From 1fd0e23708fc826451d0362a811f0d8a9203b865 Mon Sep 17 00:00:00 2001 From: obchain Date: Fri, 29 May 2026 19:30:15 +0530 Subject: [PATCH 1/5] feat(settings): surface approval history audit trail MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a read-only Approval history panel under Settings → Agent access that lists decided tool-approval requests via the existing approval_list_recent_decisions read path (merged in #2335), which had no frontend caller. Covers loading/empty/error/loaded states, a per-decision badge, refresh with a stale-response guard, and the bare-array vs {result,logs} envelope normalization. Closes #2947 --- .../settings/hooks/useSettingsNavigation.ts | 19 +++ .../settings/panels/AgentAccessPanel.tsx | 20 ++- .../settings/panels/ApprovalHistoryPanel.tsx | 155 ++++++++++++++++++ .../__tests__/ApprovalHistoryPanel.test.tsx | 111 +++++++++++++ app/src/lib/i18n/chunks/ar-5.ts | 15 ++ app/src/lib/i18n/chunks/bn-5.ts | 15 ++ app/src/lib/i18n/chunks/de-5.ts | 15 ++ app/src/lib/i18n/chunks/en-5.ts | 15 ++ app/src/lib/i18n/chunks/es-5.ts | 15 ++ app/src/lib/i18n/chunks/fr-5.ts | 15 ++ app/src/lib/i18n/chunks/hi-5.ts | 15 ++ app/src/lib/i18n/chunks/id-5.ts | 15 ++ app/src/lib/i18n/chunks/it-5.ts | 15 ++ app/src/lib/i18n/chunks/ko-5.ts | 15 ++ app/src/lib/i18n/chunks/pl-5.ts | 15 ++ app/src/lib/i18n/chunks/pt-5.ts | 15 ++ app/src/lib/i18n/chunks/ru-5.ts | 15 ++ app/src/lib/i18n/chunks/zh-CN-5.ts | 15 ++ app/src/lib/i18n/en.ts | 15 ++ app/src/pages/Settings.tsx | 2 + app/src/services/api/approvalApi.test.ts | 93 +++++++++++ app/src/services/api/approvalApi.ts | 85 ++++++++++ docs/TEST-COVERAGE-MATRIX.md | 1 + src/openhuman/about_app/catalog.rs | 12 ++ 24 files changed, 722 insertions(+), 1 deletion(-) create mode 100644 app/src/components/settings/panels/ApprovalHistoryPanel.tsx create mode 100644 app/src/components/settings/panels/__tests__/ApprovalHistoryPanel.test.tsx create mode 100644 app/src/services/api/approvalApi.test.ts create mode 100644 app/src/services/api/approvalApi.ts diff --git a/app/src/components/settings/hooks/useSettingsNavigation.ts b/app/src/components/settings/hooks/useSettingsNavigation.ts index 9f8b1abe46..c8c886dab7 100644 --- a/app/src/components/settings/hooks/useSettingsNavigation.ts +++ b/app/src/components/settings/hooks/useSettingsNavigation.ts @@ -34,6 +34,8 @@ export type SettingsRoute = | 'mascot' | 'persona' | 'appearance' + | 'agent-access' + | 'approval-history' | 'intelligence' | 'webhooks-triggers' | 'composio-triggers' @@ -119,6 +121,10 @@ export const useSettingsNavigation = (): SettingsNavigationHook => { if (path.includes('/settings/mascot')) return 'mascot'; if (path.includes('/settings/persona')) return 'persona'; if (path.includes('/settings/appearance')) return 'appearance'; + // `approval-history` must be checked before `agent-access` is irrelevant + // (distinct prefixes), but both are explicit leaf routes under Agent access. + if (path.includes('/settings/approval-history')) return 'approval-history'; + if (path.includes('/settings/agent-access')) return 'agent-access'; if (path.includes('/settings/mcp-server')) return 'mcp-server'; if (path.includes('/settings/dev-workflow')) return 'dev-workflow'; return 'home'; @@ -177,6 +183,11 @@ export const useSettingsNavigation = (): SettingsNavigationHook => { onClick: () => navigate('/settings/developer-options'), }; + const agentAccessCrumb: BreadcrumbItem = { + label: 'Agent access', + onClick: () => navigate('/settings/agent-access'), + }; + const getBreadcrumbs = (): BreadcrumbItem[] => { switch (currentRoute) { // Section pages @@ -254,6 +265,14 @@ export const useSettingsNavigation = (): SettingsNavigationHook => { case 'appearance': return [settingsCrumb]; + // Agent access panel sits at the top level of Settings. + case 'agent-access': + return [settingsCrumb]; + + // Approval history is a leaf under Agent access. + case 'approval-history': + return [settingsCrumb, agentAccessCrumb]; + case 'home': default: return []; diff --git a/app/src/components/settings/panels/AgentAccessPanel.tsx b/app/src/components/settings/panels/AgentAccessPanel.tsx index f7a85bcecc..8fdbb75581 100644 --- a/app/src/components/settings/panels/AgentAccessPanel.tsx +++ b/app/src/components/settings/panels/AgentAccessPanel.tsx @@ -26,7 +26,7 @@ interface PresetOption { const AgentAccessPanel = () => { const { t } = useT(); - const { navigateBack, breadcrumbs } = useSettingsNavigation(); + const { navigateBack, navigateToSettings, breadcrumbs } = useSettingsNavigation(); // Tier presets — built inside the component so titles/descriptions resolve // through `t()` (i18n). Order matters: it's the display order. @@ -335,6 +335,24 @@ const AgentAccessPanel = () => { )} + {/* Approval history — read-only audit trail of past decisions, + backed by the gate's durable decided-rows store. */} +
+

+ {t('settings.agentAccess.approvalHistory')} +

+

+ {t('settings.agentAccess.approvalHistoryDesc')} +

+ +
+ {/* Auto-save status — changes persist on selection; no manual save. */}
{error ? ( diff --git a/app/src/components/settings/panels/ApprovalHistoryPanel.tsx b/app/src/components/settings/panels/ApprovalHistoryPanel.tsx new file mode 100644 index 0000000000..c72be0ef44 --- /dev/null +++ b/app/src/components/settings/panels/ApprovalHistoryPanel.tsx @@ -0,0 +1,155 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; + +import { useT } from '../../../lib/i18n/I18nContext'; +import { + type ApprovalAuditEntry, + type ApprovalDecision, + fetchRecentApprovalDecisions, +} from '../../../services/api/approvalApi'; +import SettingsHeader from '../components/SettingsHeader'; +import { useSettingsNavigation } from '../hooks/useSettingsNavigation'; + +const debug = (...args: unknown[]) => { + if (import.meta.env?.DEV) { + console.debug('[ui-flow:approval-history]', ...args); + } +}; + +/** Render a decided timestamp as a locale string; fall back to the raw value. */ +const formatDateTime = (value: string): string => { + const ts = Date.parse(value); + return Number.isNaN(ts) ? value : new Date(ts).toLocaleString(); +}; + +/** Tailwind tone + i18n label key per decision variant. */ +const DECISION_TONE: Record = { + approve_once: 'bg-sage-50 text-sage ring-sage-200', + approve_always_for_tool: 'bg-sage-50 text-sage ring-sage-200', + deny: 'bg-coral-50 text-coral ring-coral-200', +}; + +const DECISION_LABEL_KEY: Record = { + approve_once: 'settings.approvalHistory.decision.approveOnce', + approve_always_for_tool: 'settings.approvalHistory.decision.approveAlways', + deny: 'settings.approvalHistory.decision.deny', +}; + +const ApprovalHistoryPanel = () => { + const { t } = useT(); + const { navigateBack, breadcrumbs } = useSettingsNavigation(); + + const [entries, setEntries] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + // Monotonic guard so an out-of-order (slower) response can't clobber a + // fresher one when the user taps Refresh rapidly (last request wins). + const loadSeqRef = useRef(0); + + // Runs the fetch and only ever calls setState AFTER the await, so it is safe + // to invoke straight from the mount effect without tripping + // react-hooks/set-state-in-effect. The synchronous spinner reset lives in the + // Refresh event handler below, where synchronous setState is expected. + const runLoad = useCallback( + async (seq: number) => { + debug('load start', { seq }); + try { + const rows = await fetchRecentApprovalDecisions(); + if (seq !== loadSeqRef.current) { + debug('stale response discarded', { seq, latest: loadSeqRef.current }); + return; + } + setEntries(rows); + setError(null); + debug('load ok', { seq, count: rows.length }); + } catch (e) { + if (seq !== loadSeqRef.current) return; + // Never leak raw backend error text into the UI; localized fallback only. + debug('load failed', e); + setError(t('settings.approvalHistory.errorGeneric')); + } finally { + if (seq === loadSeqRef.current) setIsLoading(false); + } + }, + [t] + ); + + useEffect(() => { + void runLoad(++loadSeqRef.current); + }, [runLoad]); + + const handleRefresh = () => { + setIsLoading(true); + setError(null); + void runLoad(++loadSeqRef.current); + }; + + return ( +
+ + +
+
+

{t('settings.approvalHistory.subtitle')}

+ +
+ + {isLoading ? ( +

+ {t('settings.approvalHistory.loading')} +

+ ) : error ? ( +
+

{error}

+ +
+ ) : entries.length === 0 ? ( +

+ {t('settings.approvalHistory.emptyState')} +

+ ) : ( +
    + {entries.map(entry => ( +
  • +
    + {entry.tool_name} + + {t(DECISION_LABEL_KEY[entry.decision])} + +
    +

    {entry.action_summary}

    +

    + {t('settings.approvalHistory.decidedAt')} {formatDateTime(entry.decided_at)} +

    +
  • + ))} +
+ )} +
+
+ ); +}; + +export default ApprovalHistoryPanel; diff --git a/app/src/components/settings/panels/__tests__/ApprovalHistoryPanel.test.tsx b/app/src/components/settings/panels/__tests__/ApprovalHistoryPanel.test.tsx new file mode 100644 index 0000000000..551e4f7ac9 --- /dev/null +++ b/app/src/components/settings/panels/__tests__/ApprovalHistoryPanel.test.tsx @@ -0,0 +1,111 @@ +import { fireEvent, screen, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + type ApprovalAuditEntry, + fetchRecentApprovalDecisions, +} from '../../../../services/api/approvalApi'; +import { renderWithProviders } from '../../../../test/test-utils'; +import ApprovalHistoryPanel from '../ApprovalHistoryPanel'; + +vi.mock('../../hooks/useSettingsNavigation', () => ({ + useSettingsNavigation: () => ({ + navigateBack: vi.fn(), + navigateToSettings: vi.fn(), + breadcrumbs: [], + }), +})); + +vi.mock('../../../../services/api/approvalApi', () => ({ fetchRecentApprovalDecisions: vi.fn() })); + +const mockFetch = vi.mocked(fetchRecentApprovalDecisions); + +const auditRow = (overrides: Partial = {}): ApprovalAuditEntry => ({ + request_id: 'req-1', + tool_name: 'shell', + action_summary: 'run ls -la', + args_redacted: {}, + session_id: 'sess-1', + created_at: '2026-05-29T10:00:00Z', + expires_at: null, + decided_at: '2026-05-29T10:00:05Z', + decision: 'approve_once', + ...overrides, +}); + +describe('ApprovalHistoryPanel', () => { + beforeEach(() => { + mockFetch.mockReset(); + }); + + it('renders the loaded list of decided approvals', async () => { + mockFetch.mockResolvedValueOnce([ + auditRow({ request_id: 'a', tool_name: 'shell', decision: 'approve_once' }), + auditRow({ request_id: 'b', tool_name: 'curl', decision: 'deny' }), + ]); + + renderWithProviders(, { + initialEntries: ['/settings/approval-history'], + }); + + await screen.findByTestId('approval-history-list'); + const rows = screen.getAllByTestId('approval-history-row'); + expect(rows).toHaveLength(2); + expect(screen.getByText('shell')).toBeInTheDocument(); + expect(screen.getByText('curl')).toBeInTheDocument(); + }); + + it('renders a decision badge per row', async () => { + mockFetch.mockResolvedValueOnce([ + auditRow({ request_id: 'a', decision: 'approve_always_for_tool' }), + auditRow({ request_id: 'b', decision: 'deny' }), + ]); + + renderWithProviders(, { + initialEntries: ['/settings/approval-history'], + }); + + await screen.findByTestId('approval-history-list'); + expect( + screen.getByTestId('approval-history-decision-approve_always_for_tool') + ).toBeInTheDocument(); + expect(screen.getByTestId('approval-history-decision-deny')).toBeInTheDocument(); + }); + + it('renders the empty state when there are no decisions', async () => { + mockFetch.mockResolvedValueOnce([]); + + renderWithProviders(, { + initialEntries: ['/settings/approval-history'], + }); + + await screen.findByTestId('approval-history-empty'); + expect(screen.queryByTestId('approval-history-list')).not.toBeInTheDocument(); + }); + + it('renders a localized error state when the fetch rejects', async () => { + mockFetch.mockRejectedValueOnce(new Error('boom')); + + renderWithProviders(, { + initialEntries: ['/settings/approval-history'], + }); + + const err = await screen.findByTestId('approval-history-error'); + // Raw backend text must never leak into the UI. + expect(err.textContent).not.toContain('boom'); + }); + + it('refetches when the Refresh button is clicked', async () => { + mockFetch.mockResolvedValue([auditRow()]); + + renderWithProviders(, { + initialEntries: ['/settings/approval-history'], + }); + + await screen.findByTestId('approval-history-list'); + expect(mockFetch).toHaveBeenCalledTimes(1); + + fireEvent.click(screen.getByTestId('approval-history-refresh')); + await waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(2)); + }); +}); diff --git a/app/src/lib/i18n/chunks/ar-5.ts b/app/src/lib/i18n/chunks/ar-5.ts index e9feac8611..775f720959 100644 --- a/app/src/lib/i18n/chunks/ar-5.ts +++ b/app/src/lib/i18n/chunks/ar-5.ts @@ -846,6 +846,21 @@ const ar5: TranslationMap = { 'settings.agentAccess.add': 'Add', 'settings.agentAccess.saving': 'Saving…', 'settings.agentAccess.changesApply': 'Changes apply on your next message.', + 'settings.agentAccess.approvalHistory': 'Approval history', + 'settings.agentAccess.approvalHistoryDesc': + 'Review past Approve / Deny decisions the agent requested.', + 'settings.agentAccess.viewApprovalHistory': 'View approval history', + 'settings.approvalHistory.title': 'Approval history', + 'settings.approvalHistory.subtitle': 'Recent tool-approval decisions, newest first.', + 'settings.approvalHistory.refresh': 'Refresh', + 'settings.approvalHistory.loading': 'Loading approval history…', + 'settings.approvalHistory.retry': 'Retry', + 'settings.approvalHistory.emptyState': 'No approval decisions recorded yet.', + 'settings.approvalHistory.errorGeneric': 'Unable to load approval history. Try again.', + 'settings.approvalHistory.decidedAt': 'Decided', + 'settings.approvalHistory.decision.approveOnce': 'Approved once', + 'settings.approvalHistory.decision.approveAlways': 'Always allowed', + 'settings.approvalHistory.decision.deny': 'Denied', 'skills.tabs.runners': 'Runners', 'settings.developerMenu.skillsRunner.title': 'Skills Runner', 'settings.developerMenu.skillsRunner.desc': diff --git a/app/src/lib/i18n/chunks/bn-5.ts b/app/src/lib/i18n/chunks/bn-5.ts index 552f2a32ba..7dc5a510e2 100644 --- a/app/src/lib/i18n/chunks/bn-5.ts +++ b/app/src/lib/i18n/chunks/bn-5.ts @@ -859,6 +859,21 @@ const bn5: TranslationMap = { 'settings.agentAccess.add': 'Add', 'settings.agentAccess.saving': 'Saving…', 'settings.agentAccess.changesApply': 'Changes apply on your next message.', + 'settings.agentAccess.approvalHistory': 'Approval history', + 'settings.agentAccess.approvalHistoryDesc': + 'Review past Approve / Deny decisions the agent requested.', + 'settings.agentAccess.viewApprovalHistory': 'View approval history', + 'settings.approvalHistory.title': 'Approval history', + 'settings.approvalHistory.subtitle': 'Recent tool-approval decisions, newest first.', + 'settings.approvalHistory.refresh': 'Refresh', + 'settings.approvalHistory.loading': 'Loading approval history…', + 'settings.approvalHistory.retry': 'Retry', + 'settings.approvalHistory.emptyState': 'No approval decisions recorded yet.', + 'settings.approvalHistory.errorGeneric': 'Unable to load approval history. Try again.', + 'settings.approvalHistory.decidedAt': 'Decided', + 'settings.approvalHistory.decision.approveOnce': 'Approved once', + 'settings.approvalHistory.decision.approveAlways': 'Always allowed', + 'settings.approvalHistory.decision.deny': 'Denied', 'skills.tabs.runners': 'Runners', 'settings.developerMenu.skillsRunner.title': 'Skills Runner', 'settings.developerMenu.skillsRunner.desc': diff --git a/app/src/lib/i18n/chunks/de-5.ts b/app/src/lib/i18n/chunks/de-5.ts index 42584cce6e..b97ef4cc7e 100644 --- a/app/src/lib/i18n/chunks/de-5.ts +++ b/app/src/lib/i18n/chunks/de-5.ts @@ -887,6 +887,21 @@ const de5: TranslationMap = { 'settings.agentAccess.add': 'Add', 'settings.agentAccess.saving': 'Saving…', 'settings.agentAccess.changesApply': 'Changes apply on your next message.', + 'settings.agentAccess.approvalHistory': 'Approval history', + 'settings.agentAccess.approvalHistoryDesc': + 'Review past Approve / Deny decisions the agent requested.', + 'settings.agentAccess.viewApprovalHistory': 'View approval history', + 'settings.approvalHistory.title': 'Approval history', + 'settings.approvalHistory.subtitle': 'Recent tool-approval decisions, newest first.', + 'settings.approvalHistory.refresh': 'Refresh', + 'settings.approvalHistory.loading': 'Loading approval history…', + 'settings.approvalHistory.retry': 'Retry', + 'settings.approvalHistory.emptyState': 'No approval decisions recorded yet.', + 'settings.approvalHistory.errorGeneric': 'Unable to load approval history. Try again.', + 'settings.approvalHistory.decidedAt': 'Decided', + 'settings.approvalHistory.decision.approveOnce': 'Approved once', + 'settings.approvalHistory.decision.approveAlways': 'Always allowed', + 'settings.approvalHistory.decision.deny': 'Denied', 'skills.tabs.runners': 'Runners', 'settings.developerMenu.skillsRunner.title': 'Skills Runner', 'settings.developerMenu.skillsRunner.desc': diff --git a/app/src/lib/i18n/chunks/en-5.ts b/app/src/lib/i18n/chunks/en-5.ts index d30151b1ff..651ef7f535 100644 --- a/app/src/lib/i18n/chunks/en-5.ts +++ b/app/src/lib/i18n/chunks/en-5.ts @@ -329,6 +329,21 @@ const en5: TranslationMap = { 'settings.agentAccess.add': 'Add', 'settings.agentAccess.saving': 'Saving…', 'settings.agentAccess.changesApply': 'Changes apply on your next message.', + 'settings.agentAccess.approvalHistory': 'Approval history', + 'settings.agentAccess.approvalHistoryDesc': + 'Review past Approve / Deny decisions the agent requested.', + 'settings.agentAccess.viewApprovalHistory': 'View approval history', + 'settings.approvalHistory.title': 'Approval history', + 'settings.approvalHistory.subtitle': 'Recent tool-approval decisions, newest first.', + 'settings.approvalHistory.refresh': 'Refresh', + 'settings.approvalHistory.loading': 'Loading approval history…', + 'settings.approvalHistory.retry': 'Retry', + 'settings.approvalHistory.emptyState': 'No approval decisions recorded yet.', + 'settings.approvalHistory.errorGeneric': 'Unable to load approval history. Try again.', + 'settings.approvalHistory.decidedAt': 'Decided', + 'settings.approvalHistory.decision.approveOnce': 'Approved once', + 'settings.approvalHistory.decision.approveAlways': 'Always allowed', + 'settings.approvalHistory.decision.deny': 'Denied', 'settings.mascot.active': 'Active', 'settings.mascot.characterDesc': 'Character desc', 'settings.mascot.characterHeading': 'Character heading', diff --git a/app/src/lib/i18n/chunks/es-5.ts b/app/src/lib/i18n/chunks/es-5.ts index da296192b6..3986b31954 100644 --- a/app/src/lib/i18n/chunks/es-5.ts +++ b/app/src/lib/i18n/chunks/es-5.ts @@ -873,6 +873,21 @@ const es5: TranslationMap = { 'settings.agentAccess.add': 'Add', 'settings.agentAccess.saving': 'Saving…', 'settings.agentAccess.changesApply': 'Changes apply on your next message.', + 'settings.agentAccess.approvalHistory': 'Approval history', + 'settings.agentAccess.approvalHistoryDesc': + 'Review past Approve / Deny decisions the agent requested.', + 'settings.agentAccess.viewApprovalHistory': 'View approval history', + 'settings.approvalHistory.title': 'Approval history', + 'settings.approvalHistory.subtitle': 'Recent tool-approval decisions, newest first.', + 'settings.approvalHistory.refresh': 'Refresh', + 'settings.approvalHistory.loading': 'Loading approval history…', + 'settings.approvalHistory.retry': 'Retry', + 'settings.approvalHistory.emptyState': 'No approval decisions recorded yet.', + 'settings.approvalHistory.errorGeneric': 'Unable to load approval history. Try again.', + 'settings.approvalHistory.decidedAt': 'Decided', + 'settings.approvalHistory.decision.approveOnce': 'Approved once', + 'settings.approvalHistory.decision.approveAlways': 'Always allowed', + 'settings.approvalHistory.decision.deny': 'Denied', 'skills.tabs.runners': 'Runners', 'settings.developerMenu.skillsRunner.title': 'Skills Runner', 'settings.developerMenu.skillsRunner.desc': diff --git a/app/src/lib/i18n/chunks/fr-5.ts b/app/src/lib/i18n/chunks/fr-5.ts index 44cbf461ae..5bdc7dc4c4 100644 --- a/app/src/lib/i18n/chunks/fr-5.ts +++ b/app/src/lib/i18n/chunks/fr-5.ts @@ -877,6 +877,21 @@ const fr5: TranslationMap = { 'settings.agentAccess.add': 'Add', 'settings.agentAccess.saving': 'Saving…', 'settings.agentAccess.changesApply': 'Changes apply on your next message.', + 'settings.agentAccess.approvalHistory': 'Approval history', + 'settings.agentAccess.approvalHistoryDesc': + 'Review past Approve / Deny decisions the agent requested.', + 'settings.agentAccess.viewApprovalHistory': 'View approval history', + 'settings.approvalHistory.title': 'Approval history', + 'settings.approvalHistory.subtitle': 'Recent tool-approval decisions, newest first.', + 'settings.approvalHistory.refresh': 'Refresh', + 'settings.approvalHistory.loading': 'Loading approval history…', + 'settings.approvalHistory.retry': 'Retry', + 'settings.approvalHistory.emptyState': 'No approval decisions recorded yet.', + 'settings.approvalHistory.errorGeneric': 'Unable to load approval history. Try again.', + 'settings.approvalHistory.decidedAt': 'Decided', + 'settings.approvalHistory.decision.approveOnce': 'Approved once', + 'settings.approvalHistory.decision.approveAlways': 'Always allowed', + 'settings.approvalHistory.decision.deny': 'Denied', 'skills.tabs.runners': 'Runners', 'settings.developerMenu.skillsRunner.title': 'Skills Runner', 'settings.developerMenu.skillsRunner.desc': diff --git a/app/src/lib/i18n/chunks/hi-5.ts b/app/src/lib/i18n/chunks/hi-5.ts index 0a0c9393d7..2f5ac9d625 100644 --- a/app/src/lib/i18n/chunks/hi-5.ts +++ b/app/src/lib/i18n/chunks/hi-5.ts @@ -860,6 +860,21 @@ const hi5: TranslationMap = { 'settings.agentAccess.add': 'Add', 'settings.agentAccess.saving': 'Saving…', 'settings.agentAccess.changesApply': 'Changes apply on your next message.', + 'settings.agentAccess.approvalHistory': 'Approval history', + 'settings.agentAccess.approvalHistoryDesc': + 'Review past Approve / Deny decisions the agent requested.', + 'settings.agentAccess.viewApprovalHistory': 'View approval history', + 'settings.approvalHistory.title': 'Approval history', + 'settings.approvalHistory.subtitle': 'Recent tool-approval decisions, newest first.', + 'settings.approvalHistory.refresh': 'Refresh', + 'settings.approvalHistory.loading': 'Loading approval history…', + 'settings.approvalHistory.retry': 'Retry', + 'settings.approvalHistory.emptyState': 'No approval decisions recorded yet.', + 'settings.approvalHistory.errorGeneric': 'Unable to load approval history. Try again.', + 'settings.approvalHistory.decidedAt': 'Decided', + 'settings.approvalHistory.decision.approveOnce': 'Approved once', + 'settings.approvalHistory.decision.approveAlways': 'Always allowed', + 'settings.approvalHistory.decision.deny': 'Denied', 'skills.tabs.runners': 'Runners', 'settings.developerMenu.skillsRunner.title': 'Skills Runner', 'settings.developerMenu.skillsRunner.desc': diff --git a/app/src/lib/i18n/chunks/id-5.ts b/app/src/lib/i18n/chunks/id-5.ts index d190fe7386..be78c608bc 100644 --- a/app/src/lib/i18n/chunks/id-5.ts +++ b/app/src/lib/i18n/chunks/id-5.ts @@ -860,6 +860,21 @@ const id5: TranslationMap = { 'settings.agentAccess.add': 'Add', 'settings.agentAccess.saving': 'Saving…', 'settings.agentAccess.changesApply': 'Changes apply on your next message.', + 'settings.agentAccess.approvalHistory': 'Approval history', + 'settings.agentAccess.approvalHistoryDesc': + 'Review past Approve / Deny decisions the agent requested.', + 'settings.agentAccess.viewApprovalHistory': 'View approval history', + 'settings.approvalHistory.title': 'Approval history', + 'settings.approvalHistory.subtitle': 'Recent tool-approval decisions, newest first.', + 'settings.approvalHistory.refresh': 'Refresh', + 'settings.approvalHistory.loading': 'Loading approval history…', + 'settings.approvalHistory.retry': 'Retry', + 'settings.approvalHistory.emptyState': 'No approval decisions recorded yet.', + 'settings.approvalHistory.errorGeneric': 'Unable to load approval history. Try again.', + 'settings.approvalHistory.decidedAt': 'Decided', + 'settings.approvalHistory.decision.approveOnce': 'Approved once', + 'settings.approvalHistory.decision.approveAlways': 'Always allowed', + 'settings.approvalHistory.decision.deny': 'Denied', 'skills.tabs.runners': 'Runners', 'settings.developerMenu.skillsRunner.title': 'Skills Runner', 'settings.developerMenu.skillsRunner.desc': diff --git a/app/src/lib/i18n/chunks/it-5.ts b/app/src/lib/i18n/chunks/it-5.ts index 58d965e4ca..cbe8c1796f 100644 --- a/app/src/lib/i18n/chunks/it-5.ts +++ b/app/src/lib/i18n/chunks/it-5.ts @@ -871,6 +871,21 @@ const it5: TranslationMap = { 'settings.agentAccess.add': 'Add', 'settings.agentAccess.saving': 'Saving…', 'settings.agentAccess.changesApply': 'Changes apply on your next message.', + 'settings.agentAccess.approvalHistory': 'Approval history', + 'settings.agentAccess.approvalHistoryDesc': + 'Review past Approve / Deny decisions the agent requested.', + 'settings.agentAccess.viewApprovalHistory': 'View approval history', + 'settings.approvalHistory.title': 'Approval history', + 'settings.approvalHistory.subtitle': 'Recent tool-approval decisions, newest first.', + 'settings.approvalHistory.refresh': 'Refresh', + 'settings.approvalHistory.loading': 'Loading approval history…', + 'settings.approvalHistory.retry': 'Retry', + 'settings.approvalHistory.emptyState': 'No approval decisions recorded yet.', + 'settings.approvalHistory.errorGeneric': 'Unable to load approval history. Try again.', + 'settings.approvalHistory.decidedAt': 'Decided', + 'settings.approvalHistory.decision.approveOnce': 'Approved once', + 'settings.approvalHistory.decision.approveAlways': 'Always allowed', + 'settings.approvalHistory.decision.deny': 'Denied', 'skills.tabs.runners': 'Runners', 'settings.developerMenu.skillsRunner.title': 'Skills Runner', 'settings.developerMenu.skillsRunner.desc': diff --git a/app/src/lib/i18n/chunks/ko-5.ts b/app/src/lib/i18n/chunks/ko-5.ts index 6ac1b084b9..ba21a0d061 100644 --- a/app/src/lib/i18n/chunks/ko-5.ts +++ b/app/src/lib/i18n/chunks/ko-5.ts @@ -849,6 +849,21 @@ const ko5: TranslationMap = { 'settings.agentAccess.add': 'Add', 'settings.agentAccess.saving': 'Saving…', 'settings.agentAccess.changesApply': 'Changes apply on your next message.', + 'settings.agentAccess.approvalHistory': 'Approval history', + 'settings.agentAccess.approvalHistoryDesc': + 'Review past Approve / Deny decisions the agent requested.', + 'settings.agentAccess.viewApprovalHistory': 'View approval history', + 'settings.approvalHistory.title': 'Approval history', + 'settings.approvalHistory.subtitle': 'Recent tool-approval decisions, newest first.', + 'settings.approvalHistory.refresh': 'Refresh', + 'settings.approvalHistory.loading': 'Loading approval history…', + 'settings.approvalHistory.retry': 'Retry', + 'settings.approvalHistory.emptyState': 'No approval decisions recorded yet.', + 'settings.approvalHistory.errorGeneric': 'Unable to load approval history. Try again.', + 'settings.approvalHistory.decidedAt': 'Decided', + 'settings.approvalHistory.decision.approveOnce': 'Approved once', + 'settings.approvalHistory.decision.approveAlways': 'Always allowed', + 'settings.approvalHistory.decision.deny': 'Denied', 'skills.tabs.runners': 'Runners', 'settings.developerMenu.skillsRunner.title': 'Skills Runner', 'settings.developerMenu.skillsRunner.desc': diff --git a/app/src/lib/i18n/chunks/pl-5.ts b/app/src/lib/i18n/chunks/pl-5.ts index 6bb8696ea6..37b3ce9515 100644 --- a/app/src/lib/i18n/chunks/pl-5.ts +++ b/app/src/lib/i18n/chunks/pl-5.ts @@ -320,6 +320,21 @@ const pl5: TranslationMap = { 'settings.agentAccess.add': 'Dodaj', 'settings.agentAccess.saving': 'Zapisywanie…', 'settings.agentAccess.changesApply': 'Zmiany zostaną zastosowane w następnej wiadomości.', + 'settings.agentAccess.approvalHistory': 'Approval history', + 'settings.agentAccess.approvalHistoryDesc': + 'Review past Approve / Deny decisions the agent requested.', + 'settings.agentAccess.viewApprovalHistory': 'View approval history', + 'settings.approvalHistory.title': 'Approval history', + 'settings.approvalHistory.subtitle': 'Recent tool-approval decisions, newest first.', + 'settings.approvalHistory.refresh': 'Refresh', + 'settings.approvalHistory.loading': 'Loading approval history…', + 'settings.approvalHistory.retry': 'Retry', + 'settings.approvalHistory.emptyState': 'No approval decisions recorded yet.', + 'settings.approvalHistory.errorGeneric': 'Unable to load approval history. Try again.', + 'settings.approvalHistory.decidedAt': 'Decided', + 'settings.approvalHistory.decision.approveOnce': 'Approved once', + 'settings.approvalHistory.decision.approveAlways': 'Always allowed', + 'settings.approvalHistory.decision.deny': 'Denied', // settings.mascot 'settings.mascot.active': 'Aktywny', 'settings.mascot.characterDesc': 'Wybierz charakter maskotki OpenHuman.', diff --git a/app/src/lib/i18n/chunks/pt-5.ts b/app/src/lib/i18n/chunks/pt-5.ts index 57dbfeb88d..f124476c61 100644 --- a/app/src/lib/i18n/chunks/pt-5.ts +++ b/app/src/lib/i18n/chunks/pt-5.ts @@ -870,6 +870,21 @@ const pt5: TranslationMap = { 'settings.agentAccess.add': 'Add', 'settings.agentAccess.saving': 'Saving…', 'settings.agentAccess.changesApply': 'Changes apply on your next message.', + 'settings.agentAccess.approvalHistory': 'Approval history', + 'settings.agentAccess.approvalHistoryDesc': + 'Review past Approve / Deny decisions the agent requested.', + 'settings.agentAccess.viewApprovalHistory': 'View approval history', + 'settings.approvalHistory.title': 'Approval history', + 'settings.approvalHistory.subtitle': 'Recent tool-approval decisions, newest first.', + 'settings.approvalHistory.refresh': 'Refresh', + 'settings.approvalHistory.loading': 'Loading approval history…', + 'settings.approvalHistory.retry': 'Retry', + 'settings.approvalHistory.emptyState': 'No approval decisions recorded yet.', + 'settings.approvalHistory.errorGeneric': 'Unable to load approval history. Try again.', + 'settings.approvalHistory.decidedAt': 'Decided', + 'settings.approvalHistory.decision.approveOnce': 'Approved once', + 'settings.approvalHistory.decision.approveAlways': 'Always allowed', + 'settings.approvalHistory.decision.deny': 'Denied', 'skills.tabs.runners': 'Runners', 'settings.developerMenu.skillsRunner.title': 'Skills Runner', 'settings.developerMenu.skillsRunner.desc': diff --git a/app/src/lib/i18n/chunks/ru-5.ts b/app/src/lib/i18n/chunks/ru-5.ts index edd6707c54..6234377eb9 100644 --- a/app/src/lib/i18n/chunks/ru-5.ts +++ b/app/src/lib/i18n/chunks/ru-5.ts @@ -866,6 +866,21 @@ const ru5: TranslationMap = { 'settings.agentAccess.add': 'Add', 'settings.agentAccess.saving': 'Saving…', 'settings.agentAccess.changesApply': 'Changes apply on your next message.', + 'settings.agentAccess.approvalHistory': 'Approval history', + 'settings.agentAccess.approvalHistoryDesc': + 'Review past Approve / Deny decisions the agent requested.', + 'settings.agentAccess.viewApprovalHistory': 'View approval history', + 'settings.approvalHistory.title': 'Approval history', + 'settings.approvalHistory.subtitle': 'Recent tool-approval decisions, newest first.', + 'settings.approvalHistory.refresh': 'Refresh', + 'settings.approvalHistory.loading': 'Loading approval history…', + 'settings.approvalHistory.retry': 'Retry', + 'settings.approvalHistory.emptyState': 'No approval decisions recorded yet.', + 'settings.approvalHistory.errorGeneric': 'Unable to load approval history. Try again.', + 'settings.approvalHistory.decidedAt': 'Decided', + 'settings.approvalHistory.decision.approveOnce': 'Approved once', + 'settings.approvalHistory.decision.approveAlways': 'Always allowed', + 'settings.approvalHistory.decision.deny': 'Denied', 'skills.tabs.runners': 'Runners', 'settings.developerMenu.skillsRunner.title': 'Skills Runner', 'settings.developerMenu.skillsRunner.desc': diff --git a/app/src/lib/i18n/chunks/zh-CN-5.ts b/app/src/lib/i18n/chunks/zh-CN-5.ts index 866cd04396..9a1d72f823 100644 --- a/app/src/lib/i18n/chunks/zh-CN-5.ts +++ b/app/src/lib/i18n/chunks/zh-CN-5.ts @@ -820,6 +820,21 @@ const zhCN5: TranslationMap = { 'settings.agentAccess.add': 'Add', 'settings.agentAccess.saving': 'Saving…', 'settings.agentAccess.changesApply': 'Changes apply on your next message.', + 'settings.agentAccess.approvalHistory': 'Approval history', + 'settings.agentAccess.approvalHistoryDesc': + 'Review past Approve / Deny decisions the agent requested.', + 'settings.agentAccess.viewApprovalHistory': 'View approval history', + 'settings.approvalHistory.title': 'Approval history', + 'settings.approvalHistory.subtitle': 'Recent tool-approval decisions, newest first.', + 'settings.approvalHistory.refresh': 'Refresh', + 'settings.approvalHistory.loading': 'Loading approval history…', + 'settings.approvalHistory.retry': 'Retry', + 'settings.approvalHistory.emptyState': 'No approval decisions recorded yet.', + 'settings.approvalHistory.errorGeneric': 'Unable to load approval history. Try again.', + 'settings.approvalHistory.decidedAt': 'Decided', + 'settings.approvalHistory.decision.approveOnce': 'Approved once', + 'settings.approvalHistory.decision.approveAlways': 'Always allowed', + 'settings.approvalHistory.decision.deny': 'Denied', 'skills.tabs.runners': 'Runners', 'settings.developerMenu.skillsRunner.title': 'Skills Runner', 'settings.developerMenu.skillsRunner.desc': diff --git a/app/src/lib/i18n/en.ts b/app/src/lib/i18n/en.ts index 8417f4deb2..828d60b76b 100644 --- a/app/src/lib/i18n/en.ts +++ b/app/src/lib/i18n/en.ts @@ -3487,6 +3487,21 @@ const en: TranslationMap = { 'settings.agentAccess.add': 'Add', 'settings.agentAccess.saving': 'Saving…', 'settings.agentAccess.changesApply': 'Changes apply on your next message.', + 'settings.agentAccess.approvalHistory': 'Approval history', + 'settings.agentAccess.approvalHistoryDesc': + 'Review past Approve / Deny decisions the agent requested.', + 'settings.agentAccess.viewApprovalHistory': 'View approval history', + 'settings.approvalHistory.title': 'Approval history', + 'settings.approvalHistory.subtitle': 'Recent tool-approval decisions, newest first.', + 'settings.approvalHistory.refresh': 'Refresh', + 'settings.approvalHistory.loading': 'Loading approval history…', + 'settings.approvalHistory.retry': 'Retry', + 'settings.approvalHistory.emptyState': 'No approval decisions recorded yet.', + 'settings.approvalHistory.errorGeneric': 'Unable to load approval history. Try again.', + 'settings.approvalHistory.decidedAt': 'Decided', + 'settings.approvalHistory.decision.approveOnce': 'Approved once', + 'settings.approvalHistory.decision.approveAlways': 'Always allowed', + 'settings.approvalHistory.decision.deny': 'Denied', 'settings.appearance.title': 'Appearance', 'settings.appearance.themeHeading': 'Theme', 'settings.appearance.themeAria': 'Theme', diff --git a/app/src/pages/Settings.tsx b/app/src/pages/Settings.tsx index 696992c416..782ca9b3c4 100644 --- a/app/src/pages/Settings.tsx +++ b/app/src/pages/Settings.tsx @@ -8,6 +8,7 @@ import AgentAccessPanel from '../components/settings/panels/AgentAccessPanel'; import AgentChatPanel from '../components/settings/panels/AgentChatPanel'; import AIPanel from '../components/settings/panels/AIPanel'; import AppearancePanel from '../components/settings/panels/AppearancePanel'; +import ApprovalHistoryPanel from '../components/settings/panels/ApprovalHistoryPanel'; import AutocompleteDebugPanel from '../components/settings/panels/AutocompleteDebugPanel'; import AutocompletePanel from '../components/settings/panels/AutocompletePanel'; import AutonomyPanel from '../components/settings/panels/AutonomyPanel'; @@ -426,6 +427,7 @@ const Settings = () => { )} /> )} /> )} /> + )} /> )} /> )} /> {/* Developer Options */} diff --git a/app/src/services/api/approvalApi.test.ts b/app/src/services/api/approvalApi.test.ts new file mode 100644 index 0000000000..fa6722ba9e --- /dev/null +++ b/app/src/services/api/approvalApi.test.ts @@ -0,0 +1,93 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + type ApprovalAuditEntry, + fetchPendingApprovals, + fetchRecentApprovalDecisions, + unwrapRows, +} from './approvalApi'; + +const mockCallCoreRpc = vi.fn(); + +vi.mock('../coreRpcClient', () => ({ + callCoreRpc: (...args: unknown[]) => mockCallCoreRpc(...args), +})); + +const auditRow = (overrides: Partial = {}): ApprovalAuditEntry => ({ + request_id: 'req-1', + tool_name: 'shell', + action_summary: 'run ls', + args_redacted: {}, + session_id: 'sess-1', + created_at: '2026-05-29T10:00:00Z', + expires_at: null, + decided_at: '2026-05-29T10:00:05Z', + decision: 'approve_once', + ...overrides, +}); + +describe('unwrapRows', () => { + it('returns a bare array as-is (gate absent path)', () => { + expect(unwrapRows([1, 2, 3])).toEqual([1, 2, 3]); + }); + + it('unwraps the {result, logs} envelope (gate installed path)', () => { + expect(unwrapRows({ result: [{ a: 1 }], logs: ['note'] })).toEqual([{ a: 1 }]); + }); + + it('returns [] for null / non-array / malformed shapes rather than throwing', () => { + expect(unwrapRows(null)).toEqual([]); + expect(unwrapRows(undefined)).toEqual([]); + expect(unwrapRows({ result: 'nope' })).toEqual([]); + expect(unwrapRows(42)).toEqual([]); + }); +}); + +describe('fetchRecentApprovalDecisions', () => { + beforeEach(() => mockCallCoreRpc.mockReset()); + + it('calls the correct method with no params when limit omitted', async () => { + mockCallCoreRpc.mockResolvedValueOnce({ result: [auditRow()], logs: ['x'] }); + + const rows = await fetchRecentApprovalDecisions(); + + expect(mockCallCoreRpc).toHaveBeenCalledWith({ + method: 'openhuman.approval_list_recent_decisions', + params: {}, + }); + expect(rows).toHaveLength(1); + expect(rows[0].decision).toBe('approve_once'); + }); + + it('forwards an explicit limit', async () => { + mockCallCoreRpc.mockResolvedValueOnce([]); + + await fetchRecentApprovalDecisions(10); + + expect(mockCallCoreRpc).toHaveBeenCalledWith({ + method: 'openhuman.approval_list_recent_decisions', + params: { limit: 10 }, + }); + }); + + it('normalizes a bare-array response (gate absent)', async () => { + mockCallCoreRpc.mockResolvedValueOnce([]); + expect(await fetchRecentApprovalDecisions()).toEqual([]); + }); +}); + +describe('fetchPendingApprovals', () => { + beforeEach(() => mockCallCoreRpc.mockReset()); + + it('calls the pending method and unwraps the envelope', async () => { + mockCallCoreRpc.mockResolvedValueOnce({ + result: [{ request_id: 'p-1', tool_name: 'curl' }], + logs: ['1 row'], + }); + + const rows = await fetchPendingApprovals(); + + expect(mockCallCoreRpc).toHaveBeenCalledWith({ method: 'openhuman.approval_list_pending' }); + expect(rows[0].request_id).toBe('p-1'); + }); +}); diff --git a/app/src/services/api/approvalApi.ts b/app/src/services/api/approvalApi.ts new file mode 100644 index 0000000000..b24eef7750 --- /dev/null +++ b/app/src/services/api/approvalApi.ts @@ -0,0 +1,85 @@ +import { callCoreRpc } from '../coreRpcClient'; + +// --------------------------------------------------------------------------- +// Approval audit / history read client. +// +// Surfaces the read paths added in PR #2335 (`approval_list_recent_decisions`) +// and the live `approval_list_pending` queue. Both are exposed by the core's +// approval gate through the controller registry; this client only READS them — +// decisions still flow through `openhuman.approval_decide` (ApprovalRequestCard). +// +// Wire-shape note: both RPCs return an `RpcOutcome` with a single diagnostic +// log line when the gate is installed, so the JSON-RPC `result` is the +// CLI-compatible envelope `{ result: [...rows], logs: [...] }`. When the gate +// is NOT installed the core returns a bare `[]`. `unwrapRows` normalizes both. +// --------------------------------------------------------------------------- + +/** User's decision on a pending approval (mirrors Rust `ApprovalDecision`). */ +export type ApprovalDecision = 'approve_once' | 'approve_always_for_tool' | 'deny'; + +/** A pending approval awaiting a decision (mirrors Rust `PendingApproval`). */ +export interface PendingApproval { + request_id: string; + tool_name: string; + /** Short human-readable summary, scrubbed of PII / chat content. */ + action_summary: string; + /** Redacted JSON arguments — counts/shape only, no raw message bodies. */ + args_redacted: unknown; + session_id: string; + /** RFC3339 timestamp. */ + created_at: string; + /** RFC3339 timestamp, or null when the request does not expire. */ + expires_at: string | null; +} + +/** A decided approval audit row (mirrors Rust `ApprovalAuditEntry`). */ +export interface ApprovalAuditEntry { + request_id: string; + tool_name: string; + action_summary: string; + args_redacted: unknown; + session_id: string; + created_at: string; + expires_at: string | null; + /** RFC3339 timestamp the decision was recorded. */ + decided_at: string; + decision: ApprovalDecision; +} + +/** + * Normalize the two possible wire shapes into a plain row array: + * - gate installed → `{ result: T[], logs: string[] }` + * - gate absent → bare `T[]` + * Anything else (unexpected) collapses to an empty array rather than throwing, + * so a degraded core can never blank the whole settings screen. + */ +export const unwrapRows = (raw: unknown): T[] => { + if (Array.isArray(raw)) return raw as T[]; + if (raw && typeof raw === 'object' && Array.isArray((raw as { result?: unknown }).result)) { + return (raw as { result: T[] }).result; + } + return []; +}; + +/** Default page size matching the core's `list_recent_decisions` default. */ +export const DEFAULT_APPROVAL_HISTORY_LIMIT = 50; + +/** + * Fetch recently decided approval rows for the audit/history surface. + * `limit` is clamped core-side; omit to use the core default (50). + */ +export const fetchRecentApprovalDecisions = async ( + limit?: number +): Promise => { + const raw = await callCoreRpc({ + method: 'openhuman.approval_list_recent_decisions', + params: limit === undefined ? {} : { limit }, + }); + return unwrapRows(raw); +}; + +/** Fetch the live queue of pending (undecided) approvals. */ +export const fetchPendingApprovals = async (): Promise => { + const raw = await callCoreRpc({ method: 'openhuman.approval_list_pending' }); + return unwrapRows(raw); +}; diff --git a/docs/TEST-COVERAGE-MATRIX.md b/docs/TEST-COVERAGE-MATRIX.md index 7260dc33f9..cc76241c66 100644 --- a/docs/TEST-COVERAGE-MATRIX.md +++ b/docs/TEST-COVERAGE-MATRIX.md @@ -467,6 +467,7 @@ Canonical mapping of every product feature to its test source(s). Drives gap-fil | 13.1.1 | Profile Management | VU | `app/src/components/settings/panels/__tests__/PrivacyPanel.test.tsx` | 🟡 | | | 13.1.2 | Linked Accounts | WD | `auth-access-control.spec.ts` | 🟡 | UI surface unasserted | | 13.1.3 | Meet Handoff Prompt-Injection Guard | VU | `app/src/services/__tests__/webviewAccountService.meetPromptInjection.test.ts` (this PR) | ✅ | Was ❌ — guard blocks handoff on hostile transcripts and wraps non-blocked transcripts in `` delimiters (#1920) | +| 13.1.4 | Approval History | VU | `app/src/components/settings/panels/__tests__/ApprovalHistoryPanel.test.tsx`, `app/src/services/api/approvalApi.test.ts` (this PR) | ✅ | Was ❌ — read-only audit surface over `approval_list_recent_decisions`; covers loaded/empty/error/refresh states, per-decision badge, and the bare-array vs `{result,logs}` envelope normalization | ### 13.2 Automation & Channels diff --git a/src/openhuman/about_app/catalog.rs b/src/openhuman/about_app/catalog.rs index a840ec57a5..46ea1956a6 100644 --- a/src/openhuman/about_app/catalog.rs +++ b/src/openhuman/about_app/catalog.rs @@ -1397,6 +1397,18 @@ const CAPABILITIES: &[Capability] = &[ status: CapabilityStatus::Stable, privacy: None, }, + Capability { + id: "security.approval_history", + name: "Approval History", + domain: "security", + category: CapabilityCategory::Settings, + description: "Review a read-only audit trail of past tool-approval decisions \ + (Approve once / Always allow / Deny), newest first. Summaries are \ + scrubbed of chat content and arguments are shown as redacted shape only.", + how_to: "Settings → Agent OS access → View approval history", + status: CapabilityStatus::Stable, + privacy: None, + }, Capability { id: "tool.detect_tools", name: "Detect Installed Tools", From c2cdea96a64852ac550fe6f97985a07bd06a33dd Mon Sep 17 00:00:00 2001 From: obchain Date: Sat, 30 May 2026 00:39:38 +0530 Subject: [PATCH 2/5] fix(settings): address review on approval history panel - Replace the import.meta.env DEV-gated console.debug helper with the shared `debug` namespace logger (repo convention; no direct import.meta.env access). - Localize the "decided at" line as a single {date}-placeholder string instead of concatenating, so locales control word order. --- .../settings/panels/ApprovalHistoryPanel.tsx | 20 +++++++++---------- app/src/lib/i18n/chunks/ar-5.ts | 2 +- app/src/lib/i18n/chunks/bn-5.ts | 2 +- app/src/lib/i18n/chunks/de-5.ts | 2 +- app/src/lib/i18n/chunks/en-5.ts | 2 +- app/src/lib/i18n/chunks/es-5.ts | 2 +- app/src/lib/i18n/chunks/fr-5.ts | 2 +- app/src/lib/i18n/chunks/hi-5.ts | 2 +- app/src/lib/i18n/chunks/id-5.ts | 2 +- app/src/lib/i18n/chunks/it-5.ts | 2 +- app/src/lib/i18n/chunks/ko-5.ts | 2 +- app/src/lib/i18n/chunks/pl-5.ts | 2 +- app/src/lib/i18n/chunks/pt-5.ts | 2 +- app/src/lib/i18n/chunks/ru-5.ts | 2 +- app/src/lib/i18n/chunks/zh-CN-5.ts | 2 +- app/src/lib/i18n/en.ts | 2 +- 16 files changed, 25 insertions(+), 25 deletions(-) diff --git a/app/src/components/settings/panels/ApprovalHistoryPanel.tsx b/app/src/components/settings/panels/ApprovalHistoryPanel.tsx index c72be0ef44..beac6d4a70 100644 --- a/app/src/components/settings/panels/ApprovalHistoryPanel.tsx +++ b/app/src/components/settings/panels/ApprovalHistoryPanel.tsx @@ -1,3 +1,4 @@ +import debug from 'debug'; import { useCallback, useEffect, useRef, useState } from 'react'; import { useT } from '../../../lib/i18n/I18nContext'; @@ -9,11 +10,7 @@ import { import SettingsHeader from '../components/SettingsHeader'; import { useSettingsNavigation } from '../hooks/useSettingsNavigation'; -const debug = (...args: unknown[]) => { - if (import.meta.env?.DEV) { - console.debug('[ui-flow:approval-history]', ...args); - } -}; +const log = debug('ui:approval-history'); /** Render a decided timestamp as a locale string; fall back to the raw value. */ const formatDateTime = (value: string): string => { @@ -52,20 +49,20 @@ const ApprovalHistoryPanel = () => { // Refresh event handler below, where synchronous setState is expected. const runLoad = useCallback( async (seq: number) => { - debug('load start', { seq }); + log('load start %o', { seq }); try { const rows = await fetchRecentApprovalDecisions(); if (seq !== loadSeqRef.current) { - debug('stale response discarded', { seq, latest: loadSeqRef.current }); + log('stale response discarded %o', { seq, latest: loadSeqRef.current }); return; } setEntries(rows); setError(null); - debug('load ok', { seq, count: rows.length }); + log('load ok %o', { seq, count: rows.length }); } catch (e) { if (seq !== loadSeqRef.current) return; // Never leak raw backend error text into the UI; localized fallback only. - debug('load failed', e); + log('load failed %o', e); setError(t('settings.approvalHistory.errorGeneric')); } finally { if (seq === loadSeqRef.current) setIsLoading(false); @@ -141,7 +138,10 @@ const ApprovalHistoryPanel = () => {

{entry.action_summary}

- {t('settings.approvalHistory.decidedAt')} {formatDateTime(entry.decided_at)} + {t('settings.approvalHistory.decidedAt').replace( + '{date}', + formatDateTime(entry.decided_at) + )}

))} diff --git a/app/src/lib/i18n/chunks/ar-5.ts b/app/src/lib/i18n/chunks/ar-5.ts index 775f720959..d8135db930 100644 --- a/app/src/lib/i18n/chunks/ar-5.ts +++ b/app/src/lib/i18n/chunks/ar-5.ts @@ -857,7 +857,7 @@ const ar5: TranslationMap = { 'settings.approvalHistory.retry': 'Retry', 'settings.approvalHistory.emptyState': 'No approval decisions recorded yet.', 'settings.approvalHistory.errorGeneric': 'Unable to load approval history. Try again.', - 'settings.approvalHistory.decidedAt': 'Decided', + 'settings.approvalHistory.decidedAt': 'Decided {date}', 'settings.approvalHistory.decision.approveOnce': 'Approved once', 'settings.approvalHistory.decision.approveAlways': 'Always allowed', 'settings.approvalHistory.decision.deny': 'Denied', diff --git a/app/src/lib/i18n/chunks/bn-5.ts b/app/src/lib/i18n/chunks/bn-5.ts index 7dc5a510e2..21f4515cb7 100644 --- a/app/src/lib/i18n/chunks/bn-5.ts +++ b/app/src/lib/i18n/chunks/bn-5.ts @@ -870,7 +870,7 @@ const bn5: TranslationMap = { 'settings.approvalHistory.retry': 'Retry', 'settings.approvalHistory.emptyState': 'No approval decisions recorded yet.', 'settings.approvalHistory.errorGeneric': 'Unable to load approval history. Try again.', - 'settings.approvalHistory.decidedAt': 'Decided', + 'settings.approvalHistory.decidedAt': 'Decided {date}', 'settings.approvalHistory.decision.approveOnce': 'Approved once', 'settings.approvalHistory.decision.approveAlways': 'Always allowed', 'settings.approvalHistory.decision.deny': 'Denied', diff --git a/app/src/lib/i18n/chunks/de-5.ts b/app/src/lib/i18n/chunks/de-5.ts index b97ef4cc7e..537c70b833 100644 --- a/app/src/lib/i18n/chunks/de-5.ts +++ b/app/src/lib/i18n/chunks/de-5.ts @@ -898,7 +898,7 @@ const de5: TranslationMap = { 'settings.approvalHistory.retry': 'Retry', 'settings.approvalHistory.emptyState': 'No approval decisions recorded yet.', 'settings.approvalHistory.errorGeneric': 'Unable to load approval history. Try again.', - 'settings.approvalHistory.decidedAt': 'Decided', + 'settings.approvalHistory.decidedAt': 'Decided {date}', 'settings.approvalHistory.decision.approveOnce': 'Approved once', 'settings.approvalHistory.decision.approveAlways': 'Always allowed', 'settings.approvalHistory.decision.deny': 'Denied', diff --git a/app/src/lib/i18n/chunks/en-5.ts b/app/src/lib/i18n/chunks/en-5.ts index 651ef7f535..846fc171fb 100644 --- a/app/src/lib/i18n/chunks/en-5.ts +++ b/app/src/lib/i18n/chunks/en-5.ts @@ -340,7 +340,7 @@ const en5: TranslationMap = { 'settings.approvalHistory.retry': 'Retry', 'settings.approvalHistory.emptyState': 'No approval decisions recorded yet.', 'settings.approvalHistory.errorGeneric': 'Unable to load approval history. Try again.', - 'settings.approvalHistory.decidedAt': 'Decided', + 'settings.approvalHistory.decidedAt': 'Decided {date}', 'settings.approvalHistory.decision.approveOnce': 'Approved once', 'settings.approvalHistory.decision.approveAlways': 'Always allowed', 'settings.approvalHistory.decision.deny': 'Denied', diff --git a/app/src/lib/i18n/chunks/es-5.ts b/app/src/lib/i18n/chunks/es-5.ts index 3986b31954..7416236415 100644 --- a/app/src/lib/i18n/chunks/es-5.ts +++ b/app/src/lib/i18n/chunks/es-5.ts @@ -884,7 +884,7 @@ const es5: TranslationMap = { 'settings.approvalHistory.retry': 'Retry', 'settings.approvalHistory.emptyState': 'No approval decisions recorded yet.', 'settings.approvalHistory.errorGeneric': 'Unable to load approval history. Try again.', - 'settings.approvalHistory.decidedAt': 'Decided', + 'settings.approvalHistory.decidedAt': 'Decided {date}', 'settings.approvalHistory.decision.approveOnce': 'Approved once', 'settings.approvalHistory.decision.approveAlways': 'Always allowed', 'settings.approvalHistory.decision.deny': 'Denied', diff --git a/app/src/lib/i18n/chunks/fr-5.ts b/app/src/lib/i18n/chunks/fr-5.ts index 5bdc7dc4c4..82d3eb7d63 100644 --- a/app/src/lib/i18n/chunks/fr-5.ts +++ b/app/src/lib/i18n/chunks/fr-5.ts @@ -888,7 +888,7 @@ const fr5: TranslationMap = { 'settings.approvalHistory.retry': 'Retry', 'settings.approvalHistory.emptyState': 'No approval decisions recorded yet.', 'settings.approvalHistory.errorGeneric': 'Unable to load approval history. Try again.', - 'settings.approvalHistory.decidedAt': 'Decided', + 'settings.approvalHistory.decidedAt': 'Decided {date}', 'settings.approvalHistory.decision.approveOnce': 'Approved once', 'settings.approvalHistory.decision.approveAlways': 'Always allowed', 'settings.approvalHistory.decision.deny': 'Denied', diff --git a/app/src/lib/i18n/chunks/hi-5.ts b/app/src/lib/i18n/chunks/hi-5.ts index 2f5ac9d625..5ed9082421 100644 --- a/app/src/lib/i18n/chunks/hi-5.ts +++ b/app/src/lib/i18n/chunks/hi-5.ts @@ -871,7 +871,7 @@ const hi5: TranslationMap = { 'settings.approvalHistory.retry': 'Retry', 'settings.approvalHistory.emptyState': 'No approval decisions recorded yet.', 'settings.approvalHistory.errorGeneric': 'Unable to load approval history. Try again.', - 'settings.approvalHistory.decidedAt': 'Decided', + 'settings.approvalHistory.decidedAt': 'Decided {date}', 'settings.approvalHistory.decision.approveOnce': 'Approved once', 'settings.approvalHistory.decision.approveAlways': 'Always allowed', 'settings.approvalHistory.decision.deny': 'Denied', diff --git a/app/src/lib/i18n/chunks/id-5.ts b/app/src/lib/i18n/chunks/id-5.ts index be78c608bc..4fefac55c2 100644 --- a/app/src/lib/i18n/chunks/id-5.ts +++ b/app/src/lib/i18n/chunks/id-5.ts @@ -871,7 +871,7 @@ const id5: TranslationMap = { 'settings.approvalHistory.retry': 'Retry', 'settings.approvalHistory.emptyState': 'No approval decisions recorded yet.', 'settings.approvalHistory.errorGeneric': 'Unable to load approval history. Try again.', - 'settings.approvalHistory.decidedAt': 'Decided', + 'settings.approvalHistory.decidedAt': 'Decided {date}', 'settings.approvalHistory.decision.approveOnce': 'Approved once', 'settings.approvalHistory.decision.approveAlways': 'Always allowed', 'settings.approvalHistory.decision.deny': 'Denied', diff --git a/app/src/lib/i18n/chunks/it-5.ts b/app/src/lib/i18n/chunks/it-5.ts index cbe8c1796f..6fbfaf62e5 100644 --- a/app/src/lib/i18n/chunks/it-5.ts +++ b/app/src/lib/i18n/chunks/it-5.ts @@ -882,7 +882,7 @@ const it5: TranslationMap = { 'settings.approvalHistory.retry': 'Retry', 'settings.approvalHistory.emptyState': 'No approval decisions recorded yet.', 'settings.approvalHistory.errorGeneric': 'Unable to load approval history. Try again.', - 'settings.approvalHistory.decidedAt': 'Decided', + 'settings.approvalHistory.decidedAt': 'Decided {date}', 'settings.approvalHistory.decision.approveOnce': 'Approved once', 'settings.approvalHistory.decision.approveAlways': 'Always allowed', 'settings.approvalHistory.decision.deny': 'Denied', diff --git a/app/src/lib/i18n/chunks/ko-5.ts b/app/src/lib/i18n/chunks/ko-5.ts index ba21a0d061..a4f86a32a4 100644 --- a/app/src/lib/i18n/chunks/ko-5.ts +++ b/app/src/lib/i18n/chunks/ko-5.ts @@ -860,7 +860,7 @@ const ko5: TranslationMap = { 'settings.approvalHistory.retry': 'Retry', 'settings.approvalHistory.emptyState': 'No approval decisions recorded yet.', 'settings.approvalHistory.errorGeneric': 'Unable to load approval history. Try again.', - 'settings.approvalHistory.decidedAt': 'Decided', + 'settings.approvalHistory.decidedAt': 'Decided {date}', 'settings.approvalHistory.decision.approveOnce': 'Approved once', 'settings.approvalHistory.decision.approveAlways': 'Always allowed', 'settings.approvalHistory.decision.deny': 'Denied', diff --git a/app/src/lib/i18n/chunks/pl-5.ts b/app/src/lib/i18n/chunks/pl-5.ts index 37b3ce9515..523568c809 100644 --- a/app/src/lib/i18n/chunks/pl-5.ts +++ b/app/src/lib/i18n/chunks/pl-5.ts @@ -331,7 +331,7 @@ const pl5: TranslationMap = { 'settings.approvalHistory.retry': 'Retry', 'settings.approvalHistory.emptyState': 'No approval decisions recorded yet.', 'settings.approvalHistory.errorGeneric': 'Unable to load approval history. Try again.', - 'settings.approvalHistory.decidedAt': 'Decided', + 'settings.approvalHistory.decidedAt': 'Decided {date}', 'settings.approvalHistory.decision.approveOnce': 'Approved once', 'settings.approvalHistory.decision.approveAlways': 'Always allowed', 'settings.approvalHistory.decision.deny': 'Denied', diff --git a/app/src/lib/i18n/chunks/pt-5.ts b/app/src/lib/i18n/chunks/pt-5.ts index f124476c61..38cabf5f40 100644 --- a/app/src/lib/i18n/chunks/pt-5.ts +++ b/app/src/lib/i18n/chunks/pt-5.ts @@ -881,7 +881,7 @@ const pt5: TranslationMap = { 'settings.approvalHistory.retry': 'Retry', 'settings.approvalHistory.emptyState': 'No approval decisions recorded yet.', 'settings.approvalHistory.errorGeneric': 'Unable to load approval history. Try again.', - 'settings.approvalHistory.decidedAt': 'Decided', + 'settings.approvalHistory.decidedAt': 'Decided {date}', 'settings.approvalHistory.decision.approveOnce': 'Approved once', 'settings.approvalHistory.decision.approveAlways': 'Always allowed', 'settings.approvalHistory.decision.deny': 'Denied', diff --git a/app/src/lib/i18n/chunks/ru-5.ts b/app/src/lib/i18n/chunks/ru-5.ts index 6234377eb9..e0b1ee0967 100644 --- a/app/src/lib/i18n/chunks/ru-5.ts +++ b/app/src/lib/i18n/chunks/ru-5.ts @@ -877,7 +877,7 @@ const ru5: TranslationMap = { 'settings.approvalHistory.retry': 'Retry', 'settings.approvalHistory.emptyState': 'No approval decisions recorded yet.', 'settings.approvalHistory.errorGeneric': 'Unable to load approval history. Try again.', - 'settings.approvalHistory.decidedAt': 'Decided', + 'settings.approvalHistory.decidedAt': 'Decided {date}', 'settings.approvalHistory.decision.approveOnce': 'Approved once', 'settings.approvalHistory.decision.approveAlways': 'Always allowed', 'settings.approvalHistory.decision.deny': 'Denied', diff --git a/app/src/lib/i18n/chunks/zh-CN-5.ts b/app/src/lib/i18n/chunks/zh-CN-5.ts index 9a1d72f823..63151c04b6 100644 --- a/app/src/lib/i18n/chunks/zh-CN-5.ts +++ b/app/src/lib/i18n/chunks/zh-CN-5.ts @@ -831,7 +831,7 @@ const zhCN5: TranslationMap = { 'settings.approvalHistory.retry': 'Retry', 'settings.approvalHistory.emptyState': 'No approval decisions recorded yet.', 'settings.approvalHistory.errorGeneric': 'Unable to load approval history. Try again.', - 'settings.approvalHistory.decidedAt': 'Decided', + 'settings.approvalHistory.decidedAt': 'Decided {date}', 'settings.approvalHistory.decision.approveOnce': 'Approved once', 'settings.approvalHistory.decision.approveAlways': 'Always allowed', 'settings.approvalHistory.decision.deny': 'Denied', diff --git a/app/src/lib/i18n/en.ts b/app/src/lib/i18n/en.ts index 828d60b76b..e84358d107 100644 --- a/app/src/lib/i18n/en.ts +++ b/app/src/lib/i18n/en.ts @@ -3498,7 +3498,7 @@ const en: TranslationMap = { 'settings.approvalHistory.retry': 'Retry', 'settings.approvalHistory.emptyState': 'No approval decisions recorded yet.', 'settings.approvalHistory.errorGeneric': 'Unable to load approval history. Try again.', - 'settings.approvalHistory.decidedAt': 'Decided', + 'settings.approvalHistory.decidedAt': 'Decided {date}', 'settings.approvalHistory.decision.approveOnce': 'Approved once', 'settings.approvalHistory.decision.approveAlways': 'Always allowed', 'settings.approvalHistory.decision.deny': 'Denied', From 0efe290623886c9d091ded0cd28a11e9934fa13d Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 30 May 2026 07:53:45 -0700 Subject: [PATCH 3/5] fix(settings): nest approval-history breadcrumb under the Agents section Post-merge semantic conflict: main moved agent-access under the new Agents section page, while this PR had treated it as a top-level Settings panel. That left a duplicate `agent-access` switch case (no-duplicate-case lint error) and a duplicate `SettingsRoute` union member. Drop the PR's top-level treatment so main's IA wins, and route the approval-history trail through Settings -> Agents -> Agent access (keeping both agentsCrumb and agentAccessCrumb in use). --- .../components/settings/hooks/useSettingsNavigation.ts | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/app/src/components/settings/hooks/useSettingsNavigation.ts b/app/src/components/settings/hooks/useSettingsNavigation.ts index 40288be330..4664f67bfe 100644 --- a/app/src/components/settings/hooks/useSettingsNavigation.ts +++ b/app/src/components/settings/hooks/useSettingsNavigation.ts @@ -38,7 +38,6 @@ export type SettingsRoute = | 'mascot' | 'persona' | 'appearance' - | 'agent-access' | 'approval-history' | 'intelligence' | 'webhooks-triggers' @@ -283,13 +282,10 @@ export const useSettingsNavigation = (): SettingsNavigationHook => { case 'appearance': return [settingsCrumb]; - // Agent access panel sits at the top level of Settings. - case 'agent-access': - return [settingsCrumb]; - - // Approval history is a leaf under Agent access. + // Approval history is a leaf under Agent access, which itself lives under + // the Agents section — so the trail is Settings → Agents → Agent access. case 'approval-history': - return [settingsCrumb, agentAccessCrumb]; + return [settingsCrumb, agentsCrumb, agentAccessCrumb]; case 'home': default: From 7906eba6cebcee377c08a9207b065fd8c5bc8afe Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 30 May 2026 07:53:46 -0700 Subject: [PATCH 4/5] test(settings): cover approval-history last-request-wins refresh path Addresses CodeRabbit nitpick on ApprovalHistoryPanel.test.tsx: exercises the loadSeqRef guard via the mount-load -> refresh sequence, asserting the newer response's rows replace the initial load. --- .../__tests__/ApprovalHistoryPanel.test.tsx | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/app/src/components/settings/panels/__tests__/ApprovalHistoryPanel.test.tsx b/app/src/components/settings/panels/__tests__/ApprovalHistoryPanel.test.tsx index 551e4f7ac9..677801a97c 100644 --- a/app/src/components/settings/panels/__tests__/ApprovalHistoryPanel.test.tsx +++ b/app/src/components/settings/panels/__tests__/ApprovalHistoryPanel.test.tsx @@ -108,4 +108,24 @@ describe('ApprovalHistoryPanel', () => { fireEvent.click(screen.getByTestId('approval-history-refresh')); await waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(2)); }); + + it('keeps the latest result when a refresh supersedes the initial load', async () => { + // Drives the `loadSeqRef` last-request-wins path: the mount load resolves + // with one set of rows, a refresh fetches a different set, and the newer + // response is what remains on screen. + mockFetch + .mockResolvedValueOnce([auditRow({ request_id: 'old', tool_name: 'old-tool' })]) + .mockResolvedValueOnce([auditRow({ request_id: 'new', tool_name: 'new-tool' })]); + + renderWithProviders(, { + initialEntries: ['/settings/approval-history'], + }); + + await screen.findByText('old-tool'); + + fireEvent.click(screen.getByTestId('approval-history-refresh')); + + await screen.findByText('new-tool'); + expect(screen.queryByText('old-tool')).not.toBeInTheDocument(); + }); }); From ec28a5584541edefca4f12dc85a7267264e9e58b Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 30 May 2026 08:16:29 -0700 Subject: [PATCH 5/5] test(settings): describe approval-history refresh test accurately MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses CodeRabbit on ApprovalHistoryPanel.test.tsx: the earlier test name overclaimed coverage of the overlapping last-request-wins race. That race isn't reachable from the UI — the Refresh button is `disabled` while a load is in flight, so two concurrent fetches can't be initiated (verified empirically: a deferred-promise overlap test deadlocks because the refresh click is a no-op on the disabled button). Renamed to reflect what it actually verifies — the reachable refresh-replacement path — and documented why the loadSeqRef guard stays as defensive protection against React concurrent/StrictMode double-invoke. --- .../panels/__tests__/ApprovalHistoryPanel.test.tsx | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/app/src/components/settings/panels/__tests__/ApprovalHistoryPanel.test.tsx b/app/src/components/settings/panels/__tests__/ApprovalHistoryPanel.test.tsx index 677801a97c..2b2c2d8589 100644 --- a/app/src/components/settings/panels/__tests__/ApprovalHistoryPanel.test.tsx +++ b/app/src/components/settings/panels/__tests__/ApprovalHistoryPanel.test.tsx @@ -109,10 +109,13 @@ describe('ApprovalHistoryPanel', () => { await waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(2)); }); - it('keeps the latest result when a refresh supersedes the initial load', async () => { - // Drives the `loadSeqRef` last-request-wins path: the mount load resolves - // with one set of rows, a refresh fetches a different set, and the newer - // response is what remains on screen. + it('replaces the list with the refreshed result', async () => { + // The reachable refresh behavior: a completed load is replaced by the rows + // from a subsequent refresh. (The `loadSeqRef` last-request-wins guard + // protects against *overlapping* in-flight loads, but that race is not + // reachable from the UI — the Refresh button is `disabled` while a load is + // pending, so two concurrent fetches can never be initiated. The guard + // stays as defense against React concurrent/StrictMode double-invocation.) mockFetch .mockResolvedValueOnce([auditRow({ request_id: 'old', tool_name: 'old-tool' })]) .mockResolvedValueOnce([auditRow({ request_id: 'new', tool_name: 'new-tool' })]);