feat(triage): GAP-34 batch triage confirmation dialog with cost estimate

Triage All button now shows inline confirmation (role=alert) with issue
count and estimated cost via estimateBatchCost() before executing. Confirm
fires onTriageAll, cancel reverts to button. i18n key added for both
EN and FR. 2 new tests, 16 total.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
This commit is contained in:
Sondre Engebråten
2026-02-12 22:10:03 +01:00
co-authored by Claude Opus 4.6
parent 3cd8eee3d9
commit b172be49ca
5 changed files with 93 additions and 21 deletions
@@ -1,6 +1,7 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import type { BulkActionType, BulkOperationProgress } from '../../../../shared/types/mutations';
import { estimateBatchCost } from '../../../../shared/constants/ai-triage';
interface BulkActionBarProps {
selectedCount: number;
@@ -35,6 +36,7 @@ export function BulkActionBar({
}: BulkActionBarProps) {
const { t } = useTranslation('common');
const [pendingAction, setPendingAction] = useState<BulkActionType | null>(null);
const [pendingTriageAll, setPendingTriageAll] = useState(false);
if (selectedCount === 0) {
return null;
@@ -119,18 +121,40 @@ export function BulkActionBar({
)}
{onTriageAll && untriagedCount != null && untriagedCount > 0 && (
<button
type="button"
className="ml-2 px-2.5 py-1 text-xs rounded-md border border-border bg-card hover:bg-accent disabled:opacity-50 disabled:cursor-not-allowed"
disabled={isOperating}
onClick={onTriageAll}
aria-label={t('aiTriage.triageAllButton')}
>
{t('aiTriage.triageAllButton')}
<span className="ml-1 inline-flex items-center justify-center rounded-full bg-primary/10 px-1.5 text-[10px] font-medium text-primary">
{untriagedCount}
</span>
</button>
pendingTriageAll ? (
<div className="flex items-center gap-2 ml-2" role="alert">
<span className="text-xs text-foreground">
{t('aiTriage.confirmTriage', { count: untriagedCount, cost: estimateBatchCost(untriagedCount, 'sonnet') })}
</span>
<button
type="button"
className="px-2.5 py-1 text-xs rounded-md border border-destructive bg-destructive/10 text-destructive hover:bg-destructive/20"
onClick={() => { onTriageAll(); setPendingTriageAll(false); }}
>
{t('bulk.confirm')}
</button>
<button
type="button"
className="px-2.5 py-1 text-xs rounded-md border border-border bg-card hover:bg-accent"
onClick={() => setPendingTriageAll(false)}
>
{t('bulk.cancel')}
</button>
</div>
) : (
<button
type="button"
className="ml-2 px-2.5 py-1 text-xs rounded-md border border-border bg-card hover:bg-accent disabled:opacity-50 disabled:cursor-not-allowed"
disabled={isOperating}
onClick={() => setPendingTriageAll(true)}
aria-label={t('aiTriage.triageAllButton')}
>
{t('aiTriage.triageAllButton')}
<span className="ml-1 inline-flex items-center justify-center rounded-full bg-primary/10 px-1.5 text-[10px] font-medium text-primary">
{untriagedCount}
</span>
</button>
)
)}
{isOperating && progress && (
@@ -11,6 +11,10 @@ vi.mock('react-i18next', () => ({
}),
}));
vi.mock('../../../../../shared/constants/ai-triage', () => ({
estimateBatchCost: (count: number) => `~$${(count * 0.0035).toFixed(2)}`,
}));
describe('BulkActionBar', () => {
it('not rendered when selectedCount is 0', () => {
const { container } = render(
@@ -215,4 +219,46 @@ describe('BulkActionBar', () => {
const triageBtn = screen.getByRole('button', { name: 'aiTriage.triageAllButton' });
expect(triageBtn).toBeDefined();
});
it('Triage All shows confirmation dialog with cost estimate before executing', () => {
const onTriageAll = vi.fn();
render(
<BulkActionBar
selectedCount={3}
onBulkAction={vi.fn()}
isOperating={false}
untriagedCount={5}
onTriageAll={onTriageAll}
/>,
);
// Click Triage All — should NOT fire immediately
fireEvent.click(screen.getByRole('button', { name: 'aiTriage.triageAllButton' }));
expect(onTriageAll).not.toHaveBeenCalled();
// Confirmation dialog should appear with cost estimate text
expect(screen.getByRole('alert')).toBeDefined();
expect(screen.getByText('aiTriage.confirmTriage')).toBeDefined();
// Confirm fires onTriageAll
fireEvent.click(screen.getByText('bulk.confirm'));
expect(onTriageAll).toHaveBeenCalledTimes(1);
});
it('Triage All confirmation can be cancelled', () => {
const onTriageAll = vi.fn();
render(
<BulkActionBar
selectedCount={3}
onBulkAction={vi.fn()}
isOperating={false}
untriagedCount={5}
onTriageAll={onTriageAll}
/>,
);
fireEvent.click(screen.getByRole('button', { name: 'aiTriage.triageAllButton' }));
expect(screen.getByRole('alert')).toBeDefined();
// Cancel
fireEvent.click(screen.getByText('bulk.cancel'));
expect(onTriageAll).not.toHaveBeenCalled();
// Button should be back
expect(screen.getByRole('button', { name: 'aiTriage.triageAllButton' })).toBeDefined();
});
});
@@ -565,7 +565,8 @@
"splitButton": "Split Issue",
"triageAllButton": "Triage All Untriaged",
"retry": "Retry",
"closeAsDuplicate": "Close as Duplicate"
"closeAsDuplicate": "Close as Duplicate",
"confirmTriage": "Triage {{count}} issues? Estimated cost: {{cost}}"
},
"batchReview": {
"title": "Batch Triage Review",
@@ -565,7 +565,8 @@
"splitButton": "Diviser l'issue",
"triageAllButton": "Trier tous les non triés",
"retry": "Réessayer",
"closeAsDuplicate": "Fermer comme doublon"
"closeAsDuplicate": "Fermer comme doublon",
"confirmTriage": "Trier {{count}} issues ? Coût estimé : {{cost}}"
},
"batchReview": {
"title": "Revue de triage par lots",
+7 -7
View File
@@ -3,7 +3,7 @@
**Branch:** `terminal/enhancement-issues-tab`
**Created:** 2026-02-12
**Total Gaps:** 41 confirmed (from triple-verified audit)
**Status:** 26 / 41 complete
**Status:** 27 / 41 complete
---
@@ -417,16 +417,16 @@ Each gap has: ID, description, status, files to modify, doc reference, test stat
- **Commit:** pending
### GAP-34: No batch triage confirmation dialog or cost estimate
- **Status:** `PENDING`
- **Status:** `DONE`
- **Priority:** SHOULD-FIX
- **Scope:** Medium
- **Doc ref:** Phase 3 PRD > US-2 > AC2.2; NFR 3.2
- **Files to modify:** `renderer/components/github-issues/components/BulkActionBar.tsx`
- **Fix:** Add confirmation state. Import estimateBatchCost. Show dialog with count + estimated cost before triaging. Only call onTriageAll after confirm.
- **Tests:** Click Triage All → confirm dialog with cost; confirm → action; cancel → no action
- **Test status:** `PENDING`
- **Files modified:** `BulkActionBar.tsx`, `BulkActionBar.test.tsx`, `en/common.json`, `fr/common.json`
- **Fix:** Added `pendingTriageAll` state. Clicking Triage All shows inline confirmation (role=alert) with issue count + estimated cost via `estimateBatchCost()`. Confirm fires `onTriageAll`, cancel reverts to button. i18n key: aiTriage.confirmTriage (EN+FR).
- **Tests:** 2 new tests: confirm fires action with cost shown, cancel reverts. 16 total.
- **Test status:** `PASS`
- **Depends on:** None
- **Commit:**
- **Commit:** pending
### GAP-35: No undo batch mechanism
- **Status:** `PENDING`