feat(triage): GAP-33 clickable duplicate links and close-as-duplicate action

Make duplicate issue number a clickable button (text-primary hover:underline)
via onNavigateToIssue prop. Add Close as Duplicate button via onCloseAsDuplicate
prop, shown only for pending items. i18n keys added for both EN and FR.
5 new tests, 13 total.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
This commit is contained in:
Sondre Engebråten
2026-02-12 22:08:03 +01:00
co-authored by Claude Opus 4.6
parent 1503e722e3
commit 3cd8eee3d9
5 changed files with 93 additions and 15 deletions
@@ -16,9 +16,11 @@ interface TriageResultCardProps {
item: TriageReviewItem;
onAccept: (issueNumber: number) => void;
onReject: (issueNumber: number) => void;
onNavigateToIssue?: (issueNumber: number) => void;
onCloseAsDuplicate?: (issueNumber: number, duplicateOf: number) => void;
}
export function TriageResultCard({ item, onAccept, onReject }: TriageResultCardProps) {
export function TriageResultCard({ item, onAccept, onReject, onNavigateToIssue, onCloseAsDuplicate }: TriageResultCardProps) {
const { t } = useTranslation(['common']);
const { result } = item;
const level = getConfidenceLevel(result.confidence);
@@ -58,11 +60,37 @@ export function TriageResultCard({ item, onAccept, onReject }: TriageResultCardP
</div>
{/* Duplicate */}
{result.isDuplicate && result.duplicateOf && (
<div className="text-xs text-foreground/50">
{t('common:aiTriage.duplicateOf')} <span className="font-medium">#{result.duplicateOf}</span>
</div>
)}
{result.isDuplicate && result.duplicateOf && (() => {
const dupOf = result.duplicateOf;
return (
<div className="flex items-center gap-2 text-xs text-foreground/50">
<span>
{t('common:aiTriage.duplicateOf')}{' '}
{onNavigateToIssue ? (
<button
type="button"
className="font-medium text-primary hover:underline cursor-pointer"
onClick={() => onNavigateToIssue(dupOf)}
>
#{dupOf}
</button>
) : (
<span className="font-medium">#{dupOf}</span>
)}
</span>
{onCloseAsDuplicate && isPending && (
<button
type="button"
className="text-xs px-2 py-0.5 rounded border border-border bg-card hover:bg-accent"
aria-label={t('common:aiTriage.closeAsDuplicate')}
onClick={() => onCloseAsDuplicate(item.issueNumber, dupOf)}
>
{t('common:aiTriage.closeAsDuplicate')}
</button>
)}
</div>
);
})()}
{/* Status / Actions */}
{item.status === 'accepted' && (
@@ -94,4 +94,52 @@ describe('TriageResultCard', () => {
render(<TriageResultCard item={createItem({ status: 'accepted' })} onAccept={vi.fn()} onReject={vi.fn()} />);
expect(screen.queryByRole('button', { name: /accept/i })).toBeNull();
});
it('makes duplicate issue number clickable when onNavigateToIssue provided', () => {
const onNavigate = vi.fn();
const item = createItem();
item.result.isDuplicate = true;
item.result.duplicateOf = 10;
render(
<TriageResultCard item={item} onAccept={vi.fn()} onReject={vi.fn()} onNavigateToIssue={onNavigate} />,
);
const link = screen.getByRole('button', { name: '#10' });
expect(link).toBeDefined();
fireEvent.click(link);
expect(onNavigate).toHaveBeenCalledWith(10);
});
it('renders duplicate number as static text when onNavigateToIssue absent', () => {
const item = createItem();
item.result.isDuplicate = true;
item.result.duplicateOf = 10;
render(<TriageResultCard item={item} onAccept={vi.fn()} onReject={vi.fn()} />);
// Should have #10 text but not as a button
expect(screen.getByText(/#10/)).toBeDefined();
expect(screen.queryByRole('button', { name: '#10' })).toBeNull();
});
it('shows Close as Duplicate button when onCloseAsDuplicate provided and pending', () => {
const onClose = vi.fn();
const item = createItem();
item.result.isDuplicate = true;
item.result.duplicateOf = 10;
render(
<TriageResultCard item={item} onAccept={vi.fn()} onReject={vi.fn()} onCloseAsDuplicate={onClose} />,
);
const btn = screen.getByRole('button', { name: 'common:aiTriage.closeAsDuplicate' });
expect(btn).toBeDefined();
fireEvent.click(btn);
expect(onClose).toHaveBeenCalledWith(42, 10);
});
it('hides Close as Duplicate for already-accepted items', () => {
const item = createItem({ status: 'accepted' });
item.result.isDuplicate = true;
item.result.duplicateOf = 10;
render(
<TriageResultCard item={item} onAccept={vi.fn()} onReject={vi.fn()} onCloseAsDuplicate={vi.fn()} />,
);
expect(screen.queryByRole('button', { name: 'common:aiTriage.closeAsDuplicate' })).toBeNull();
});
});
@@ -564,7 +564,8 @@
"improveButton": "Improve Issue",
"splitButton": "Split Issue",
"triageAllButton": "Triage All Untriaged",
"retry": "Retry"
"retry": "Retry",
"closeAsDuplicate": "Close as Duplicate"
},
"batchReview": {
"title": "Batch Triage Review",
@@ -564,7 +564,8 @@
"improveButton": "Améliorer l'issue",
"splitButton": "Diviser l'issue",
"triageAllButton": "Trier tous les non triés",
"retry": "Réessayer"
"retry": "Réessayer",
"closeAsDuplicate": "Fermer comme doublon"
},
"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:** 25 / 41 complete
**Status:** 26 / 41 complete
---
@@ -405,16 +405,16 @@ Each gap has: ID, description, status, files to modify, doc reference, test stat
- **Commit:** pending
### GAP-33: Duplicate detection display-only
- **Status:** `PENDING`
- **Status:** `DONE`
- **Priority:** SHOULD-FIX
- **Scope:** Medium
- **Doc ref:** Phase 3 PRD > US-7 > AC7.2, AC7.3
- **Files to modify:** `renderer/components/github-issues/components/TriageResultCard.tsx`
- **Fix:** Make #{duplicateOf} a clickable button with onNavigateToIssue prop. Add "Close as Duplicate" button with onCloseAsDuplicate prop.
- **Tests:** Click duplicate link → onNavigateToIssue called; Close as Duplicate → onCloseAsDuplicate called
- **Test status:** `PENDING`
- **Files modified:** `TriageResultCard.tsx`, `TriageResultCard.test.tsx`, `en/common.json`, `fr/common.json`
- **Fix:** Added `onNavigateToIssue` and `onCloseAsDuplicate` props. Duplicate number rendered as clickable button (text-primary hover:underline) when onNavigateToIssue provided, else static span. "Close as Duplicate" button shown when onCloseAsDuplicate provided and item is pending. i18n key: aiTriage.closeAsDuplicate (EN+FR).
- **Tests:** 5 new tests: clickable duplicate navigates, static when absent, close-as-duplicate fires callback, hidden when accepted. 13 total.
- **Test status:** `PASS`
- **Depends on:** None
- **Commit:** —
- **Commit:** pending
### GAP-34: No batch triage confirmation dialog or cost estimate
- **Status:** `PENDING`