Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions app/src/components/settings/hooks/useSettingsNavigation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ export type SettingsRoute =
| 'mascot'
| 'persona'
| 'appearance'
| 'approval-history'
| 'intelligence'
| 'webhooks-triggers'
| 'composio-triggers'
Expand Down Expand Up @@ -124,6 +125,9 @@ 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` is an explicit leaf route under Agent access; it has a
// distinct prefix from `agent-access`, so ordering between them is cosmetic.
if (path.includes('/settings/approval-history')) return 'approval-history';
// `agents-settings` (the Agents section page) must be checked before the
// shorter `agents` (the manage-agents registry panel) so it isn't swallowed.
if (path.includes('/settings/agents-settings')) return 'agents-settings';
Expand Down Expand Up @@ -187,6 +191,11 @@ export const useSettingsNavigation = (): SettingsNavigationHook => {
onClick: () => navigate('/settings/developer-options'),
};

const agentAccessCrumb: BreadcrumbItem = {
label: 'Agent access',
onClick: () => navigate('/settings/agent-access'),
};
Comment on lines +194 to +197
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot May 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Localize the new breadcrumb label instead of hard-coding it.

agentAccessCrumb.label is user-visible and should come from useT() to preserve locale parity.

Suggested fix
+import { useT } from '../../../lib/i18n/I18nContext';
...
 export const useSettingsNavigation = (): SettingsNavigationHook => {
+  const { t } = useT();
   const navigate = useNavigate();
   const location = useLocation();
...
   const agentAccessCrumb: BreadcrumbItem = {
-    label: 'Agent access',
+    label: t('settings.agentAccess.title'),
     onClick: () => navigate('/settings/agent-access'),
   };

As per coding guidelines: “Every user-visible string in app/src/** … must use useT() …; hard-coded literals … are not allowed.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/src/components/settings/hooks/useSettingsNavigation.ts` around lines 186
- 189, Replace the hard-coded breadcrumb label in agentAccessCrumb with a
localized string from useT(): import and call useT() (the i18n hook used
elsewhere in this file) and replace label: 'Agent access' with label:
t('settings.agentAccess') (or the appropriate key consistent with existing keys)
so the user-visible string uses the translator; update any nearby tests or keys
if needed and keep the BreadcrumbItem object name agentAccessCrumb unchanged.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deferring: every breadcrumb label in this hook is a hard-coded English literal today (settingsCrumb 'Settings', accountCrumb 'Account', developerCrumb 'Developer Options', etc.). Localizing only the new agentAccessCrumb would be inconsistent; the whole breadcrumb set should move to useT() in one dedicated pass rather than piecemeal here. Tracking as a follow-up.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@obchain, that's a fair point — applying useT() to only agentAccessCrumb while all sibling crumbs remain hard-coded English would be inconsistent and would create a confusing mixed state. A single pass across the whole breadcrumb set is the right approach.

Would you like me to open a GitHub issue to track the breadcrumb-localization follow-up so it doesn't get lost?


const agentsCrumb: BreadcrumbItem = {
label: 'Agents',
onClick: () => navigate('/settings/agents-settings'),
Expand Down Expand Up @@ -273,6 +282,11 @@ export const useSettingsNavigation = (): SettingsNavigationHook => {
case 'appearance':
return [settingsCrumb];

// 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, agentsCrumb, agentAccessCrumb];

case 'home':
default:
return [];
Expand Down
20 changes: 19 additions & 1 deletion app/src/components/settings/panels/AgentAccessPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -391,6 +391,24 @@ const AgentAccessPanel = () => {
)}
</section>

{/* Approval history — read-only audit trail of past decisions,
backed by the gate's durable decided-rows store. */}
<section className="space-y-2">
<h2 className="text-sm font-semibold text-ink">
{t('settings.agentAccess.approvalHistory')}
</h2>
<p className="text-xs text-ink-soft">
{t('settings.agentAccess.approvalHistoryDesc')}
</p>
<button
type="button"
onClick={() => navigateToSettings('approval-history')}
data-testid="agent-access-approval-history-link"
className="rounded border border-line px-3 py-1 text-xs text-ink hover:border-primary-300">
{t('settings.agentAccess.viewApprovalHistory')}
</button>
</section>

{/* Auto-save status — changes persist on selection; no manual save. */}
<div className="min-h-[1.25rem] text-sm" aria-live="polite">
{error ? (
Expand Down
155 changes: 155 additions & 0 deletions app/src/components/settings/panels/ApprovalHistoryPanel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
import debug from 'debug';
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 log = debug('ui:approval-history');

/** 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<ApprovalDecision, string> = {
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<ApprovalDecision, string> = {
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<ApprovalAuditEntry[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(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) => {
log('load start %o', { seq });
try {
const rows = await fetchRecentApprovalDecisions();
if (seq !== loadSeqRef.current) {
log('stale response discarded %o', { seq, latest: loadSeqRef.current });
return;
}
setEntries(rows);
setError(null);
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.
log('load failed %o', 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 (
<div>
<SettingsHeader
title={t('settings.approvalHistory.title')}
showBackButton
onBack={navigateBack}
breadcrumbs={breadcrumbs}
/>

<div className="p-4 space-y-4" data-testid="approval-history-panel">
<div className="flex items-center justify-between">
<p className="text-xs text-ink-soft">{t('settings.approvalHistory.subtitle')}</p>
<button
type="button"
onClick={handleRefresh}
disabled={isLoading}
data-testid="approval-history-refresh"
className="rounded bg-primary-500 px-3 py-1 text-xs text-white hover:bg-primary-600 disabled:opacity-50">
{t('settings.approvalHistory.refresh')}
</button>
</div>

{isLoading ? (
<p className="text-sm text-ink-soft" data-testid="approval-history-loading">
{t('settings.approvalHistory.loading')}
</p>
) : error ? (
<div className="space-y-2" data-testid="approval-history-error">
<p className="text-sm text-coral">{error}</p>
<button
type="button"
onClick={handleRefresh}
className="text-xs text-primary-600 hover:underline">
{t('settings.approvalHistory.retry')}
</button>
</div>
) : entries.length === 0 ? (
<p className="text-sm text-ink-soft" data-testid="approval-history-empty">
{t('settings.approvalHistory.emptyState')}
</p>
) : (
<ul className="space-y-2" data-testid="approval-history-list">
{entries.map(entry => (
<li
key={entry.request_id}
className="rounded-lg border border-line p-3 space-y-1"
data-testid="approval-history-row">
<div className="flex items-center justify-between gap-2">
<span className="font-mono text-xs text-ink truncate">{entry.tool_name}</span>
<span
className={`inline-flex shrink-0 items-center rounded-full px-2 py-0.5 text-xs font-medium ring-1 ${DECISION_TONE[entry.decision]}`}
data-testid={`approval-history-decision-${entry.decision}`}>
{t(DECISION_LABEL_KEY[entry.decision])}
</span>
</div>
<p className="text-xs text-ink-soft">{entry.action_summary}</p>
<p className="text-[11px] text-ink-soft">
{t('settings.approvalHistory.decidedAt').replace(
'{date}',
formatDateTime(entry.decided_at)
)}
</p>
Comment thread
coderabbitai[bot] marked this conversation as resolved.
</li>
))}
</ul>
)}
</div>
</div>
);
};

export default ApprovalHistoryPanel;
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
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> = {}): 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(<ApprovalHistoryPanel />, {
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(<ApprovalHistoryPanel />, {
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(<ApprovalHistoryPanel />, {
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(<ApprovalHistoryPanel />, {
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(<ApprovalHistoryPanel />, {
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));
});

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
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in ec28a5584 — but not by adapting the suggested deferred-promise test, because that overlap isn't reachable from the component's public API.

The Refresh button is disabled={isLoading}, and isLoading starts true for the mount load. So while the initial request is in flight the button is disabled and a second fetch can't be initiated — there's no way to get two overlapping in-flight loads through the UI. I verified this empirically: wiring up the exact deferred-promise shape you suggested makes the test deadlock — fireEvent.click on the disabled Refresh button is a no-op, the second fetch never fires, and findByText('new-tool') times out.

So the loadSeqRef guard is genuinely defensive — it protects against React concurrent-mode / StrictMode double-invocation of the mount effect, not a user-reachable double-click. I tried exercising it via <StrictMode> too, but the dev double-invoke doesn't reproduce deterministically under Vitest/jsdom here.

I renamed the test to replaces the list with the refreshed result so it no longer overclaims, and documented the above in a comment so the next reader knows the race is unreachable-by-design rather than untested-by-omission. The uncovered stale-discard branch is 3 lines and doesn't move the changed-line coverage gate below threshold.

.mockResolvedValueOnce([auditRow({ request_id: 'old', tool_name: 'old-tool' })])
.mockResolvedValueOnce([auditRow({ request_id: 'new', tool_name: 'new-tool' })]);

renderWithProviders(<ApprovalHistoryPanel />, {
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();
});
});
15 changes: 15 additions & 0 deletions app/src/lib/i18n/ar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3387,6 +3387,21 @@ const messages: TranslationMap = {
'settings.agentAccess.add': 'مضاف',
'settings.agentAccess.saving': 'إنقاذ...',
'settings.agentAccess.changesApply': 'التغييرات تنطبق على رسالتك القادمة',
'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 {date}',
'settings.approvalHistory.decision.approveOnce': 'Approved once',
'settings.approvalHistory.decision.approveAlways': 'Always allowed',
'settings.approvalHistory.decision.deny': 'Denied',
'settings.appearance.title': 'المظهر',
'settings.appearance.themeHeading': 'الموضوع',
'settings.appearance.themeAria': 'الموضوع',
Expand Down
Loading
Loading