feat(triage): GAP-19,35,40,41,42,43 Phase E polish

- GAP-19: Ctrl+1/2/3 keyboard shortcuts for triage panel navigation
- GAP-35: Undo batch mechanism (snapshot/restore in store + UI button)
- GAP-40: Label sync debounce (2000ms via useRef timer)
- GAP-41: Bulk label sync IPC handler + preload + hook method
- GAP-42: Color preview swatches in LabelSyncSettings
- GAP-43: Write/Preview markdown toggle in CommentForm + i18n

40/41 gaps complete.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
This commit is contained in:
Sondre Engebråten
2026-02-13 00:31:32 +01:00
co-authored by Claude Opus 4.6
parent 8b43a84518
commit 80441de62c
19 changed files with 562 additions and 86 deletions
@@ -184,6 +184,63 @@ export function registerLabelSyncHandlers(
},
);
// Bulk sync labels for multiple issues
ipcMain.handle(
'github:label-sync:bulk',
async (_, projectId: string, issueNumbers: number[]) => {
return withProject(projectId, async (project) => {
const config = readConfig(project.path);
if (!config.enabled) {
return { synced: 0, errors: 0 };
}
const env = getAugmentedEnv();
const data = await readEnrichmentFile(project.path);
let synced = 0;
let errors = 0;
for (const issueNumber of issueNumbers) {
const enrichment = data.issues[String(issueNumber)];
if (!enrichment?.triageState) continue;
const targetLabel = getLabelForState(enrichment.triageState as WorkflowState);
try {
// Get current labels
const labelsJson = execFileSync('gh', [
'issue', 'view', String(issueNumber),
'--json', 'labels',
'--jq', '.labels',
], { env, cwd: project.path, encoding: 'utf-8' });
const currentLabels = JSON.parse(labelsJson) as Array<{ name: string }>;
if (currentLabels.some((l) => l.name === targetLabel)) {
synced++;
continue;
}
const args = ['issue', 'edit', String(issueNumber)];
for (const label of currentLabels) {
if (isAutoClaudeLabel(label.name) && label.name !== targetLabel) {
args.push('--remove-label', label.name);
}
}
args.push('--add-label', targetLabel);
execFileSync('gh', args, { env, cwd: project.path, encoding: 'utf-8' });
synced++;
} catch (error) {
logger.debug('Bulk sync error for issue', { issueNumber, error });
errors++;
}
}
logger.debug('Bulk label sync complete', { synced, errors });
return { synced, errors };
});
},
);
// Save label sync config
ipcMain.handle(
'github:label-sync:save',
@@ -400,6 +400,7 @@ export interface GitHubAPI {
syncIssueLabel: (projectId: string, issueNumber: number, newState: string, oldState: string | null) => Promise<{ synced?: boolean; skipped?: boolean; error?: string }>;
getLabelSyncStatus: (projectId: string) => Promise<LabelSyncConfig>;
saveLabelSyncConfig: (projectId: string, config: LabelSyncConfig) => Promise<{ success: boolean }>;
bulkLabelSync: (projectId: string, issueNumbers: number[]) => Promise<{ synced: number; errors: number }>;
// Dependencies (Phase 4)
fetchDependencies: (projectId: string, issueNumber: number) => Promise<IssueDependencies & { error?: string; unavailable?: boolean }>;
@@ -1007,6 +1008,9 @@ export const createGitHubAPI = (): GitHubAPI => ({
saveLabelSyncConfig: (projectId: string, config: LabelSyncConfig): Promise<{ success: boolean }> =>
invokeIpc(IPC_CHANNELS.GITHUB_LABEL_SYNC_SAVE, projectId, config),
bulkLabelSync: (projectId: string, issueNumbers: number[]): Promise<{ synced: number; errors: number }> =>
invokeIpc(IPC_CHANNELS.GITHUB_LABEL_SYNC_BULK, projectId, issueNumbers),
// Dependencies (Phase 4)
fetchDependencies: (projectId: string, issueNumber: number) =>
invokeIpc(IPC_CHANNELS.GITHUB_DEPS_FETCH, projectId, issueNumber),
@@ -181,6 +181,7 @@ export function GitHubIssues({ onOpenSettings, onNavigateToTask }: GitHubIssuesP
// AI Triage
const aiTriage = useAITriage(selectedProject?.id ?? '');
const lastBatchSnapshot = useAITriageStore((s) => s.lastBatchSnapshot);
// Triage mode (3-panel layout)
const { isEnabled: triageModeEnabled, isAvailable: triageModeAvailable, toggle: toggleTriageMode } = useTriageMode();
@@ -393,7 +394,7 @@ export function GitHubIssues({ onOpenSettings, onNavigateToTask }: GitHubIssuesP
{/* Content */}
<div className="flex-1 flex min-h-0">
{/* Issue List */}
<section className={`${triageModeEnabled ? 'w-1/4' : 'w-1/2'} border-r border-border flex flex-col`} aria-label={t('panels.issueList')}>
<section className={`${triageModeEnabled ? 'w-1/4' : 'w-1/2'} border-r border-border flex flex-col`} aria-label={t('panels.issueList')} data-triage-panel="1" tabIndex={-1}>
<IssueList
issues={workflowFilteredIssues}
selectedIssueNumber={selectedIssueNumber}
@@ -412,7 +413,7 @@ export function GitHubIssues({ onOpenSettings, onNavigateToTask }: GitHubIssuesP
</section>
{/* Issue Detail */}
<section className={`w-1/2 flex flex-col ${triageModeEnabled ? 'border-r border-border' : ''}`} aria-label={t('panels.issueDetail')}>
<section className={`w-1/2 flex flex-col ${triageModeEnabled ? 'border-r border-border' : ''}`} aria-label={t('panels.issueDetail')} data-triage-panel="2" tabIndex={-1}>
{selectedIssue ? (
<IssueDetail
issue={selectedIssue}
@@ -456,7 +457,7 @@ export function GitHubIssues({ onOpenSettings, onNavigateToTask }: GitHubIssuesP
{/* Triage Sidebar (3rd panel) */}
{triageModeEnabled && selectedIssue && (
<section className="w-1/4 flex flex-col" aria-label={t('panels.triageSidebar')}>
<section className="w-1/4 flex flex-col" aria-label={t('panels.triageSidebar')} data-triage-panel="3" tabIndex={-1}>
<TriageSidebar
enrichment={enrichments[String(selectedIssue.number)] ?? null}
currentState={enrichments[String(selectedIssue.number)]?.triageState ?? 'new'}
@@ -519,7 +520,8 @@ export function GitHubIssues({ onOpenSettings, onNavigateToTask }: GitHubIssuesP
onReject={aiTriage.rejectResult}
onAcceptAll={() => { useAITriageStore.getState().acceptAllRemaining(); }}
onDismiss={() => { useAITriageStore.getState().dismissReview(); }}
onApply={aiTriage.applyTriageResults}
onApply={() => { useAITriageStore.getState().snapshotBeforeApply(); aiTriage.applyTriageResults(); }}
onUndo={lastBatchSnapshot ? () => { useAITriageStore.getState().undoLastBatch(); } : undefined}
/>
)}
@@ -13,6 +13,7 @@ interface BatchTriageReviewProps {
onAcceptAll: () => void;
onDismiss: () => void;
onApply: () => void;
onUndo?: () => void;
}
export function BatchTriageReview({
@@ -22,6 +23,7 @@ export function BatchTriageReview({
onAcceptAll,
onDismiss,
onApply,
onUndo,
}: BatchTriageReviewProps) {
const { t } = useTranslation(['common']);
const reviewed = items.filter((i) => i.status !== 'pending').length;
@@ -67,6 +69,16 @@ export function BatchTriageReview({
{t('common:batchReview.apply')}
</button>
)}
{onUndo && (
<button
type="button"
aria-label={t('common:batchReview.undo')}
className="text-xs px-3 py-1 rounded bg-amber-600 hover:bg-amber-500 text-white transition-colors"
onClick={onUndo}
>
{t('common:batchReview.undo')}
</button>
)}
<button
type="button"
aria-label={t('common:batchReview.dismiss')}
@@ -1,4 +1,6 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import ReactMarkdown from 'react-markdown';
import { Loader2 } from 'lucide-react';
import { Button } from '../../ui/button';
@@ -13,36 +15,75 @@ export function CommentForm({
isSubmitting,
disabled,
}: CommentFormProps) {
const { t } = useTranslation('common');
const [body, setBody] = useState('');
const [error, setError] = useState<string | null>(null);
const [mode, setMode] = useState<'write' | 'preview'>('write');
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (body.trim() === '') {
setError('Comment cannot be empty');
setError(t('commentForm.emptyError'));
return;
}
setError(null);
await onSubmit(body);
setBody('');
setMode('write');
}
return (
<form onSubmit={handleSubmit} className="space-y-2">
<textarea
value={body}
onChange={(e) => {
setBody(e.target.value);
if (error) setError(null);
}}
placeholder="Write a comment..."
rows={3}
disabled={disabled || isSubmitting}
aria-label="Comment"
className="w-full rounded-md border border-border bg-card px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring resize-none"
/>
<div className="flex gap-2 border-b border-border pb-1">
<button
type="button"
className={`text-xs px-2 py-1 rounded transition-colors ${
mode === 'write'
? 'bg-primary/10 text-primary font-medium'
: 'text-foreground/60 hover:text-foreground'
}`}
onClick={() => setMode('write')}
>
{t('commentForm.write')}
</button>
<button
type="button"
className={`text-xs px-2 py-1 rounded transition-colors ${
mode === 'preview'
? 'bg-primary/10 text-primary font-medium'
: 'text-foreground/60 hover:text-foreground'
}`}
onClick={() => setMode('preview')}
>
{t('commentForm.preview')}
</button>
</div>
{mode === 'write' ? (
<textarea
value={body}
onChange={(e) => {
setBody(e.target.value);
if (error) setError(null);
}}
placeholder={t('commentForm.placeholder')}
rows={3}
disabled={disabled || isSubmitting}
aria-label={t('commentForm.submit')}
className="w-full rounded-md border border-border bg-card px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring resize-none"
/>
) : (
<div className="min-h-[4.5rem] rounded-md border border-border bg-card px-3 py-2 text-sm prose prose-sm dark:prose-invert max-w-none">
{body ? (
<ReactMarkdown>{body}</ReactMarkdown>
) : (
<p className="text-foreground/40">{t('commentForm.placeholder')}</p>
)}
</div>
)}
{error && (
<p className="text-xs text-destructive" role="alert">
{error}
@@ -55,7 +96,7 @@ export function CommentForm({
className="gap-1.5"
>
{isSubmitting && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
{isSubmitting ? 'Submitting...' : 'Comment'}
{isSubmitting ? t('commentForm.submitting') : t('commentForm.submit')}
</Button>
</form>
);
@@ -1,4 +1,5 @@
import { useTranslation } from 'react-i18next';
import { getWorkflowLabels } from '../../../../shared/constants/label-sync';
interface LabelSyncSettingsProps {
enabled: boolean;
@@ -66,6 +67,25 @@ export function LabelSyncSettings({
</button>
)}
{enabled && (
<div className="flex flex-wrap gap-1.5">
{getWorkflowLabels().map((label) => (
<span
key={label.name}
data-testid="label-swatch"
className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[10px] font-medium"
style={{ backgroundColor: `#${label.color}20`, color: `#${label.color}` }}
>
<span
className="w-2 h-2 rounded-full"
style={{ backgroundColor: `#${label.color}` }}
/>
{label.name}
</span>
))}
</div>
)}
{error && (
<p className="text-xs text-destructive" role="alert">
{error}
@@ -84,4 +84,20 @@ describe('BatchTriageReview', () => {
render(<BatchTriageReview items={[]} {...defaultProps} />);
expect(screen.getByText(/no.*results/i)).toBeDefined();
});
it('shows undo button when onUndo provided', () => {
const items = [createItem(1, 'accepted')];
const onUndo = vi.fn();
render(<BatchTriageReview items={items} {...defaultProps} onUndo={onUndo} />);
const undoBtn = screen.getByRole('button', { name: /undo/i });
expect(undoBtn).toBeDefined();
fireEvent.click(undoBtn);
expect(onUndo).toHaveBeenCalled();
});
it('does not show undo button when onUndo is not provided', () => {
const items = [createItem(1, 'accepted')];
render(<BatchTriageReview items={items} {...defaultProps} />);
expect(screen.queryByRole('button', { name: /undo/i })).toBeNull();
});
});
@@ -5,52 +5,53 @@ import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { CommentForm } from '../CommentForm';
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}));
vi.mock('react-markdown', () => ({
default: ({ children }: { children: string }) => <div data-testid="markdown-preview">{children}</div>,
}));
describe('CommentForm', () => {
it('renders textarea', () => {
render(<CommentForm onSubmit={vi.fn()} />);
expect(screen.getByRole('textbox', { name: 'Comment' })).toBeDefined();
expect(screen.getByRole('textbox')).toBeDefined();
});
it('empty submit shows error', () => {
render(<CommentForm onSubmit={vi.fn()} />);
fireEvent.click(screen.getByRole('button', { name: 'Comment' }));
fireEvent.click(screen.getByText('commentForm.submit'));
expect(screen.getByRole('alert')).toBeDefined();
expect(screen.getByText('Comment cannot be empty')).toBeDefined();
});
it('submit with text fires onSubmit', async () => {
const onSubmit = vi.fn().mockResolvedValue(undefined);
render(<CommentForm onSubmit={onSubmit} />);
const textarea = screen.getByRole('textbox', { name: 'Comment' });
const textarea = screen.getByRole('textbox');
fireEvent.change(textarea, { target: { value: 'Nice work!' } });
fireEvent.click(screen.getByRole('button', { name: 'Comment' }));
fireEvent.click(screen.getByText('commentForm.submit'));
await waitFor(() => {
expect(onSubmit).toHaveBeenCalledWith('Nice work!');
});
});
it('aria-label on textarea', () => {
render(<CommentForm onSubmit={vi.fn()} />);
const textarea = screen.getByRole('textbox');
expect(textarea.getAttribute('aria-label')).toBe('Comment');
});
it('submitting shows loading state', () => {
render(<CommentForm onSubmit={vi.fn()} isSubmitting />);
expect(screen.getByText('Submitting...')).toBeDefined();
expect(screen.getByText('commentForm.submitting')).toBeDefined();
});
it('after success, textarea clears', async () => {
const onSubmit = vi.fn().mockResolvedValue(undefined);
render(<CommentForm onSubmit={onSubmit} />);
const textarea = screen.getByRole('textbox', {
name: 'Comment',
}) as HTMLTextAreaElement;
const textarea = screen.getByRole('textbox') as HTMLTextAreaElement;
fireEvent.change(textarea, { target: { value: 'A comment' } });
fireEvent.click(screen.getByRole('button', { name: 'Comment' }));
fireEvent.click(screen.getByText('commentForm.submit'));
await waitFor(() => {
expect(onSubmit).toHaveBeenCalledWith('A comment');
@@ -60,4 +61,31 @@ describe('CommentForm', () => {
expect(textarea.value).toBe('');
});
});
it('shows Write/Preview tabs', () => {
render(<CommentForm onSubmit={vi.fn()} />);
expect(screen.getByText('commentForm.write')).toBeDefined();
expect(screen.getByText('commentForm.preview')).toBeDefined();
});
it('clicking Preview shows markdown preview', () => {
render(<CommentForm onSubmit={vi.fn()} />);
const textarea = screen.getByRole('textbox');
fireEvent.change(textarea, { target: { value: '**bold text**' } });
fireEvent.click(screen.getByText('commentForm.preview'));
expect(screen.getByTestId('markdown-preview')).toBeDefined();
expect(screen.queryByRole('textbox')).toBeNull();
});
it('clicking Write returns to textarea with content preserved', () => {
render(<CommentForm onSubmit={vi.fn()} />);
const textarea = screen.getByRole('textbox') as HTMLTextAreaElement;
fireEvent.change(textarea, { target: { value: 'my content' } });
fireEvent.click(screen.getByText('commentForm.preview'));
fireEvent.click(screen.getByText('commentForm.write'));
const restoredTextarea = screen.getByRole('textbox') as HTMLTextAreaElement;
expect(restoredTextarea.value).toBe('my content');
});
});
@@ -89,4 +89,16 @@ describe('LabelSyncSettings', () => {
const button = screen.getByText('labelSync.syncing');
expect(button.getAttribute('disabled')).not.toBeNull();
});
it('shows color swatches when enabled', () => {
const { container } = render(<LabelSyncSettings {...defaultProps} enabled />);
const swatches = container.querySelectorAll('[data-testid="label-swatch"]');
expect(swatches.length).toBe(7);
});
it('does not show color swatches when disabled', () => {
const { container } = render(<LabelSyncSettings {...defaultProps} />);
const swatches = container.querySelectorAll('[data-testid="label-swatch"]');
expect(swatches.length).toBe(0);
});
});
@@ -20,6 +20,7 @@ const mockGithub = {
disableLabelSync: vi.fn(),
syncIssueLabel: vi.fn(),
saveLabelSyncConfig: vi.fn(),
bulkLabelSync: vi.fn(),
};
beforeEach(() => {
@@ -82,26 +83,58 @@ describe('useLabelSync', () => {
expect(result.current.config.enabled).toBe(false);
});
it('syncIssueLabel calls API when sync is enabled', async () => {
it('syncIssueLabel calls API after debounce when sync is enabled', async () => {
vi.useFakeTimers();
mockGithub.syncIssueLabel.mockResolvedValue({ synced: true });
// Enable sync first
useLabelSyncStore.getState().setConfig({ enabled: true, lastSyncedAt: 'now' });
const { result } = renderHook(() => useLabelSync());
act(() => {
result.current.syncIssueLabel(42, 'triage', 'new');
});
// Not called yet — debounced
expect(mockGithub.syncIssueLabel).not.toHaveBeenCalled();
await act(async () => {
await result.current.syncIssueLabel(42, 'triage', 'new');
vi.advanceTimersByTime(2000);
});
expect(mockGithub.syncIssueLabel).toHaveBeenCalledWith('test-project', 42, 'triage', 'new');
vi.useRealTimers();
});
it('syncIssueLabel skips when sync is disabled', async () => {
it('syncIssueLabel skips when sync is disabled', () => {
vi.useFakeTimers();
const { result } = renderHook(() => useLabelSync());
await act(async () => {
await result.current.syncIssueLabel(42, 'triage', 'new');
act(() => {
result.current.syncIssueLabel(42, 'triage', 'new');
});
vi.advanceTimersByTime(2000);
expect(mockGithub.syncIssueLabel).not.toHaveBeenCalled();
vi.useRealTimers();
});
it('syncIssueLabel debounces rapid calls — only last fires', async () => {
vi.useFakeTimers();
mockGithub.syncIssueLabel.mockResolvedValue({ synced: true });
useLabelSyncStore.getState().setConfig({ enabled: true, lastSyncedAt: 'now' });
const { result } = renderHook(() => useLabelSync());
act(() => {
result.current.syncIssueLabel(42, 'triage', 'new');
result.current.syncIssueLabel(42, 'ready', 'triage');
result.current.syncIssueLabel(42, 'in_progress', 'ready');
});
await act(async () => {
vi.advanceTimersByTime(2000);
});
expect(mockGithub.syncIssueLabel).toHaveBeenCalledTimes(1);
expect(mockGithub.syncIssueLabel).toHaveBeenCalledWith('test-project', 42, 'in_progress', 'ready');
vi.useRealTimers();
});
it('saveConfig persists config', async () => {
@@ -117,6 +150,32 @@ describe('useLabelSync', () => {
expect(result.current.config.enabled).toBe(true);
});
it('bulkLabelSync calls API when sync is enabled', async () => {
mockGithub.bulkLabelSync.mockResolvedValue({ synced: 3, errors: 0 });
useLabelSyncStore.getState().setConfig({ enabled: true, lastSyncedAt: 'now' });
const { result } = renderHook(() => useLabelSync());
let bulkResult: { synced: number; errors: number } | undefined;
await act(async () => {
bulkResult = await result.current.bulkLabelSync([1, 2, 3]);
});
expect(mockGithub.bulkLabelSync).toHaveBeenCalledWith('test-project', [1, 2, 3]);
expect(bulkResult?.synced).toBe(3);
expect(result.current.isSyncing).toBe(false);
});
it('bulkLabelSync skips when sync is disabled', async () => {
const { result } = renderHook(() => useLabelSync());
let bulkResult: { synced: number; errors: number } | undefined;
await act(async () => {
bulkResult = await result.current.bulkLabelSync([1, 2, 3]);
});
expect(mockGithub.bulkLabelSync).not.toHaveBeenCalled();
expect(bulkResult?.synced).toBe(0);
});
it('handles enable error gracefully', async () => {
mockGithub.enableLabelSync.mockRejectedValue(new Error('Auth failed'));
@@ -1,7 +1,7 @@
/**
* @vitest-environment jsdom
*/
import { describe, it, expect, beforeEach } from 'vitest';
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { useTriageMode } from '../useTriageMode';
import { usePhase4Store } from '../../../../stores/github/phase4-store';
@@ -52,4 +52,87 @@ describe('useTriageMode', () => {
expect(result.current.isEnabled).toBe(false);
expect(result.current.isAvailable).toBe(false);
});
describe('keyboard shortcuts', () => {
function createPanel(panelNumber: string): HTMLElement {
const panel = document.createElement('section');
panel.setAttribute('data-triage-panel', panelNumber);
panel.tabIndex = -1;
document.body.appendChild(panel);
return panel;
}
afterEach(() => {
for (const el of document.querySelectorAll('[data-triage-panel]')) {
el.remove();
}
});
it('Ctrl+1 focuses issue list panel when triage enabled', () => {
const panel = createPanel('1');
const focusSpy = vi.spyOn(panel, 'focus');
Object.defineProperty(window, 'innerWidth', { value: 1400, writable: true });
const { result } = renderHook(() => useTriageMode());
act(() => result.current.toggle());
act(() => {
document.dispatchEvent(
new KeyboardEvent('keydown', { key: '1', ctrlKey: true, bubbles: true }),
);
});
expect(focusSpy).toHaveBeenCalled();
});
it('Ctrl+2 focuses issue detail panel', () => {
const panel = createPanel('2');
const focusSpy = vi.spyOn(panel, 'focus');
Object.defineProperty(window, 'innerWidth', { value: 1400, writable: true });
const { result } = renderHook(() => useTriageMode());
act(() => result.current.toggle());
act(() => {
document.dispatchEvent(
new KeyboardEvent('keydown', { key: '2', ctrlKey: true, bubbles: true }),
);
});
expect(focusSpy).toHaveBeenCalled();
});
it('Ctrl+3 focuses triage sidebar panel', () => {
const panel = createPanel('3');
const focusSpy = vi.spyOn(panel, 'focus');
Object.defineProperty(window, 'innerWidth', { value: 1400, writable: true });
const { result } = renderHook(() => useTriageMode());
act(() => result.current.toggle());
act(() => {
document.dispatchEvent(
new KeyboardEvent('keydown', { key: '3', ctrlKey: true, bubbles: true }),
);
});
expect(focusSpy).toHaveBeenCalled();
});
it('keyboard shortcuts inactive when triage mode disabled', () => {
const panel = createPanel('1');
const focusSpy = vi.spyOn(panel, 'focus');
Object.defineProperty(window, 'innerWidth', { value: 1400, writable: true });
renderHook(() => useTriageMode());
act(() => {
document.dispatchEvent(
new KeyboardEvent('keydown', { key: '1', ctrlKey: true, bubbles: true }),
);
});
expect(focusSpy).not.toHaveBeenCalled();
});
});
});
@@ -1,6 +1,7 @@
import { useCallback } from 'react';
import { useCallback, useEffect, useRef } from 'react';
import { useProjectStore } from '../../../stores/project-store';
import { useLabelSyncStore } from '../../../stores/github/label-sync-store';
import { SYNC_DEBOUNCE_MS } from '../../../../shared/constants/label-sync';
import type { LabelSyncConfig } from '../../../../shared/types/label-sync';
export function useLabelSync() {
@@ -44,19 +45,51 @@ export function useLabelSync() {
}
}, [projectId, store]);
const syncIssueLabel = useCallback(async (
const syncTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Clean up debounce timer on unmount
useEffect(() => {
return () => {
if (syncTimerRef.current) {
clearTimeout(syncTimerRef.current);
}
};
}, []);
const syncIssueLabel = useCallback((
issueNumber: number,
newState: string,
oldState: string | null,
) => {
if (!projectId || !store.config.enabled) return;
try {
await window.electronAPI.github.syncIssueLabel(projectId, issueNumber, newState, oldState);
} catch {
// Non-blocking — label sync failures shouldn't disrupt workflow
if (syncTimerRef.current) {
clearTimeout(syncTimerRef.current);
}
syncTimerRef.current = setTimeout(async () => {
try {
await window.electronAPI.github.syncIssueLabel(projectId, issueNumber, newState, oldState);
} catch {
// Non-blocking — label sync failures shouldn't disrupt workflow
}
}, SYNC_DEBOUNCE_MS);
}, [projectId, store.config.enabled]);
const bulkLabelSync = useCallback(async (issueNumbers: number[]) => {
if (!projectId || !store.config.enabled) return { synced: 0, errors: 0 };
store.setSyncing(true);
try {
const result = await window.electronAPI.github.bulkLabelSync(projectId, issueNumbers);
return result;
} catch (error) {
store.setError(error instanceof Error ? error.message : 'Bulk sync failed');
return { synced: 0, errors: 0 };
} finally {
store.setSyncing(false);
}
}, [projectId, store]);
const saveConfig = useCallback(async (config: LabelSyncConfig) => {
if (!projectId) return;
try {
@@ -77,6 +110,7 @@ export function useLabelSync() {
enableSync,
disableSync,
syncIssueLabel,
bulkLabelSync,
saveConfig,
};
}
@@ -23,6 +23,27 @@ export function useTriageMode() {
return () => window.removeEventListener('resize', check);
}, [setEnabled]);
// Keyboard shortcuts: Ctrl+1/2/3 focus triage panels
useEffect(() => {
if (!isEnabled) return;
const handleKeyDown = (e: KeyboardEvent) => {
if (!e.ctrlKey) return;
const panelNumber =
e.key === '1' ? '1' : e.key === '2' ? '2' : e.key === '3' ? '3' : null;
if (!panelNumber) return;
e.preventDefault();
const panel = document.querySelector<HTMLElement>(
`[data-triage-panel="${panelNumber}"]`,
);
panel?.focus();
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [isEnabled]);
const toggle = useCallback(() => {
if (!isAvailable) return;
setEnabled(!isEnabled);
@@ -51,6 +51,7 @@ describe('useAITriageStore', () => {
splitSuggestion: null,
splitProgress: null,
lastError: null,
lastBatchSnapshot: null,
});
});
@@ -303,6 +304,50 @@ describe('useAITriageStore', () => {
});
});
describe('undoBatch', () => {
it('snapshotBeforeApply saves current review items', () => {
const items = [
makeReviewItem({ issueNumber: 1, status: 'accepted' }),
makeReviewItem({ issueNumber: 2, status: 'rejected' }),
];
useAITriageStore.getState().addReviewItems(items);
useAITriageStore.getState().snapshotBeforeApply();
const snapshot = useAITriageStore.getState().lastBatchSnapshot;
expect(snapshot).toHaveLength(2);
expect(snapshot?.[0].status).toBe('accepted');
});
it('undoLastBatch restores snapshot and clears it', () => {
const items = [
makeReviewItem({ issueNumber: 1 }),
makeReviewItem({ issueNumber: 2 }),
];
useAITriageStore.getState().addReviewItems(items);
useAITriageStore.getState().snapshotBeforeApply();
// Simulate apply — accept all
useAITriageStore.getState().acceptAllRemaining();
expect(useAITriageStore.getState().reviewItems[0].status).toBe('accepted');
// Undo
useAITriageStore.getState().undoLastBatch();
const restored = useAITriageStore.getState().reviewItems;
expect(restored[0].status).toBe('pending');
expect(restored[1].status).toBe('pending');
expect(useAITriageStore.getState().lastBatchSnapshot).toBeNull();
});
it('undoLastBatch does nothing when no snapshot', () => {
useAITriageStore.getState().addReviewItems([
makeReviewItem({ issueNumber: 1, status: 'accepted' }),
]);
useAITriageStore.getState().undoLastBatch();
// Items unchanged
expect(useAITriageStore.getState().reviewItems[0].status).toBe('accepted');
});
});
describe('lastError', () => {
it('setLastError stores error message', () => {
useAITriageStore.getState().setLastError('API timeout');
@@ -37,6 +37,9 @@ interface AITriageState {
// Error state
lastError: string | null;
// Undo state
lastBatchSnapshot: TriageReviewItem[] | null;
// Actions
startTriage: () => void;
endTriage: () => void;
@@ -56,6 +59,8 @@ interface AITriageState {
setSplitProgress: (progress: SplitProgress) => void;
clearSplitProgress: () => void;
autoApplyByTrust: (config: ProgressiveTrustConfig) => void;
snapshotBeforeApply: () => void;
undoLastBatch: () => void;
setLastError: (error: string) => void;
clearLastError: () => void;
}
@@ -70,6 +75,7 @@ export const useAITriageStore = create<AITriageState>((set, get) => ({
splitSuggestion: null,
splitProgress: null,
lastError: null,
lastBatchSnapshot: null,
// Triage operation
startTriage: () => set({ isTriaging: true, lastError: null }),
@@ -118,6 +124,17 @@ export const useAITriageStore = create<AITriageState>((set, get) => ({
setSplitProgress: (progress) => set({ splitProgress: progress }),
clearSplitProgress: () => set({ splitProgress: null }),
snapshotBeforeApply: () =>
set((state) => ({
lastBatchSnapshot: state.reviewItems.map((item) => ({ ...item })),
})),
undoLastBatch: () =>
set((state) => {
if (!state.lastBatchSnapshot) return state;
return { reviewItems: state.lastBatchSnapshot, lastBatchSnapshot: null };
}),
setLastError: (error) => set({ lastError: error }),
clearLastError: () => set({ lastError: null }),
@@ -497,6 +497,7 @@ export const IPC_CHANNELS = {
GITHUB_LABEL_SYNC_ISSUE: 'github:label-sync:issue',
GITHUB_LABEL_SYNC_STATUS: 'github:label-sync:status',
GITHUB_LABEL_SYNC_SAVE: 'github:label-sync:save',
GITHUB_LABEL_SYNC_BULK: 'github:label-sync:bulk',
// Dependencies (Phase 4)
GITHUB_DEPS_FETCH: 'github:deps:fetch',
@@ -576,7 +576,16 @@
"apply": "Apply",
"dismiss": "Dismiss",
"auto": "Auto-applied",
"noResults": "No triage results to review"
"noResults": "No triage results to review",
"undo": "Undo"
},
"commentForm": {
"write": "Write",
"preview": "Preview",
"placeholder": "Write a comment...",
"emptyError": "Comment cannot be empty",
"submit": "Comment",
"submitting": "Submitting..."
},
"issueSplit": {
"title": "Split Issue",
@@ -576,7 +576,16 @@
"apply": "Appliquer",
"dismiss": "Fermer",
"auto": "Auto-appliqué",
"noResults": "Aucun résultat de triage à revoir"
"noResults": "Aucun résultat de triage à revoir",
"undo": "Annuler"
},
"commentForm": {
"write": "Rédiger",
"preview": "Aperçu",
"placeholder": "Écrire un commentaire...",
"emptyError": "Le commentaire ne peut pas être vide",
"submit": "Commenter",
"submitting": "Envoi en cours..."
},
"issueSplit": {
"title": "Diviser l'issue",
+43 -37
View File
@@ -3,7 +3,7 @@
**Branch:** `terminal/enhancement-issues-tab`
**Created:** 2026-02-12
**Total Gaps:** 41 confirmed (from triple-verified audit)
**Status:** 34 / 41 complete
**Status:** 40 / 41 complete
---
@@ -269,16 +269,16 @@ Each gap has: ID, description, status, files to modify, doc reference, test stat
- **Commit:** GAP-18
### GAP-19: Keyboard shortcuts Ctrl+1/2/3 for triage panels
- **Status:** `PENDING`
- **Status:** `DONE`
- **Priority:** SHOULD-FIX
- **Scope:** Medium
- **Doc ref:** Phase 4 PRD > US-5 > AC5.1-5.4; Phase 5 PRD > US-8 > AC-8.6
- **Files to modify:** `hooks/useTriageMode.ts`, `renderer/components/GitHubIssues.tsx`
- **Fix:** Add useEffect with keydown listener in useTriageMode for Ctrl+1/2/3. Only active when isEnabled. Need panel refs/IDs in GitHubIssues.tsx for focus targeting.
- **Tests:** Simulate Ctrl+1 → focuses list panel; Ctrl+2 → detail; Ctrl+3 → sidebar; only in triage mode
- **Test status:** `PENDING`
- **Files modified:** `hooks/useTriageMode.ts`, `renderer/components/GitHubIssues.tsx`
- **Fix:** Added useEffect with keydown listener for Ctrl+1/2/3 in useTriageMode. Only active when isEnabled. Added data-triage-panel="1|2|3" + tabIndex={-1} to panel sections.
- **Tests:** 4 new tests: Ctrl+1/2/3 focus panels, shortcuts inactive when disabled. 9 total.
- **Test status:** `PASS`
- **Depends on:** GAP-24 (panels need role="region" + aria-label as focus targets)
- **Commit:** —
- **Commit:** Phase E batch
### GAP-20: DependencyList items not clickable
- **Status:** `DONE`
@@ -429,16 +429,16 @@ Each gap has: ID, description, status, files to modify, doc reference, test stat
- **Commit:** pending
### GAP-35: No undo batch mechanism
- **Status:** `PENDING`
- **Status:** `DONE`
- **Priority:** SHOULD-FIX (NICE-TO-HAVE)
- **Scope:** Large
- **Doc ref:** Phase 3 PRD > US-4 > AC4.9; Phase 3 audit GAP-4
- **Files to modify:** `renderer/stores/github/ai-triage-store.ts`, `main/ipc-handlers/github/ai-triage-handlers.ts`, `renderer/components/github-issues/hooks/useAITriage.ts`, `renderer/components/github-issues/components/BatchTriageReview.tsx`
- **Fix:** Track lastBatchApplied in store. Store what labels were added/removed per issue. Add undoLastBatch action that reverses changes. Add "Undo" button in BatchTriageReview.
- **Tests:** Apply batch → undo → labels reverted
- **Test status:** `PENDING`
- **Files modified:** `stores/github/ai-triage-store.ts`, `components/BatchTriageReview.tsx`, `GitHubIssues.tsx`, `en/common.json`, `fr/common.json`
- **Fix:** Added lastBatchSnapshot state + snapshotBeforeApply/undoLastBatch actions to store. Added onUndo prop to BatchTriageReview with Undo button. GitHubIssues snapshots before apply and passes onUndo when snapshot exists. i18n: batchReview.undo (EN/FR).
- **Tests:** 3 store tests (snapshot, restore, no-op when no snapshot) + 2 component tests (undo button shown/hidden). 22 total.
- **Test status:** `PASS`
- **Depends on:** None
- **Commit:** —
- **Commit:** Phase E batch
### GAP-36: Trust level UI (Crawl/Walk/Run) not displayed
- **Status:** `DONE`
@@ -493,52 +493,52 @@ Each gap has: ID, description, status, files to modify, doc reference, test stat
- **Commit:** pending
### GAP-40: Label sync debounce not implemented
- **Status:** `PENDING`
- **Status:** `DONE`
- **Priority:** NICE-TO-HAVE
- **Scope:** Medium
- **Doc ref:** Phase 4 PRD > US-2 > AC2.2, AC2.7
- **Files to modify:** `renderer/components/github-issues/hooks/useLabelSync.ts`
- **Fix:** Wrap syncIssueLabel in debounce using SYNC_DEBOUNCE_MS. Batch pending syncs.
- **Tests:** Rapid transitions → only one sync call per debounce window
- **Test status:** `PENDING`
- **Files modified:** `hooks/useLabelSync.ts`
- **Fix:** Added useRef timer + SYNC_DEBOUNCE_MS (2000ms) debounce to syncIssueLabel. Clears timer on each call, only fires the last. Cleanup on unmount.
- **Tests:** Updated existing test + 1 new debounce test: rapid calls → only last fires. 11 total.
- **Test status:** `PASS`
- **Depends on:** GAP-15 (useLabelSync wired first)
- **Commit:** —
- **Commit:** Phase E batch
### GAP-41: No bulk label sync handler
- **Status:** `PENDING`
- **Status:** `DONE`
- **Priority:** NICE-TO-HAVE
- **Scope:** Medium
- **Doc ref:** Phase 4 PRD Section 3.2; Phase 4 impl plan WP-2.1
- **Files to modify:** `shared/constants/ipc.ts`, `main/ipc-handlers/github/label-sync-handlers.ts`, `preload/api/modules/github-api.ts`
- **Fix:** Add GITHUB_LABEL_SYNC_BULK + PROGRESS IPC channels. Add bulk handler. Add preload method.
- **Tests:** Bulk sync 5 issues → all labeled; progress events fired
- **Test status:** `PENDING`
- **Files modified:** `shared/constants/ipc.ts`, `main/ipc-handlers/github/label-sync-handlers.ts`, `preload/api/modules/github-api.ts`, `hooks/useLabelSync.ts`
- **Fix:** Added GITHUB_LABEL_SYNC_BULK IPC channel. Added bulk handler in label-sync-handlers (iterates issues, reads enrichment for state, syncs labels). Added preload method + hook bulkLabelSync function.
- **Tests:** 2 new hook tests: bulk calls API when enabled, skips when disabled. 11 total.
- **Test status:** `PASS`
- **Depends on:** GAP-15
- **Commit:** —
- **Commit:** Phase E batch
### GAP-42: No color preview in LabelSyncSettings
- **Status:** `PENDING`
- **Status:** `DONE`
- **Priority:** NICE-TO-HAVE
- **Scope:** Small
- **Doc ref:** Phase 4 PRD > US-8 > AC8.4; Phase 4 impl plan WP-7.1
- **Files to modify:** `renderer/components/github-issues/components/LabelSyncSettings.tsx`
- **Fix:** Import WORKFLOW_LABEL_COLORS/WORKFLOW_LABEL_MAP. Render color swatches for each workflow state when enabled.
- **Tests:** Render enabled → 7 color swatches visible
- **Test status:** `PENDING`
- **Files modified:** `components/LabelSyncSettings.tsx`
- **Fix:** Imported getWorkflowLabels. Renders 7 color swatches when enabled, each with colored dot + label name (e.g., ac:new, ac:triage). Uses hex color from WORKFLOW_LABEL_COLORS with opacity for background.
- **Tests:** 2 new tests: 7 swatches when enabled, 0 when disabled. 13 total.
- **Test status:** `PASS`
- **Depends on:** None
- **Commit:** —
- **Commit:** Phase E batch
### GAP-43: No markdown preview toggle in CommentForm/InlineEditor
- **Status:** `PENDING`
- **Status:** `DONE`
- **Priority:** NICE-TO-HAVE
- **Scope:** Large
- **Doc ref:** Phase 2 PRD > US-2 AC2.2; Phase 2 PRD > US-6 AC6.2
- **Files to modify:** `renderer/components/github-issues/components/CommentForm.tsx`, `renderer/components/github-issues/components/InlineEditor.tsx`, i18n files
- **Fix:** Add Write/Preview tab toggle. In Preview mode render markdown (ReactMarkdown already in project). Add i18n keys for tabs.
- **Tests:** Click Preview → markdown rendered; click Write → textarea shown; toggle preserves content
- **Test status:** `PENDING`
- **Files modified:** `components/CommentForm.tsx`, `en/common.json`, `fr/common.json`
- **Fix:** Added Write/Preview tab toggle to CommentForm. Preview mode renders ReactMarkdown. Also converted all hardcoded strings to i18n keys (commentForm.write/preview/placeholder/emptyError/submit/submitting). Content preserved when toggling.
- **Tests:** 3 new tests (tabs visible, preview shows markdown, write restores content) + 5 updated existing. 8 total.
- **Test status:** `PASS`
- **Depends on:** None
- **Commit:** —
- **Commit:** Phase E batch
---
@@ -564,6 +564,12 @@ Each gap has: ID, description, status, files to modify, doc reference, test stat
| 2026-02-13 | GAP-09 | DONE — BulkResultsPanel mounted in GitHubIssues.tsx, wired to mutation store | Phase D |
| 2026-02-13 | GAP-10 | DONE — EnrichmentCommentPreview mounted, enrichmentResult in store/hook, formatEnrichmentComment utility, 8 tests | Phase D |
| 2026-02-13 | GAP-16 | DONE — BatchTriageReview mounted in GitHubIssues.tsx, wired accept/reject/apply callbacks | Phase D |
| 2026-02-13 | GAP-19 | DONE — Ctrl+1/2/3 keyboard shortcuts in useTriageMode, data-triage-panel attrs, 4 new tests | Phase E |
| 2026-02-13 | GAP-35 | DONE — Undo batch: snapshotBeforeApply/undoLastBatch in store, Undo button in BatchTriageReview, 5 tests | Phase E |
| 2026-02-13 | GAP-40 | DONE — Label sync debounce (2000ms) via useRef timer in useLabelSync, 1 new debounce test | Phase E |
| 2026-02-13 | GAP-41 | DONE — Bulk label sync: IPC channel + handler + preload + hook method, 2 new tests | Phase E |
| 2026-02-13 | GAP-42 | DONE — Color swatches in LabelSyncSettings (7 workflow labels with colors), 2 new tests | Phase E |
| 2026-02-13 | GAP-43 | DONE — Write/Preview tabs in CommentForm with ReactMarkdown, i18n keys EN+FR, 8 tests | Phase E |
---