Add diff review flow dialog
This commit is contained in:
@@ -16,6 +16,7 @@ import { useSnippetsStore } from '@/stores/useSnippetsStore';
|
||||
import { appendInlineComments } from '@/lib/messages/inlineComments';
|
||||
import { renderMagicPrompt } from '@/lib/magicPrompts';
|
||||
import { startReviewFlow } from '@/lib/reviewFlow';
|
||||
import { ReviewFlowDialog, type ReviewFlowExecution } from '@/components/session/ReviewFlowDialog';
|
||||
import { AttachedFilesList, AttachedVSCodeFileChips, ActiveEditorFileSuggestion } from './FileAttachment';
|
||||
import ToolOutputDialog from './message/ToolOutputDialog';
|
||||
import type { ToolPopupContent } from './message/types';
|
||||
@@ -1014,6 +1015,8 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
const projects = useProjectsStore((state) => state.projects);
|
||||
const activeProjectId = useProjectsStore((state) => state.activeProjectId);
|
||||
const setActiveProjectIdOnly = useProjectsStore((state) => state.setActiveProjectIdOnly);
|
||||
const [reviewDialogOpen, setReviewDialogOpen] = React.useState(false);
|
||||
const [reviewFlowSubmitting, setReviewFlowSubmitting] = React.useState(false);
|
||||
|
||||
const currentProviderId = useConfigStore((state) => state.currentProviderId);
|
||||
const currentModelId = useConfigStore((state) => state.currentModelId);
|
||||
@@ -1062,6 +1065,35 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
setImagePreviewOpen(open);
|
||||
}, [setImagePreviewOpen]);
|
||||
|
||||
const handleStartReviewFlow = React.useCallback(async (execution: ReviewFlowExecution) => {
|
||||
if (!currentSessionId) return;
|
||||
const directory = useSessionUIStore.getState().getDirectoryForSession(currentSessionId) || currentDirectory || '';
|
||||
if (!directory) {
|
||||
toast.error(t('diffView.reviewDialog.toast.noSessionDirectory'));
|
||||
return;
|
||||
}
|
||||
|
||||
setReviewFlowSubmitting(true);
|
||||
try {
|
||||
await startReviewFlow({
|
||||
originalSessionID: currentSessionId,
|
||||
directory,
|
||||
providerID: execution.providerID,
|
||||
modelID: execution.modelID,
|
||||
agent: execution.agent || undefined,
|
||||
variant: execution.variant || undefined,
|
||||
generateHandoff: execution.generateHandoff,
|
||||
returnAfterHandoffRequest: execution.generateHandoff,
|
||||
});
|
||||
setReviewDialogOpen(false);
|
||||
} catch (error) {
|
||||
console.error('[review-flow] failed to start review flow', error);
|
||||
toast.error(error instanceof Error ? error.message : t('diffView.reviewDialog.toast.startFailed'));
|
||||
} finally {
|
||||
setReviewFlowSubmitting(false);
|
||||
}
|
||||
}, [currentSessionId, currentDirectory, t]);
|
||||
|
||||
const isDesktopExpanded = isExpandedInput && !isMobile;
|
||||
const chatInputRadius = 'var(--radius-xl)';
|
||||
const useCompactChatPlaceholder = isMobile || isNarrowComposer;
|
||||
@@ -1940,24 +1972,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
return;
|
||||
}
|
||||
else if (commandName === 'handoff-review' && currentSessionId && !isMobile && !isVSCodeRuntime()) {
|
||||
try {
|
||||
const directory = useSessionUIStore.getState().getDirectoryForSession(currentSessionId) || currentDirectory || '';
|
||||
if (!directory) {
|
||||
throw new Error('Session directory is unavailable');
|
||||
}
|
||||
await startReviewFlow({
|
||||
originalSessionID: currentSessionId,
|
||||
directory,
|
||||
providerID: providerIdToSend,
|
||||
modelID: modelIdToSend,
|
||||
agent: agentNameToSend,
|
||||
variant: variantToSend,
|
||||
agentMentionName,
|
||||
});
|
||||
scrollToBottom?.();
|
||||
} catch (error) {
|
||||
console.error('[review-flow] failed to start review flow', error);
|
||||
}
|
||||
setReviewDialogOpen(true);
|
||||
return;
|
||||
}
|
||||
else if (commandName === 'plan-feature' && (currentSessionId || newSessionDraftOpen)) {
|
||||
@@ -4502,6 +4517,13 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
setLinkedIssue(null);
|
||||
}}
|
||||
/>
|
||||
<ReviewFlowDialog
|
||||
open={reviewDialogOpen}
|
||||
onOpenChange={setReviewDialogOpen}
|
||||
projectDirectory={currentSessionDirectoryForSync ?? currentDirectory ?? null}
|
||||
submitting={reviewFlowSubmitting}
|
||||
onConfirm={handleStartReviewFlow}
|
||||
/>
|
||||
<ToolOutputDialog
|
||||
popup={attachmentPreview}
|
||||
onOpenChange={handleAttachmentPreviewOpenChange}
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
import React from 'react';
|
||||
|
||||
import { AgentSelector } from '@/components/sections/commands/AgentSelector';
|
||||
import { ModelSelector } from '@/components/sections/agents/ModelSelector';
|
||||
import { ThinkingPill } from '@/components/session/ThinkingPill';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { isPrimaryMode } from '@/components/chat/mobileControlsUtils';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { useConfigStore } from '@/stores/useConfigStore';
|
||||
import { useAgentsStore } from '@/stores/useAgentsStore';
|
||||
|
||||
export type ReviewFlowExecution = {
|
||||
providerID: string;
|
||||
modelID: string;
|
||||
variant: string;
|
||||
agent: string;
|
||||
generateHandoff: boolean;
|
||||
};
|
||||
|
||||
type ReviewFlowDialogProps = {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
projectDirectory: string | null;
|
||||
submitting?: boolean;
|
||||
onConfirm: (execution: ReviewFlowExecution) => Promise<void> | void;
|
||||
};
|
||||
|
||||
const getInitialExecution = (params: {
|
||||
providerID: string;
|
||||
modelID: string;
|
||||
variant: string;
|
||||
agent: string;
|
||||
}): ReviewFlowExecution => ({
|
||||
providerID: params.providerID,
|
||||
modelID: params.modelID,
|
||||
variant: params.variant,
|
||||
agent: params.agent,
|
||||
generateHandoff: true,
|
||||
});
|
||||
|
||||
export function ReviewFlowDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
projectDirectory,
|
||||
submitting = false,
|
||||
onConfirm,
|
||||
}: ReviewFlowDialogProps) {
|
||||
const { t } = useI18n();
|
||||
const loadProviders = useConfigStore((state) => state.loadProviders);
|
||||
const loadConfigAgents = useConfigStore((state) => state.loadAgents);
|
||||
const loadAgentsStoreAgents = useAgentsStore((state) => state.loadAgents);
|
||||
const providers = useConfigStore((state) => state.providers);
|
||||
const currentProviderID = useConfigStore((state) => state.currentProviderId);
|
||||
const currentModelID = useConfigStore((state) => state.currentModelId);
|
||||
const currentVariant = useConfigStore((state) => state.currentVariant || '');
|
||||
const currentAgentName = useConfigStore((state) => state.currentAgentName || '');
|
||||
|
||||
const [execution, setExecution] = React.useState<ReviewFlowExecution>(() => getInitialExecution({
|
||||
providerID: currentProviderID,
|
||||
modelID: currentModelID,
|
||||
variant: currentVariant,
|
||||
agent: currentAgentName,
|
||||
}));
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!open) return;
|
||||
void loadProviders({ directory: projectDirectory, source: 'reviewFlowDialog' });
|
||||
void loadConfigAgents({ directory: projectDirectory });
|
||||
void loadAgentsStoreAgents();
|
||||
}, [open, loadProviders, loadConfigAgents, loadAgentsStoreAgents, projectDirectory]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!open) return;
|
||||
setExecution(getInitialExecution({
|
||||
providerID: currentProviderID,
|
||||
modelID: currentModelID,
|
||||
variant: currentVariant,
|
||||
agent: currentAgentName,
|
||||
}));
|
||||
}, [open, currentProviderID, currentModelID, currentVariant, currentAgentName]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!open || providers.length === 0) return;
|
||||
|
||||
const provider = providers.find((item) => item.id === execution.providerID) ?? providers[0];
|
||||
const models = Array.isArray(provider?.models) ? provider.models : [];
|
||||
const hasModel = models.some((item) => item.id === execution.modelID);
|
||||
const fallbackModelID = models[0]?.id ?? '';
|
||||
|
||||
if (provider?.id === execution.providerID && hasModel) return;
|
||||
|
||||
setExecution((prev) => ({
|
||||
...prev,
|
||||
providerID: provider?.id ?? '',
|
||||
modelID: hasModel ? prev.modelID : fallbackModelID,
|
||||
variant: '',
|
||||
}));
|
||||
}, [open, providers, execution.providerID, execution.modelID]);
|
||||
|
||||
const agentFilter = React.useCallback((agent: { mode?: string }) => isPrimaryMode(agent.mode), []);
|
||||
|
||||
const variantOptions = React.useMemo(() => {
|
||||
const provider = providers.find((item) => item.id === execution.providerID);
|
||||
const model = provider?.models?.find((item) => item.id === execution.modelID) as { variants?: Record<string, unknown> } | undefined;
|
||||
return model?.variants ? Object.keys(model.variants) : [];
|
||||
}, [providers, execution.providerID, execution.modelID]);
|
||||
|
||||
const hasVariantOptions = variantOptions.length > 0;
|
||||
|
||||
React.useEffect(() => {
|
||||
if (hasVariantOptions || !execution.variant) return;
|
||||
setExecution((prev) => ({ ...prev, variant: '' }));
|
||||
}, [hasVariantOptions, execution.variant]);
|
||||
|
||||
const canConfirm = execution.providerID.trim().length > 0 && execution.modelID.trim().length > 0;
|
||||
|
||||
const handleSubmit = React.useCallback(() => {
|
||||
if (!canConfirm || submitting) return;
|
||||
void onConfirm(execution);
|
||||
}, [canConfirm, submitting, onConfirm, execution]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
handleSubmit();
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', onKeyDown);
|
||||
return () => window.removeEventListener('keydown', onKeyDown);
|
||||
}, [open, handleSubmit]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(nextOpen) => { if (!submitting) onOpenChange(nextOpen); }}>
|
||||
<DialogContent className="max-w-md overflow-visible">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('diffView.reviewDialog.title')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t('diffView.reviewDialog.description')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="rounded-md border border-[color:color-mix(in_srgb,var(--status-info)_35%,var(--interactive-border))] bg-[color:color-mix(in_srgb,var(--status-info)_10%,var(--surface-background))] px-3 py-2 typography-meta text-foreground">
|
||||
{t('diffView.reviewDialog.info')}
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-2 typography-ui-label text-foreground">
|
||||
<Checkbox
|
||||
checked={execution.generateHandoff}
|
||||
onChange={(generateHandoff) => setExecution((prev) => ({ ...prev, generateHandoff }))}
|
||||
disabled={submitting}
|
||||
ariaLabel={t('diffView.reviewDialog.generateHandoff')}
|
||||
/>
|
||||
<span>{t('diffView.reviewDialog.generateHandoff')}</span>
|
||||
</label>
|
||||
|
||||
<div className="flex min-w-0 flex-col gap-1.5">
|
||||
<span className="typography-meta font-medium text-muted-foreground">{t('chat.modelControls.model')}</span>
|
||||
<ModelSelector
|
||||
providerId={execution.providerID}
|
||||
modelId={execution.modelID}
|
||||
className="max-w-[320px] justify-between"
|
||||
dropdownPortalToBody
|
||||
onChange={(providerID, modelID) => {
|
||||
setExecution((prev) => ({ ...prev, providerID, modelID, variant: '' }));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<span className="typography-meta font-medium text-muted-foreground">{t('sessions.scheduledTasks.editor.thinkingLevel.label')}</span>
|
||||
<ThinkingPill
|
||||
value={execution.variant}
|
||||
options={variantOptions}
|
||||
disabled={!hasVariantOptions || submitting}
|
||||
onChange={(variant) => setExecution((prev) => ({ ...prev, variant }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<span className="typography-meta font-medium text-muted-foreground">{t('sessions.scheduledTasks.editor.agent.label')}</span>
|
||||
<AgentSelector
|
||||
agentName={execution.agent}
|
||||
filter={agentFilter}
|
||||
dropdownPortalToBody
|
||||
onChange={(agent) => setExecution((prev) => ({ ...prev, agent }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" size="sm" onClick={() => onOpenChange(false)} disabled={submitting}>
|
||||
{t('diffView.reviewDialog.actions.cancel')}
|
||||
</Button>
|
||||
<Button size="sm" onClick={handleSubmit} disabled={!canConfirm || submitting}>
|
||||
{submitting ? t('diffView.reviewDialog.actions.starting') : t('diffView.reviewDialog.actions.start')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -25,6 +25,7 @@ import { getLanguageFromExtension, isImageFile } from '@/lib/toolHelpers';
|
||||
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
|
||||
import { DiffViewToggle } from '@/components/chat/message/DiffViewToggle';
|
||||
import type { DiffViewMode } from '@/components/chat/message/types';
|
||||
import { ReviewFlowDialog, type ReviewFlowExecution } from '@/components/session/ReviewFlowDialog';
|
||||
import { PierreDiffViewer } from './PierreDiffViewer';
|
||||
import { useDeviceInfo } from '@/lib/device';
|
||||
import { FileTypeIcon } from '@/components/icons/FileTypeIcon';
|
||||
@@ -35,6 +36,9 @@ import { sessionEvents } from '@/lib/sessionEvents';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import type { I18nKey } from '@/lib/i18n/store';
|
||||
import { fileDiffFromPatch } from '@/lib/diff/patchFileDiff';
|
||||
import { isVSCodeRuntime } from '@/lib/desktop';
|
||||
import { startReviewFlow } from '@/lib/reviewFlow';
|
||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||
import type { FileDiffMetadata } from '@pierre/diffs';
|
||||
|
||||
// Minimum width for side-by-side diff view (px)
|
||||
@@ -948,6 +952,8 @@ export const DiffView: React.FC<DiffViewProps> = ({
|
||||
const [mountedStackedFiles, setMountedStackedFiles] = React.useState<Set<string>>(() => new Set());
|
||||
const [loadFullFiles, setLoadFullFiles] = React.useState(false);
|
||||
const [scrollRequestNonce, setScrollRequestNonce] = React.useState(0);
|
||||
const [reviewDialogOpen, setReviewDialogOpen] = React.useState(false);
|
||||
const [reviewFlowSubmitting, setReviewFlowSubmitting] = React.useState(false);
|
||||
|
||||
const pendingDiffFile = useUIStore((state) => state.pendingDiffFile);
|
||||
const pendingDiffStaged = useUIStore((state) => state.pendingDiffStaged);
|
||||
@@ -958,11 +964,13 @@ export const DiffView: React.FC<DiffViewProps> = ({
|
||||
const diffWrapLinesStore = useUIStore((state) => state.diffWrapLines);
|
||||
const setDiffWrapLines = useUIStore((state) => state.setDiffWrapLines);
|
||||
const openContextFileAtLine = useUIStore((state) => state.openContextFileAtLine);
|
||||
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
|
||||
const diffWrapLines = diffWrapLinesStore;
|
||||
const forcedStaged = diffScope === 'staged' ? true : diffScope === 'working' ? false : null;
|
||||
const activeDiffStaged = forcedStaged ?? displayFileStaged;
|
||||
|
||||
const isMobileLayout = isMobile || screenWidth <= 768;
|
||||
const showReviewAction = Boolean(currentSessionId) && !isMobileLayout && !isVSCodeRuntime();
|
||||
const showFileSidebar = !hideStackedFileSidebar && !isMobileLayout && screenWidth >= 1024;
|
||||
const diffScrollRef = React.useRef<HTMLElement | null>(null);
|
||||
const fileSectionRefs = React.useRef(new Map<string, HTMLDivElement | null>());
|
||||
@@ -1270,6 +1278,35 @@ export const DiffView: React.FC<DiffViewProps> = ({
|
||||
queueVisibleStackedFilesSync();
|
||||
}, [cancelPendingScrollAlignment, changedFiles, queueVisibleStackedFilesSync]);
|
||||
|
||||
const handleStartReviewFlow = React.useCallback(async (execution: ReviewFlowExecution) => {
|
||||
if (!currentSessionId) return;
|
||||
const directory = useSessionUIStore.getState().getDirectoryForSession(currentSessionId) || effectiveDirectory || '';
|
||||
if (!directory) {
|
||||
toast.error(t('diffView.reviewDialog.toast.noSessionDirectory'));
|
||||
return;
|
||||
}
|
||||
|
||||
setReviewFlowSubmitting(true);
|
||||
try {
|
||||
await startReviewFlow({
|
||||
originalSessionID: currentSessionId,
|
||||
directory,
|
||||
providerID: execution.providerID,
|
||||
modelID: execution.modelID,
|
||||
agent: execution.agent || undefined,
|
||||
variant: execution.variant || undefined,
|
||||
generateHandoff: execution.generateHandoff,
|
||||
returnAfterHandoffRequest: execution.generateHandoff,
|
||||
});
|
||||
setReviewDialogOpen(false);
|
||||
} catch (error) {
|
||||
console.error('[review-flow] failed to start review flow', error);
|
||||
toast.error(error instanceof Error ? error.message : t('diffView.reviewDialog.toast.startFailed'));
|
||||
} finally {
|
||||
setReviewFlowSubmitting(false);
|
||||
}
|
||||
}, [currentSessionId, effectiveDirectory, t]);
|
||||
|
||||
const scrollToFile = React.useCallback((path: string): boolean => {
|
||||
const node = fileSectionRefs.current.get(path);
|
||||
const scrollRoot = diffScrollRef.current;
|
||||
@@ -1582,6 +1619,25 @@ export const DiffView: React.FC<DiffViewProps> = ({
|
||||
</span>
|
||||
</Button>
|
||||
)}
|
||||
{changedFiles.length > 0 && showReviewAction && (
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={() => setReviewDialogOpen(true)}
|
||||
disabled={reviewFlowSubmitting}
|
||||
className="diff-toolbar__review-button h-7 flex-shrink-0 gap-1.5 px-2"
|
||||
aria-label={t('diffView.actions.reviewAria')}
|
||||
>
|
||||
{reviewFlowSubmitting ? (
|
||||
<Icon name="loader-4" className="size-4 animate-spin" />
|
||||
) : (
|
||||
<Icon name="search-eye" className="size-4" />
|
||||
)}
|
||||
<span className="diff-toolbar__review-label typography-ui-label">
|
||||
{t('diffView.actions.review')}
|
||||
</span>
|
||||
</Button>
|
||||
)}
|
||||
{changedFiles.length > 0 && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
@@ -1626,6 +1682,14 @@ export const DiffView: React.FC<DiffViewProps> = ({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ReviewFlowDialog
|
||||
open={reviewDialogOpen}
|
||||
onOpenChange={setReviewDialogOpen}
|
||||
projectDirectory={effectiveDirectory ?? null}
|
||||
submitting={reviewFlowSubmitting}
|
||||
onConfirm={handleStartReviewFlow}
|
||||
/>
|
||||
|
||||
{renderContent()}
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user