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:
co-authored by
Claude Opus 4.6
parent
3cd8eee3d9
commit
b172be49ca
@@ -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 && (
|
||||
|
||||
+46
@@ -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
@@ -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`
|
||||
|
||||
Reference in New Issue
Block a user