feat(issues): add batch staging banner and investigation settings (F7)
Co-Authored-By: Claude Opus 4.6 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
aad0fd8ad3
commit
03c217e5d2
+147
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* BatchStagingBanner — collapsible inline banner shown at the top of the issues
|
||||
* view when auto-create is enabled and there are pending task creations.
|
||||
*
|
||||
* Each pending item shows issue title, investigation summary snippet,
|
||||
* and approve/reject buttons. Batch "Approve All" / "Reject All" actions
|
||||
* are provided at the top.
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ChevronDown, ChevronRight, CheckCircle2, XCircle, Layers } from 'lucide-react';
|
||||
import { Button } from '../../ui/button';
|
||||
import { Badge } from '../../ui/badge';
|
||||
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from '../../ui/collapsible';
|
||||
import type { BatchStagingItem } from '@shared/types';
|
||||
|
||||
interface BatchStagingBannerProps {
|
||||
projectId: string;
|
||||
items: BatchStagingItem[];
|
||||
onApprove: (issueNumber: number) => void;
|
||||
onReject: (issueNumber: number) => void;
|
||||
onApproveAll: () => void;
|
||||
}
|
||||
|
||||
export function BatchStagingBanner({
|
||||
projectId: _projectId,
|
||||
items,
|
||||
onApprove,
|
||||
onReject,
|
||||
onApproveAll,
|
||||
}: BatchStagingBannerProps) {
|
||||
const { t } = useTranslation('common');
|
||||
const [isOpen, setIsOpen] = useState(items.length > 0);
|
||||
|
||||
if (items.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const pendingItems = items.filter((item) => item.approved === undefined);
|
||||
|
||||
return (
|
||||
<Collapsible open={isOpen} onOpenChange={setIsOpen}>
|
||||
<div className="mx-2 mt-2 rounded-lg border border-info/30 bg-info/5">
|
||||
{/* Header / trigger */}
|
||||
<CollapsibleTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center justify-between px-3 py-2 text-left hover:bg-info/10 rounded-t-lg transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{isOpen ? (
|
||||
<ChevronDown className="h-4 w-4 text-info" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4 text-info" />
|
||||
)}
|
||||
<Layers className="h-4 w-4 text-info" />
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
{t('investigation.batchStaging.title', 'Pending Task Creations')}
|
||||
</span>
|
||||
<Badge variant="info" className="text-[10px] px-1.5 py-0">
|
||||
{pendingItems.length}
|
||||
</Badge>
|
||||
</div>
|
||||
{/* Batch actions — stop propagation so clicks don't toggle collapse */}
|
||||
<div
|
||||
className="flex items-center gap-1"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') e.stopPropagation(); }}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 text-xs text-success hover:text-success"
|
||||
onClick={onApproveAll}
|
||||
disabled={pendingItems.length === 0}
|
||||
>
|
||||
<CheckCircle2 className="h-3 w-3 mr-1" />
|
||||
{t('investigation.batchStaging.approveAll', 'Approve All')}
|
||||
</Button>
|
||||
</div>
|
||||
</button>
|
||||
</CollapsibleTrigger>
|
||||
|
||||
{/* Expandable item list */}
|
||||
<CollapsibleContent>
|
||||
<div className="border-t border-info/20">
|
||||
{items.map((item) => (
|
||||
<div
|
||||
key={item.issueNumber}
|
||||
className="flex items-center justify-between px-3 py-2 border-b border-border/30 last:border-b-0"
|
||||
>
|
||||
<div className="flex-1 min-w-0 mr-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-muted-foreground font-mono">
|
||||
#{item.issueNumber}
|
||||
</span>
|
||||
<span className="text-sm text-foreground truncate">
|
||||
{item.issueTitle}
|
||||
</span>
|
||||
</div>
|
||||
{item.report.summary && (
|
||||
<p className="text-xs text-muted-foreground mt-0.5 line-clamp-1">
|
||||
{item.report.summary}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
{item.approved === undefined ? (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-7 p-0 text-success hover:text-success hover:bg-success/10"
|
||||
onClick={() => onApprove(item.issueNumber)}
|
||||
title={t('investigation.batchStaging.approve', 'Approve')}
|
||||
>
|
||||
<CheckCircle2 className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-7 p-0 text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||
onClick={() => onReject(item.issueNumber)}
|
||||
title={t('investigation.batchStaging.reject', 'Reject')}
|
||||
>
|
||||
<XCircle className="h-4 w-4" />
|
||||
</Button>
|
||||
</>
|
||||
) : item.approved ? (
|
||||
<Badge variant="success" className="text-[10px]">
|
||||
{t('investigation.batchStaging.approved', 'Approved')}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="destructive" className="text-[10px]">
|
||||
{t('investigation.batchStaging.rejected', 'Rejected')}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</div>
|
||||
</Collapsible>
|
||||
);
|
||||
}
|
||||
+349
@@ -0,0 +1,349 @@
|
||||
/**
|
||||
* InvestigationSettings — subsection within the GitHub settings page.
|
||||
*
|
||||
* All 8 settings from the design doc:
|
||||
* 1. Auto-create tasks (toggle, default off)
|
||||
* 2. Auto-start tasks (toggle, default off)
|
||||
* 3. Pipeline mode (dropdown: Full / Skip to planning / Minimal)
|
||||
* 4. Auto-post to GitHub (toggle, default off)
|
||||
* 5. Auto-close issues (toggle, default off)
|
||||
* 6. Max parallel investigations (number 1-10, default 3)
|
||||
* 7. Label include filter (multi-select)
|
||||
* 8. Label exclude filter (multi-select)
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Brain, Loader2 } from 'lucide-react';
|
||||
import { Switch } from '../../ui/switch';
|
||||
import { Label } from '../../ui/label';
|
||||
import { Input } from '../../ui/input';
|
||||
import { Separator } from '../../ui/separator';
|
||||
import {
|
||||
Select,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
} from '../../ui/select';
|
||||
import { useInvestigationStore } from '../../../stores/github/investigation-store';
|
||||
import type { InvestigationSettings as InvestigationSettingsType, InvestigationPipelineMode } from '@shared/types';
|
||||
|
||||
const DEFAULT_SETTINGS: InvestigationSettingsType = {
|
||||
autoCreateTasks: false,
|
||||
autoStartTasks: false,
|
||||
pipelineMode: 'full',
|
||||
autoPostToGitHub: false,
|
||||
autoCloseIssues: false,
|
||||
maxParallelInvestigations: 3,
|
||||
labelIncludeFilter: [],
|
||||
labelExcludeFilter: [],
|
||||
};
|
||||
|
||||
interface InvestigationSettingsProps {
|
||||
projectId: string;
|
||||
}
|
||||
|
||||
export function InvestigationSettings({ projectId }: InvestigationSettingsProps) {
|
||||
const { t } = useTranslation(['settings', 'common']);
|
||||
const storeSettings = useInvestigationStore((s) => s.getSettings(projectId));
|
||||
const setStoreSettings = useInvestigationStore((s) => s.setSettings);
|
||||
const [settings, setSettings] = useState<InvestigationSettingsType>(
|
||||
storeSettings ?? DEFAULT_SETTINGS,
|
||||
);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [labelIncludeInput, setLabelIncludeInput] = useState('');
|
||||
const [labelExcludeInput, setLabelExcludeInput] = useState('');
|
||||
|
||||
// Load settings from store on mount / projectId change
|
||||
useEffect(() => {
|
||||
if (storeSettings) {
|
||||
setSettings(storeSettings);
|
||||
}
|
||||
}, [storeSettings]);
|
||||
|
||||
// Persist to store + backend whenever local settings change
|
||||
const persist = useCallback(
|
||||
(updated: InvestigationSettingsType) => {
|
||||
setSettings(updated);
|
||||
setStoreSettings(projectId, updated);
|
||||
// Save to backend if API available
|
||||
if (window.electronAPI?.github?.saveInvestigationSettings) {
|
||||
setIsLoading(true);
|
||||
window.electronAPI.github
|
||||
.saveInvestigationSettings(projectId, updated)
|
||||
.catch(() => { /* silently handle save errors */ })
|
||||
.finally(() => setIsLoading(false));
|
||||
}
|
||||
},
|
||||
[projectId, setStoreSettings],
|
||||
);
|
||||
|
||||
const updateSetting = <K extends keyof InvestigationSettingsType>(
|
||||
key: K,
|
||||
value: InvestigationSettingsType[K],
|
||||
) => {
|
||||
persist({ ...settings, [key]: value });
|
||||
};
|
||||
|
||||
const addLabelFilter = (type: 'include' | 'exclude', label: string) => {
|
||||
const trimmed = label.trim();
|
||||
if (!trimmed) return;
|
||||
const key = type === 'include' ? 'labelIncludeFilter' : 'labelExcludeFilter';
|
||||
if (settings[key].includes(trimmed)) return;
|
||||
persist({ ...settings, [key]: [...settings[key], trimmed] });
|
||||
if (type === 'include') setLabelIncludeInput('');
|
||||
else setLabelExcludeInput('');
|
||||
};
|
||||
|
||||
const removeLabelFilter = (type: 'include' | 'exclude', label: string) => {
|
||||
const key = type === 'include' ? 'labelIncludeFilter' : 'labelExcludeFilter';
|
||||
persist({ ...settings, [key]: settings[key].filter((l) => l !== label) });
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="space-y-4" aria-label={t('settings:investigation.title', 'AI Investigation')}>
|
||||
{/* Section Header */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Brain className="h-4 w-4 text-info" />
|
||||
<h4 className="text-sm font-medium text-foreground">
|
||||
{t('settings:investigation.title', 'AI Investigation')}
|
||||
</h4>
|
||||
{isLoading && <Loader2 className="h-3 w-3 animate-spin text-muted-foreground" />}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('settings:investigation.description', 'Configure how AI investigations create tasks and interact with GitHub.')}
|
||||
</p>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* 1. Auto-create tasks */}
|
||||
<SettingToggle
|
||||
label={t('settings:investigation.autoCreateTasks', 'Auto-create tasks')}
|
||||
description={t(
|
||||
'settings:investigation.autoCreateTasksDescription',
|
||||
'Automatically create kanban tasks from investigation results',
|
||||
)}
|
||||
checked={settings.autoCreateTasks}
|
||||
onCheckedChange={(v) => updateSetting('autoCreateTasks', v)}
|
||||
/>
|
||||
|
||||
{/* 2. Auto-start tasks */}
|
||||
<SettingToggle
|
||||
label={t('settings:investigation.autoStartTasks', 'Auto-start tasks')}
|
||||
description={t(
|
||||
'settings:investigation.autoStartTasksDescription',
|
||||
'Auto-start build pipeline when tasks are created',
|
||||
)}
|
||||
checked={settings.autoStartTasks}
|
||||
onCheckedChange={(v) => updateSetting('autoStartTasks', v)}
|
||||
/>
|
||||
|
||||
{/* 3. Pipeline mode */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label className="font-normal text-foreground">
|
||||
{t('settings:investigation.pipelineMode', 'Pipeline mode')}
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t(
|
||||
'settings:investigation.pipelineModeDescription',
|
||||
'Pipeline mode for investigation-created tasks',
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<Select
|
||||
value={settings.pipelineMode}
|
||||
onValueChange={(v) => updateSetting('pipelineMode', v as InvestigationPipelineMode)}
|
||||
>
|
||||
<SelectTrigger className="w-[180px] h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="full">
|
||||
{t('settings:investigation.pipelineFull', 'Full')}
|
||||
</SelectItem>
|
||||
<SelectItem value="skip_to_planning">
|
||||
{t('settings:investigation.pipelineSkipToPlanning', 'Skip to planning')}
|
||||
</SelectItem>
|
||||
<SelectItem value="minimal">
|
||||
{t('settings:investigation.pipelineMinimal', 'Minimal')}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* 4. Auto-post to GitHub */}
|
||||
<SettingToggle
|
||||
label={t('settings:investigation.autoPostToGitHub', 'Auto-post to GitHub')}
|
||||
description={t(
|
||||
'settings:investigation.autoPostToGitHubDescription',
|
||||
'Automatically post investigation results to GitHub',
|
||||
)}
|
||||
checked={settings.autoPostToGitHub}
|
||||
onCheckedChange={(v) => updateSetting('autoPostToGitHub', v)}
|
||||
/>
|
||||
|
||||
{/* 5. Auto-close issues */}
|
||||
<SettingToggle
|
||||
label={t('settings:investigation.autoCloseIssues', 'Auto-close issues')}
|
||||
description={t(
|
||||
'settings:investigation.autoCloseIssuesDescription',
|
||||
'Close GitHub issues when linked task completes',
|
||||
)}
|
||||
checked={settings.autoCloseIssues}
|
||||
onCheckedChange={(v) => updateSetting('autoCloseIssues', v)}
|
||||
/>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* 6. Max parallel investigations */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label className="font-normal text-foreground">
|
||||
{t('settings:investigation.maxParallel', 'Max parallel investigations')}
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t(
|
||||
'settings:investigation.maxParallelDescription',
|
||||
'Maximum concurrent investigations (1-10)',
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={10}
|
||||
value={settings.maxParallelInvestigations}
|
||||
onChange={(e) => {
|
||||
const val = Math.min(10, Math.max(1, Number.parseInt(e.target.value, 10) || 1));
|
||||
updateSetting('maxParallelInvestigations', val);
|
||||
}}
|
||||
className="w-20 h-8 text-xs text-center"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* 7. Label include filter */}
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-0.5">
|
||||
<Label className="font-normal text-foreground">
|
||||
{t('settings:investigation.labelIncludeFilter', 'Label include filter')}
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t(
|
||||
'settings:investigation.labelIncludeFilterDescription',
|
||||
'Only auto-create tasks for issues with these labels',
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{settings.labelIncludeFilter.map((label) => (
|
||||
<LabelChip
|
||||
key={label}
|
||||
label={label}
|
||||
onRemove={() => removeLabelFilter('include', label)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
placeholder={t('settings:investigation.labelPlaceholder', 'Add label...')}
|
||||
value={labelIncludeInput}
|
||||
onChange={(e) => setLabelIncludeInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
addLabelFilter('include', labelIncludeInput);
|
||||
}
|
||||
}}
|
||||
className="h-7 text-xs flex-1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 8. Label exclude filter */}
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-0.5">
|
||||
<Label className="font-normal text-foreground">
|
||||
{t('settings:investigation.labelExcludeFilter', 'Label exclude filter')}
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t(
|
||||
'settings:investigation.labelExcludeFilterDescription',
|
||||
'Never auto-create tasks for issues with these labels',
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{settings.labelExcludeFilter.map((label) => (
|
||||
<LabelChip
|
||||
key={label}
|
||||
label={label}
|
||||
onRemove={() => removeLabelFilter('exclude', label)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
placeholder={t('settings:investigation.labelPlaceholder', 'Add label...')}
|
||||
value={labelExcludeInput}
|
||||
onChange={(e) => setLabelExcludeInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
addLabelFilter('exclude', labelExcludeInput);
|
||||
}
|
||||
}}
|
||||
className="h-7 text-xs flex-1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// ---- Internal sub-components ----
|
||||
|
||||
interface SettingToggleProps {
|
||||
label: string;
|
||||
description: string;
|
||||
checked: boolean;
|
||||
onCheckedChange: (checked: boolean) => void;
|
||||
}
|
||||
|
||||
function SettingToggle({ label, description, checked, onCheckedChange }: SettingToggleProps) {
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label className="font-normal text-foreground">{label}</Label>
|
||||
<p className="text-xs text-muted-foreground">{description}</p>
|
||||
</div>
|
||||
<Switch checked={checked} onCheckedChange={onCheckedChange} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface LabelChipProps {
|
||||
label: string;
|
||||
onRemove: () => void;
|
||||
}
|
||||
|
||||
function LabelChip({ label, onRemove }: LabelChipProps) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-md text-xs bg-muted text-muted-foreground">
|
||||
{label}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRemove}
|
||||
className="ml-0.5 hover:text-destructive transition-colors"
|
||||
aria-label={`Remove ${label}`}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -39,3 +39,7 @@ export { CompletenessBreakdown } from './CompletenessBreakdown';
|
||||
|
||||
// Phase 5 — Integration
|
||||
export { TriageSidebar } from './TriageSidebar';
|
||||
|
||||
// Investigation System (F7)
|
||||
export { BatchStagingBanner } from './BatchStagingBanner';
|
||||
export { InvestigationSettings } from './InvestigationSettings';
|
||||
|
||||
@@ -9,6 +9,7 @@ import { GitLabIntegration } from '../integrations/GitLabIntegration';
|
||||
import { InitializationGuard } from '../common/InitializationGuard';
|
||||
import { LabelSyncSettingsConnected } from '../../github-issues/components/LabelSyncSettingsConnected';
|
||||
import { ProgressiveTrustSettingsConnected } from '../../github-issues/components/ProgressiveTrustSettingsConnected';
|
||||
import { InvestigationSettings } from '../../github-issues/components/InvestigationSettings';
|
||||
import type { ProjectSettingsSection } from '../ProjectSettingsContent';
|
||||
|
||||
interface SectionRouterProps {
|
||||
@@ -143,6 +144,7 @@ export function SectionRouter({
|
||||
/>
|
||||
<LabelSyncSettingsConnected />
|
||||
<ProgressiveTrustSettingsConnected />
|
||||
<InvestigationSettings projectId={project.id} />
|
||||
</InitializationGuard>
|
||||
</SettingsSection>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user