Merge branch 'refs/heads/main' into cw/agent-details-conflict-1772799979862
# Conflicts: # apps/server/drizzle/meta/_journal.json
This commit is contained in:
@@ -12,7 +12,7 @@ export interface SerializedTask {
|
||||
parentTaskId: string | null;
|
||||
name: string;
|
||||
description: string | null;
|
||||
type: "auto" | "checkpoint:human-verify" | "checkpoint:decision" | "checkpoint:human-action";
|
||||
type: "auto";
|
||||
category: string;
|
||||
priority: "low" | "medium" | "high";
|
||||
status: "pending" | "in_progress" | "completed" | "blocked";
|
||||
|
||||
@@ -27,6 +27,7 @@ export function PlanSection({
|
||||
(a) =>
|
||||
a.mode === "plan" &&
|
||||
a.initiativeId === initiativeId &&
|
||||
!a.userDismissedAt &&
|
||||
["running", "waiting_for_input", "idle"].includes(a.status),
|
||||
)
|
||||
.sort(
|
||||
|
||||
@@ -7,14 +7,15 @@ interface CommentFormProps {
|
||||
onCancel: () => void;
|
||||
placeholder?: string;
|
||||
submitLabel?: string;
|
||||
initialValue?: string;
|
||||
}
|
||||
|
||||
export const CommentForm = forwardRef<HTMLTextAreaElement, CommentFormProps>(
|
||||
function CommentForm(
|
||||
{ onSubmit, onCancel, placeholder = "Write a comment...", submitLabel = "Comment" },
|
||||
{ onSubmit, onCancel, placeholder = "Write a comment...", submitLabel = "Comment", initialValue = "" },
|
||||
ref
|
||||
) {
|
||||
const [body, setBody] = useState("");
|
||||
const [body, setBody] = useState(initialValue);
|
||||
|
||||
const handleSubmit = useCallback(() => {
|
||||
const trimmed = body.trim();
|
||||
|
||||
@@ -1,71 +1,214 @@
|
||||
import { Check, RotateCcw } from "lucide-react";
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { Check, RotateCcw, Reply, Pencil } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { CommentForm } from "./CommentForm";
|
||||
import type { ReviewComment } from "./types";
|
||||
|
||||
interface CommentThreadProps {
|
||||
comments: ReviewComment[];
|
||||
onResolve: (commentId: string) => void;
|
||||
onUnresolve: (commentId: string) => void;
|
||||
onReply?: (parentCommentId: string, body: string) => void;
|
||||
onEdit?: (commentId: string, body: string) => void;
|
||||
}
|
||||
|
||||
export function CommentThread({ comments, onResolve, onUnresolve }: CommentThreadProps) {
|
||||
export function CommentThread({ comments, onResolve, onUnresolve, onReply, onEdit }: CommentThreadProps) {
|
||||
// Group: root comments (no parentCommentId) and their replies
|
||||
const rootComments = comments.filter((c) => !c.parentCommentId);
|
||||
const repliesByParent = new Map<string, ReviewComment[]>();
|
||||
for (const c of comments) {
|
||||
if (c.parentCommentId) {
|
||||
const arr = repliesByParent.get(c.parentCommentId) ?? [];
|
||||
arr.push(c);
|
||||
repliesByParent.set(c.parentCommentId, arr);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{comments.map((comment) => (
|
||||
<div
|
||||
{rootComments.map((comment) => (
|
||||
<RootComment
|
||||
key={comment.id}
|
||||
className={`rounded border p-2.5 text-xs space-y-1.5 ${
|
||||
comment.resolved
|
||||
? "border-status-success-border bg-status-success-bg/50"
|
||||
: "border-border bg-card"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="font-semibold text-foreground">{comment.author}</span>
|
||||
<span className="text-muted-foreground">
|
||||
{formatTime(comment.createdAt)}
|
||||
</span>
|
||||
{comment.resolved && (
|
||||
<span className="flex items-center gap-0.5 text-status-success-fg text-[10px] font-medium">
|
||||
<Check className="h-3 w-3" />
|
||||
Resolved
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
{comment.resolved ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 px-1.5 text-[10px]"
|
||||
onClick={() => onUnresolve(comment.id)}
|
||||
>
|
||||
<RotateCcw className="h-3 w-3 mr-0.5" />
|
||||
Reopen
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 px-1.5 text-[10px]"
|
||||
onClick={() => onResolve(comment.id)}
|
||||
>
|
||||
<Check className="h-3 w-3 mr-0.5" />
|
||||
Resolve
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-foreground/90 leading-relaxed whitespace-pre-wrap">
|
||||
{comment.body}
|
||||
</p>
|
||||
</div>
|
||||
comment={comment}
|
||||
replies={repliesByParent.get(comment.id) ?? []}
|
||||
onResolve={onResolve}
|
||||
onUnresolve={onUnresolve}
|
||||
onReply={onReply}
|
||||
onEdit={onEdit}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RootComment({
|
||||
comment,
|
||||
replies,
|
||||
onResolve,
|
||||
onUnresolve,
|
||||
onReply,
|
||||
onEdit,
|
||||
}: {
|
||||
comment: ReviewComment;
|
||||
replies: ReviewComment[];
|
||||
onResolve: (id: string) => void;
|
||||
onUnresolve: (id: string) => void;
|
||||
onReply?: (parentCommentId: string, body: string) => void;
|
||||
onEdit?: (commentId: string, body: string) => void;
|
||||
}) {
|
||||
const [isReplying, setIsReplying] = useState(false);
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const replyRef = useRef<HTMLTextAreaElement>(null);
|
||||
const editRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (isReplying) replyRef.current?.focus();
|
||||
}, [isReplying]);
|
||||
|
||||
useEffect(() => {
|
||||
if (editingId) editRef.current?.focus();
|
||||
}, [editingId]);
|
||||
|
||||
const isEditingRoot = editingId === comment.id;
|
||||
|
||||
return (
|
||||
<div className={`rounded border ${comment.resolved ? "border-status-success-border bg-status-success-bg/50" : "border-border bg-card"}`}>
|
||||
{/* Root comment */}
|
||||
<div className="p-2.5 text-xs space-y-1.5">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="font-semibold text-foreground">{comment.author}</span>
|
||||
<span className="text-muted-foreground">{formatTime(comment.createdAt)}</span>
|
||||
{comment.resolved && (
|
||||
<span className="flex items-center gap-0.5 text-status-success-fg text-[10px] font-medium">
|
||||
<Check className="h-3 w-3" />
|
||||
Resolved
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-0.5">
|
||||
{onEdit && comment.author !== "agent" && !comment.resolved && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 px-1.5 text-[10px]"
|
||||
onClick={() => setEditingId(isEditingRoot ? null : comment.id)}
|
||||
>
|
||||
<Pencil className="h-3 w-3 mr-0.5" />
|
||||
Edit
|
||||
</Button>
|
||||
)}
|
||||
{onReply && !comment.resolved && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 px-1.5 text-[10px]"
|
||||
onClick={() => setIsReplying(!isReplying)}
|
||||
>
|
||||
<Reply className="h-3 w-3 mr-0.5" />
|
||||
Reply
|
||||
</Button>
|
||||
)}
|
||||
{comment.resolved ? (
|
||||
<Button variant="ghost" size="sm" className="h-6 px-1.5 text-[10px]" onClick={() => onUnresolve(comment.id)}>
|
||||
<RotateCcw className="h-3 w-3 mr-0.5" />
|
||||
Reopen
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="ghost" size="sm" className="h-6 px-1.5 text-[10px]" onClick={() => onResolve(comment.id)}>
|
||||
<Check className="h-3 w-3 mr-0.5" />
|
||||
Resolve
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{isEditingRoot ? (
|
||||
<CommentForm
|
||||
ref={editRef}
|
||||
initialValue={comment.body}
|
||||
onSubmit={(body) => {
|
||||
onEdit!(comment.id, body);
|
||||
setEditingId(null);
|
||||
}}
|
||||
onCancel={() => setEditingId(null)}
|
||||
placeholder="Edit comment..."
|
||||
submitLabel="Save"
|
||||
/>
|
||||
) : (
|
||||
<p className="text-foreground/90 leading-relaxed whitespace-pre-wrap">{comment.body}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Replies */}
|
||||
{replies.length > 0 && (
|
||||
<div className="border-t border-border/50">
|
||||
{replies.map((reply) => (
|
||||
<div
|
||||
key={reply.id}
|
||||
className={`px-2.5 py-2 text-xs border-l-2 ml-3 space-y-1 ${
|
||||
reply.author === "agent"
|
||||
? "border-l-primary bg-primary/5"
|
||||
: "border-l-muted-foreground/30"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-1.5">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className={`font-semibold ${reply.author === "agent" ? "text-primary" : "text-foreground"}`}>
|
||||
{reply.author}
|
||||
</span>
|
||||
<span className="text-muted-foreground">{formatTime(reply.createdAt)}</span>
|
||||
</div>
|
||||
{onEdit && reply.author !== "agent" && !comment.resolved && editingId !== reply.id && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-5 px-1 text-[10px]"
|
||||
onClick={() => setEditingId(reply.id)}
|
||||
>
|
||||
<Pencil className="h-2.5 w-2.5 mr-0.5" />
|
||||
Edit
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{editingId === reply.id ? (
|
||||
<CommentForm
|
||||
ref={editRef}
|
||||
initialValue={reply.body}
|
||||
onSubmit={(body) => {
|
||||
onEdit!(reply.id, body);
|
||||
setEditingId(null);
|
||||
}}
|
||||
onCancel={() => setEditingId(null)}
|
||||
placeholder="Edit reply..."
|
||||
submitLabel="Save"
|
||||
/>
|
||||
) : (
|
||||
<p className="text-foreground/90 leading-relaxed whitespace-pre-wrap">{reply.body}</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Reply form */}
|
||||
{isReplying && onReply && (
|
||||
<div className="border-t border-border/50 p-2.5">
|
||||
<CommentForm
|
||||
ref={replyRef}
|
||||
onSubmit={(body) => {
|
||||
onReply(comment.id, body);
|
||||
setIsReplying(false);
|
||||
}}
|
||||
onCancel={() => setIsReplying(false)}
|
||||
placeholder="Write a reply..."
|
||||
submitLabel="Reply"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatTime(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
return d.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" });
|
||||
|
||||
180
apps/web/src/components/review/ConflictResolutionPanel.tsx
Normal file
180
apps/web/src/components/review/ConflictResolutionPanel.tsx
Normal file
@@ -0,0 +1,180 @@
|
||||
import { Loader2, AlertCircle, GitMerge, CheckCircle2, ChevronDown, ChevronRight, Terminal } from 'lucide-react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { QuestionForm } from '@/components/QuestionForm';
|
||||
import { useConflictAgent } from '@/hooks/useConflictAgent';
|
||||
|
||||
interface ConflictResolutionPanelProps {
|
||||
initiativeId: string;
|
||||
conflicts: string[];
|
||||
onResolved: () => void;
|
||||
}
|
||||
|
||||
export function ConflictResolutionPanel({ initiativeId, conflicts, onResolved }: ConflictResolutionPanelProps) {
|
||||
const { state, agent, questions, spawn, resume, stop, dismiss } = useConflictAgent(initiativeId);
|
||||
const [showManual, setShowManual] = useState(false);
|
||||
const prevStateRef = useRef(state);
|
||||
|
||||
// Auto-dismiss and re-check mergeability when conflict agent completes
|
||||
useEffect(() => {
|
||||
const prev = prevStateRef.current;
|
||||
prevStateRef.current = state;
|
||||
if (prev !== 'completed' && state === 'completed') {
|
||||
dismiss();
|
||||
onResolved();
|
||||
}
|
||||
}, [state, dismiss, onResolved]);
|
||||
|
||||
if (state === 'none') {
|
||||
return (
|
||||
<div className="mx-4 mt-3 rounded-lg border border-status-error-border bg-status-error-bg/50 p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertCircle className="h-4 w-4 text-status-error-fg mt-0.5 shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-sm font-semibold text-foreground mb-1">
|
||||
{conflicts.length} merge conflict{conflicts.length !== 1 ? 's' : ''} detected
|
||||
</h3>
|
||||
<ul className="text-xs text-muted-foreground font-mono space-y-0.5 mb-3">
|
||||
{conflicts.map((file) => (
|
||||
<li key={file}>{file}</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => spawn.mutate({ initiativeId })}
|
||||
disabled={spawn.isPending}
|
||||
className="h-7 text-xs"
|
||||
>
|
||||
{spawn.isPending ? (
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
) : (
|
||||
<GitMerge className="h-3 w-3" />
|
||||
)}
|
||||
Resolve with Agent
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setShowManual(!showManual)}
|
||||
className="h-7 text-xs text-muted-foreground"
|
||||
>
|
||||
{showManual ? <ChevronDown className="h-3 w-3" /> : <ChevronRight className="h-3 w-3" />}
|
||||
Manual Resolution
|
||||
</Button>
|
||||
</div>
|
||||
{spawn.error && (
|
||||
<p className="mt-2 text-xs text-status-error-fg">{spawn.error.message}</p>
|
||||
)}
|
||||
{showManual && (
|
||||
<div className="mt-3 rounded border border-border bg-card p-3">
|
||||
<p className="text-xs text-muted-foreground mb-2">
|
||||
In your project clone, run:
|
||||
</p>
|
||||
<pre className="text-xs font-mono bg-terminal text-terminal-fg rounded p-2 overflow-x-auto">
|
||||
{`git checkout <initiative-branch>
|
||||
git merge <target-branch>
|
||||
# Resolve conflicts in each file
|
||||
git add <resolved-files>
|
||||
git commit --no-edit`}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (state === 'running') {
|
||||
return (
|
||||
<div className="mx-4 mt-3 rounded-lg border border-border bg-card px-4 py-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin text-primary" />
|
||||
<span className="text-sm text-muted-foreground">Resolving merge conflicts...</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => stop.mutate()}
|
||||
disabled={stop.isPending}
|
||||
className="h-7 text-xs"
|
||||
>
|
||||
Stop
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (state === 'waiting' && questions) {
|
||||
return (
|
||||
<div className="mx-4 mt-3 rounded-lg border border-border bg-card p-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Terminal className="h-3.5 w-3.5 text-primary" />
|
||||
<h3 className="text-sm font-semibold">Agent needs input</h3>
|
||||
</div>
|
||||
<QuestionForm
|
||||
questions={questions.questions}
|
||||
onSubmit={(answers) => resume.mutate(answers)}
|
||||
onCancel={() => {}}
|
||||
onDismiss={() => stop.mutate()}
|
||||
isSubmitting={resume.isPending}
|
||||
isDismissing={stop.isPending}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (state === 'completed') {
|
||||
// Auto-dismiss effect above handles this — show brief success message during transition
|
||||
return (
|
||||
<div className="mx-4 mt-3 rounded-lg border border-status-success-border bg-status-success-bg/50 px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckCircle2 className="h-3.5 w-3.5 text-status-success-fg" />
|
||||
<span className="text-sm text-status-success-fg">Conflicts resolved — re-checking mergeability...</span>
|
||||
<Loader2 className="h-3 w-3 animate-spin text-status-success-fg" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (state === 'crashed') {
|
||||
return (
|
||||
<div className="mx-4 mt-3 rounded-lg border border-status-error-border bg-status-error-bg/50 px-4 py-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertCircle className="h-3.5 w-3.5 text-status-error-fg" />
|
||||
<span className="text-sm text-status-error-fg">Conflict resolution agent crashed</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
dismiss();
|
||||
}}
|
||||
className="h-7 text-xs"
|
||||
>
|
||||
Dismiss
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
dismiss();
|
||||
spawn.mutate({ initiativeId });
|
||||
}}
|
||||
disabled={spawn.isPending}
|
||||
className="h-7 text-xs"
|
||||
>
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -12,6 +12,8 @@ interface DiffViewerProps {
|
||||
) => void;
|
||||
onResolveComment: (commentId: string) => void;
|
||||
onUnresolveComment: (commentId: string) => void;
|
||||
onReplyComment?: (parentCommentId: string, body: string) => void;
|
||||
onEditComment?: (commentId: string, body: string) => void;
|
||||
viewedFiles?: Set<string>;
|
||||
onToggleViewed?: (filePath: string) => void;
|
||||
onRegisterRef?: (filePath: string, el: HTMLDivElement | null) => void;
|
||||
@@ -23,6 +25,8 @@ export function DiffViewer({
|
||||
onAddComment,
|
||||
onResolveComment,
|
||||
onUnresolveComment,
|
||||
onReplyComment,
|
||||
onEditComment,
|
||||
viewedFiles,
|
||||
onToggleViewed,
|
||||
onRegisterRef,
|
||||
@@ -37,6 +41,8 @@ export function DiffViewer({
|
||||
onAddComment={onAddComment}
|
||||
onResolveComment={onResolveComment}
|
||||
onUnresolveComment={onUnresolveComment}
|
||||
onReplyComment={onReplyComment}
|
||||
onEditComment={onEditComment}
|
||||
isViewed={viewedFiles?.has(file.newPath) ?? false}
|
||||
onToggleViewed={() => onToggleViewed?.(file.newPath)}
|
||||
/>
|
||||
|
||||
@@ -52,6 +52,8 @@ interface FileCardProps {
|
||||
) => void;
|
||||
onResolveComment: (commentId: string) => void;
|
||||
onUnresolveComment: (commentId: string) => void;
|
||||
onReplyComment?: (parentCommentId: string, body: string) => void;
|
||||
onEditComment?: (commentId: string, body: string) => void;
|
||||
isViewed?: boolean;
|
||||
onToggleViewed?: () => void;
|
||||
}
|
||||
@@ -62,6 +64,8 @@ export function FileCard({
|
||||
onAddComment,
|
||||
onResolveComment,
|
||||
onUnresolveComment,
|
||||
onReplyComment,
|
||||
onEditComment,
|
||||
isViewed = false,
|
||||
onToggleViewed = () => {},
|
||||
}: FileCardProps) {
|
||||
@@ -77,10 +81,11 @@ export function FileCard({
|
||||
const tokenMap = useHighlightedFile(file.newPath, allLines);
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-border overflow-hidden">
|
||||
<div className="rounded-lg border border-border overflow-clip">
|
||||
{/* File header — sticky so it stays visible when scrolling */}
|
||||
<button
|
||||
className={`sticky top-0 z-10 flex w-full items-center gap-2 px-3 py-2 bg-muted hover:bg-muted/90 text-left text-sm font-mono transition-colors ${leftBorderClass[file.changeType]}`}
|
||||
className={`sticky z-10 flex w-full items-center gap-2 px-3 py-2 bg-muted hover:bg-muted/90 text-left text-sm font-mono transition-colors ${leftBorderClass[file.changeType]}`}
|
||||
style={{ top: 'var(--review-header-h, 0px)' }}
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
>
|
||||
{expanded ? (
|
||||
@@ -157,6 +162,8 @@ export function FileCard({
|
||||
onAddComment={onAddComment}
|
||||
onResolveComment={onResolveComment}
|
||||
onUnresolveComment={onUnresolveComment}
|
||||
onReplyComment={onReplyComment}
|
||||
onEditComment={onEditComment}
|
||||
tokenMap={tokenMap}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -15,6 +15,8 @@ interface HunkRowsProps {
|
||||
) => void;
|
||||
onResolveComment: (commentId: string) => void;
|
||||
onUnresolveComment: (commentId: string) => void;
|
||||
onReplyComment?: (parentCommentId: string, body: string) => void;
|
||||
onEditComment?: (commentId: string, body: string) => void;
|
||||
tokenMap?: LineTokenMap | null;
|
||||
}
|
||||
|
||||
@@ -25,6 +27,8 @@ export function HunkRows({
|
||||
onAddComment,
|
||||
onResolveComment,
|
||||
onUnresolveComment,
|
||||
onReplyComment,
|
||||
onEditComment,
|
||||
tokenMap,
|
||||
}: HunkRowsProps) {
|
||||
const [commentingLine, setCommentingLine] = useState<{
|
||||
@@ -98,6 +102,8 @@ export function HunkRows({
|
||||
onSubmitComment={handleSubmitComment}
|
||||
onResolveComment={onResolveComment}
|
||||
onUnresolveComment={onUnresolveComment}
|
||||
onReplyComment={onReplyComment}
|
||||
onEditComment={onEditComment}
|
||||
tokens={
|
||||
line.newLineNumber !== null
|
||||
? tokenMap?.get(line.newLineNumber) ?? undefined
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import { useCallback, useMemo, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Loader2, GitBranch, ArrowRight, FileCode, Plus, Minus, Upload, GitMerge } from "lucide-react";
|
||||
import { Loader2, GitBranch, ArrowRight, FileCode, Plus, Minus, Upload, GitMerge, AlertTriangle, CheckCircle2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { trpc } from "@/lib/trpc";
|
||||
import { parseUnifiedDiff } from "./parse-diff";
|
||||
import { DiffViewer } from "./DiffViewer";
|
||||
import { ReviewSidebar } from "./ReviewSidebar";
|
||||
import { PreviewControls } from "./PreviewControls";
|
||||
import { ConflictResolutionPanel } from "./ConflictResolutionPanel";
|
||||
|
||||
interface InitiativeReviewProps {
|
||||
initiativeId: string;
|
||||
@@ -48,6 +51,61 @@ export function InitiativeReview({ initiativeId, onCompleted }: InitiativeReview
|
||||
{ enabled: !!selectedCommit },
|
||||
);
|
||||
|
||||
// Mergeability check
|
||||
const mergeabilityQuery = trpc.checkInitiativeMergeability.useQuery(
|
||||
{ initiativeId },
|
||||
{ refetchInterval: 30_000 },
|
||||
);
|
||||
const mergeability = mergeabilityQuery.data ?? null;
|
||||
|
||||
// Auto-refresh mergeability when a conflict agent completes
|
||||
const conflictAgentQuery = trpc.getActiveConflictAgent.useQuery({ initiativeId });
|
||||
const conflictAgentStatus = conflictAgentQuery.data?.status;
|
||||
const prevConflictStatusRef = useRef(conflictAgentStatus);
|
||||
useEffect(() => {
|
||||
const prev = prevConflictStatusRef.current;
|
||||
prevConflictStatusRef.current = conflictAgentStatus;
|
||||
// When agent transitions from running/waiting to idle (completed)
|
||||
if (prev && ['running', 'waiting_for_input'].includes(prev) && conflictAgentStatus === 'idle') {
|
||||
void mergeabilityQuery.refetch();
|
||||
void diffQuery.refetch();
|
||||
void commitsQuery.refetch();
|
||||
}
|
||||
}, [conflictAgentStatus, mergeabilityQuery, diffQuery, commitsQuery]);
|
||||
|
||||
// Preview state
|
||||
const projectsQuery = trpc.getInitiativeProjects.useQuery({ initiativeId });
|
||||
const firstProjectId = projectsQuery.data?.[0]?.id ?? null;
|
||||
|
||||
const previewsQuery = trpc.listPreviews.useQuery({ initiativeId });
|
||||
const existingPreview = previewsQuery.data?.find(
|
||||
(p) => p.initiativeId === initiativeId,
|
||||
);
|
||||
const [activePreviewId, setActivePreviewId] = useState<string | null>(null);
|
||||
const previewStatusQuery = trpc.getPreviewStatus.useQuery(
|
||||
{ previewId: activePreviewId ?? existingPreview?.id ?? "" },
|
||||
{ enabled: !!(activePreviewId ?? existingPreview?.id) },
|
||||
);
|
||||
const preview = previewStatusQuery.data ?? existingPreview;
|
||||
|
||||
const startPreview = trpc.startPreview.useMutation({
|
||||
onSuccess: (data) => {
|
||||
setActivePreviewId(data.id);
|
||||
previewsQuery.refetch();
|
||||
toast.success(`Preview running at ${data.url}`);
|
||||
},
|
||||
onError: (err) => toast.error(`Preview failed: ${err.message}`),
|
||||
});
|
||||
|
||||
const stopPreview = trpc.stopPreview.useMutation({
|
||||
onSuccess: () => {
|
||||
setActivePreviewId(null);
|
||||
toast.success("Preview stopped");
|
||||
previewsQuery.refetch();
|
||||
},
|
||||
onError: (err) => toast.error(`Failed to stop: ${err.message}`),
|
||||
});
|
||||
|
||||
const approveMutation = trpc.approveInitiativeReview.useMutation({
|
||||
onSuccess: (_data, variables) => {
|
||||
const msg = variables.strategy === "merge_and_push"
|
||||
@@ -87,6 +145,31 @@ export function InitiativeReview({ initiativeId, onCompleted }: InitiativeReview
|
||||
const sourceBranch = diffQuery.data?.sourceBranch ?? "";
|
||||
const targetBranch = diffQuery.data?.targetBranch ?? "";
|
||||
|
||||
const previewState = firstProjectId && sourceBranch
|
||||
? {
|
||||
status: preview?.status === "running"
|
||||
? ("running" as const)
|
||||
: preview?.status === "failed"
|
||||
? ("failed" as const)
|
||||
: (startPreview.isPending || preview?.status === "building")
|
||||
? ("building" as const)
|
||||
: ("idle" as const),
|
||||
url: preview?.url ?? undefined,
|
||||
onStart: () =>
|
||||
startPreview.mutate({
|
||||
initiativeId,
|
||||
projectId: firstProjectId,
|
||||
branch: sourceBranch,
|
||||
}),
|
||||
onStop: () => {
|
||||
const id = activePreviewId ?? existingPreview?.id;
|
||||
if (id) stopPreview.mutate({ previewId: id });
|
||||
},
|
||||
isStarting: startPreview.isPending,
|
||||
isStopping: stopPreview.isPending,
|
||||
}
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-border overflow-hidden bg-card">
|
||||
{/* Header */}
|
||||
@@ -125,10 +208,29 @@ export function InitiativeReview({ initiativeId, onCompleted }: InitiativeReview
|
||||
{totalDeletions}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Mergeability badge */}
|
||||
{mergeabilityQuery.isLoading ? (
|
||||
<Badge variant="secondary" className="text-[10px] h-5">
|
||||
<Loader2 className="h-2.5 w-2.5 animate-spin mr-1" />
|
||||
Checking...
|
||||
</Badge>
|
||||
) : mergeability?.mergeable ? (
|
||||
<Badge variant="success" className="text-[10px] h-5">
|
||||
<CheckCircle2 className="h-2.5 w-2.5 mr-1" />
|
||||
Clean merge
|
||||
</Badge>
|
||||
) : mergeability && !mergeability.mergeable ? (
|
||||
<Badge variant="error" className="text-[10px] h-5">
|
||||
<AlertTriangle className="h-2.5 w-2.5 mr-1" />
|
||||
{mergeability.conflictFiles.length} conflict{mergeability.conflictFiles.length !== 1 ? 's' : ''}
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Right: action buttons */}
|
||||
{/* Right: preview + action buttons */}
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{previewState && <PreviewControls preview={previewState} />}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@@ -146,7 +248,8 @@ export function InitiativeReview({ initiativeId, onCompleted }: InitiativeReview
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => approveMutation.mutate({ initiativeId, strategy: "merge_and_push" })}
|
||||
disabled={approveMutation.isPending}
|
||||
disabled={approveMutation.isPending || mergeability?.mergeable === false}
|
||||
title={mergeability?.mergeable === false ? 'Resolve merge conflicts before merging' : undefined}
|
||||
className="h-9 px-5 text-sm font-semibold shadow-sm"
|
||||
>
|
||||
{approveMutation.isPending ? (
|
||||
@@ -154,12 +257,25 @@ export function InitiativeReview({ initiativeId, onCompleted }: InitiativeReview
|
||||
) : (
|
||||
<GitMerge className="h-3.5 w-3.5" />
|
||||
)}
|
||||
Merge & Push to Default
|
||||
Merge & Push to {targetBranch || "default"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Conflict resolution panel */}
|
||||
{mergeability && !mergeability.mergeable && (
|
||||
<ConflictResolutionPanel
|
||||
initiativeId={initiativeId}
|
||||
conflicts={mergeability.conflictFiles}
|
||||
onResolved={() => {
|
||||
void mergeabilityQuery.refetch();
|
||||
void diffQuery.refetch();
|
||||
void commitsQuery.refetch();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Main content */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-[260px_1fr]">
|
||||
<div className="border-r border-border">
|
||||
|
||||
@@ -15,6 +15,8 @@ interface LineWithCommentsProps {
|
||||
onSubmitComment: (body: string) => void;
|
||||
onResolveComment: (commentId: string) => void;
|
||||
onUnresolveComment: (commentId: string) => void;
|
||||
onReplyComment?: (parentCommentId: string, body: string) => void;
|
||||
onEditComment?: (commentId: string, body: string) => void;
|
||||
/** Syntax-highlighted tokens for this line (if available) */
|
||||
tokens?: TokenizedLine;
|
||||
}
|
||||
@@ -29,6 +31,8 @@ export function LineWithComments({
|
||||
onSubmitComment,
|
||||
onResolveComment,
|
||||
onUnresolveComment,
|
||||
onReplyComment,
|
||||
onEditComment,
|
||||
tokens,
|
||||
}: LineWithCommentsProps) {
|
||||
const formRef = useRef<HTMLTextAreaElement>(null);
|
||||
@@ -132,7 +136,7 @@ export function LineWithComments({
|
||||
|
||||
{/* Existing comments on this line */}
|
||||
{lineComments.length > 0 && (
|
||||
<tr>
|
||||
<tr data-comment-id={lineComments.find((c) => !c.parentCommentId)?.id}>
|
||||
<td
|
||||
colSpan={3}
|
||||
className="px-3 py-2 bg-muted/20 border-y border-border/50"
|
||||
@@ -141,6 +145,8 @@ export function LineWithComments({
|
||||
comments={lineComments}
|
||||
onResolve={onResolveComment}
|
||||
onUnresolve={onUnresolveComment}
|
||||
onReply={onReplyComment}
|
||||
onEdit={onEditComment}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
81
apps/web/src/components/review/PreviewControls.tsx
Normal file
81
apps/web/src/components/review/PreviewControls.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import {
|
||||
ExternalLink,
|
||||
Loader2,
|
||||
Square,
|
||||
CircleDot,
|
||||
RotateCcw,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
export interface PreviewState {
|
||||
status: "idle" | "building" | "running" | "failed";
|
||||
url?: string;
|
||||
onStart: () => void;
|
||||
onStop: () => void;
|
||||
isStarting: boolean;
|
||||
isStopping: boolean;
|
||||
}
|
||||
|
||||
export function PreviewControls({ preview }: { preview: PreviewState }) {
|
||||
if (preview.status === "building" || preview.isStarting) {
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 text-xs text-status-active-fg">
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
<span>Building...</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (preview.status === "running") {
|
||||
return (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<a
|
||||
href={preview.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1 text-xs text-status-success-fg hover:underline"
|
||||
>
|
||||
<CircleDot className="h-3 w-3" />
|
||||
Preview
|
||||
<ExternalLink className="h-2.5 w-2.5" />
|
||||
</a>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={preview.onStop}
|
||||
disabled={preview.isStopping}
|
||||
className="h-6 w-6 p-0"
|
||||
>
|
||||
<Square className="h-2.5 w-2.5" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (preview.status === "failed") {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={preview.onStart}
|
||||
className="h-7 text-xs text-status-error-fg"
|
||||
>
|
||||
<RotateCcw className="h-3 w-3" />
|
||||
Retry Preview
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={preview.onStart}
|
||||
disabled={preview.isStarting}
|
||||
className="h-7 text-xs"
|
||||
>
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
Preview
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -6,11 +6,7 @@ import {
|
||||
FileCode,
|
||||
Plus,
|
||||
Minus,
|
||||
ExternalLink,
|
||||
Loader2,
|
||||
Square,
|
||||
CircleDot,
|
||||
RotateCcw,
|
||||
ArrowRight,
|
||||
Eye,
|
||||
AlertCircle,
|
||||
@@ -18,25 +14,21 @@ import {
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { PreviewControls } from "./PreviewControls";
|
||||
import type { PreviewState } from "./PreviewControls";
|
||||
import type { FileDiff, ReviewStatus } from "./types";
|
||||
|
||||
interface PhaseOption {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface PreviewState {
|
||||
status: "idle" | "building" | "running" | "failed";
|
||||
url?: string;
|
||||
onStart: () => void;
|
||||
onStop: () => void;
|
||||
isStarting: boolean;
|
||||
isStopping: boolean;
|
||||
status: string;
|
||||
}
|
||||
|
||||
interface ReviewHeaderProps {
|
||||
ref?: React.Ref<HTMLDivElement>;
|
||||
phases: PhaseOption[];
|
||||
activePhaseId: string | null;
|
||||
isReadOnly?: boolean;
|
||||
onPhaseSelect: (id: string) => void;
|
||||
phaseName: string;
|
||||
sourceBranch: string;
|
||||
@@ -53,8 +45,10 @@ interface ReviewHeaderProps {
|
||||
}
|
||||
|
||||
export function ReviewHeader({
|
||||
ref,
|
||||
phases,
|
||||
activePhaseId,
|
||||
isReadOnly,
|
||||
onPhaseSelect,
|
||||
phaseName,
|
||||
sourceBranch,
|
||||
@@ -72,28 +66,38 @@ export function ReviewHeader({
|
||||
const totalAdditions = files.reduce((s, f) => s + f.additions, 0);
|
||||
const totalDeletions = files.reduce((s, f) => s + f.deletions, 0);
|
||||
const [showConfirmation, setShowConfirmation] = useState(false);
|
||||
const [showRequestConfirm, setShowRequestConfirm] = useState(false);
|
||||
const confirmRef = useRef<HTMLDivElement>(null);
|
||||
const requestConfirmRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Click-outside handler to dismiss confirmation
|
||||
// Click-outside handler to dismiss confirmation dropdowns
|
||||
useEffect(() => {
|
||||
if (!showConfirmation) return;
|
||||
if (!showConfirmation && !showRequestConfirm) return;
|
||||
function handleClickOutside(e: MouseEvent) {
|
||||
if (
|
||||
showConfirmation &&
|
||||
confirmRef.current &&
|
||||
!confirmRef.current.contains(e.target as Node)
|
||||
) {
|
||||
setShowConfirmation(false);
|
||||
}
|
||||
if (
|
||||
showRequestConfirm &&
|
||||
requestConfirmRef.current &&
|
||||
!requestConfirmRef.current.contains(e.target as Node)
|
||||
) {
|
||||
setShowRequestConfirm(false);
|
||||
}
|
||||
}
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, [showConfirmation]);
|
||||
}, [showConfirmation, showRequestConfirm]);
|
||||
|
||||
const viewed = viewedCount ?? 0;
|
||||
const total = totalCount ?? 0;
|
||||
|
||||
return (
|
||||
<div className="border-b border-border bg-card/80 backdrop-blur-sm sticky top-0 z-20">
|
||||
<div ref={ref} className="border-b border-border bg-card backdrop-blur-sm sticky top-0 z-20 rounded-t-lg">
|
||||
{/* Phase selector row */}
|
||||
{phases.length > 1 && (
|
||||
<div className="flex items-center gap-1 px-4 pt-3 pb-2 border-b border-border/50">
|
||||
@@ -103,6 +107,12 @@ export function ReviewHeader({
|
||||
<div className="flex gap-1 overflow-x-auto">
|
||||
{phases.map((phase) => {
|
||||
const isActive = phase.id === activePhaseId;
|
||||
const isCompleted = phase.status === "completed";
|
||||
const dotColor = isActive
|
||||
? "bg-primary"
|
||||
: isCompleted
|
||||
? "bg-status-success-dot"
|
||||
: "bg-status-warning-dot";
|
||||
return (
|
||||
<button
|
||||
key={phase.id}
|
||||
@@ -117,9 +127,7 @@ export function ReviewHeader({
|
||||
`}
|
||||
>
|
||||
<span
|
||||
className={`h-1.5 w-1.5 rounded-full shrink-0 ${
|
||||
isActive ? "bg-primary" : "bg-status-warning-dot"
|
||||
}`}
|
||||
className={`h-1.5 w-1.5 rounded-full shrink-0 ${dotColor}`}
|
||||
/>
|
||||
{phase.name}
|
||||
</button>
|
||||
@@ -182,102 +190,151 @@ export function ReviewHeader({
|
||||
{preview && <PreviewControls preview={preview} />}
|
||||
|
||||
{/* Review status / actions */}
|
||||
{status === "pending" && (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onRequestChanges}
|
||||
disabled={isRequestingChanges}
|
||||
className="h-8 text-xs px-3 border-status-error-border/50 text-status-error-fg hover:bg-status-error-bg/50 hover:border-status-error-border"
|
||||
>
|
||||
{isRequestingChanges ? (
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
) : (
|
||||
<X className="h-3 w-3" />
|
||||
)}
|
||||
Request Changes
|
||||
</Button>
|
||||
<div className="relative" ref={confirmRef}>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
if (unresolvedCount > 0) return;
|
||||
setShowConfirmation(true);
|
||||
}}
|
||||
disabled={unresolvedCount > 0}
|
||||
className="h-9 px-5 text-sm font-semibold shadow-sm"
|
||||
>
|
||||
{unresolvedCount > 0 ? (
|
||||
<>
|
||||
<AlertCircle className="h-3.5 w-3.5" />
|
||||
{unresolvedCount} unresolved
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<GitMerge className="h-3.5 w-3.5" />
|
||||
Approve & Merge
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{/* Merge confirmation dropdown */}
|
||||
{showConfirmation && (
|
||||
<div className="absolute right-0 top-full mt-1 z-30 w-64 rounded-lg border border-border bg-card shadow-lg p-4">
|
||||
<p className="text-sm font-semibold mb-3">
|
||||
Ready to merge?
|
||||
</p>
|
||||
<div className="space-y-1.5 mb-4">
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<Check className="h-3.5 w-3.5 text-status-success-fg" />
|
||||
<span className="text-muted-foreground">
|
||||
0 unresolved comments
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<Eye className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<span className="text-muted-foreground">
|
||||
{viewed}/{total} files viewed
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setShowConfirmation(false)}
|
||||
className="h-8 text-xs"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setShowConfirmation(false);
|
||||
onApprove();
|
||||
}}
|
||||
className="h-8 px-4 text-xs font-semibold shadow-sm"
|
||||
>
|
||||
<GitMerge className="h-3.5 w-3.5" />
|
||||
Merge Now
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{status === "approved" && (
|
||||
{isReadOnly ? (
|
||||
<Badge variant="success" size="xs">
|
||||
<Check className="h-3 w-3" />
|
||||
Approved
|
||||
</Badge>
|
||||
)}
|
||||
{status === "changes_requested" && (
|
||||
<Badge variant="warning" size="xs">
|
||||
<X className="h-3 w-3" />
|
||||
Changes Requested
|
||||
Merged
|
||||
</Badge>
|
||||
) : (
|
||||
<>
|
||||
{status === "pending" && (
|
||||
<>
|
||||
<div className="relative" ref={requestConfirmRef}>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setShowRequestConfirm(true)}
|
||||
disabled={isRequestingChanges || unresolvedCount === 0}
|
||||
className="h-8 text-xs px-3 border-status-error-border/50 text-status-error-fg hover:bg-status-error-bg/50 hover:border-status-error-border"
|
||||
>
|
||||
{isRequestingChanges ? (
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
) : (
|
||||
<X className="h-3 w-3" />
|
||||
)}
|
||||
Request Changes
|
||||
</Button>
|
||||
|
||||
{showRequestConfirm && (
|
||||
<div className="absolute right-0 top-full mt-1 z-30 w-64 rounded-lg border border-border bg-card shadow-lg p-4">
|
||||
<p className="text-sm font-semibold mb-3">
|
||||
Request changes?
|
||||
</p>
|
||||
<div className="space-y-1.5 mb-4">
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<AlertCircle className="h-3.5 w-3.5 text-status-error-fg" />
|
||||
<span className="text-muted-foreground">
|
||||
{unresolvedCount} unresolved {unresolvedCount === 1 ? "comment" : "comments"} will be sent
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setShowRequestConfirm(false)}
|
||||
className="h-8 text-xs"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setShowRequestConfirm(false);
|
||||
onRequestChanges();
|
||||
}}
|
||||
className="h-8 px-4 text-xs font-semibold shadow-sm border-status-error-border text-status-error-fg hover:bg-status-error-bg"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
Request Changes
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="relative" ref={confirmRef}>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
if (unresolvedCount > 0) return;
|
||||
setShowConfirmation(true);
|
||||
}}
|
||||
disabled={unresolvedCount > 0}
|
||||
className="h-9 px-5 text-sm font-semibold shadow-sm"
|
||||
>
|
||||
{unresolvedCount > 0 ? (
|
||||
<>
|
||||
<AlertCircle className="h-3.5 w-3.5" />
|
||||
{unresolvedCount} unresolved
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<GitMerge className="h-3.5 w-3.5" />
|
||||
Approve & Merge
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{/* Merge confirmation dropdown */}
|
||||
{showConfirmation && (
|
||||
<div className="absolute right-0 top-full mt-1 z-30 w-64 rounded-lg border border-border bg-card shadow-lg p-4">
|
||||
<p className="text-sm font-semibold mb-3">
|
||||
Ready to merge?
|
||||
</p>
|
||||
<div className="space-y-1.5 mb-4">
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<Check className="h-3.5 w-3.5 text-status-success-fg" />
|
||||
<span className="text-muted-foreground">
|
||||
0 unresolved comments
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<Eye className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<span className="text-muted-foreground">
|
||||
{viewed}/{total} files viewed
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setShowConfirmation(false)}
|
||||
className="h-8 text-xs"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setShowConfirmation(false);
|
||||
onApprove();
|
||||
}}
|
||||
className="h-8 px-4 text-xs font-semibold shadow-sm"
|
||||
>
|
||||
<GitMerge className="h-3.5 w-3.5" />
|
||||
Merge Now
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{status === "approved" && (
|
||||
<Badge variant="success" size="xs">
|
||||
<Check className="h-3 w-3" />
|
||||
Approved
|
||||
</Badge>
|
||||
)}
|
||||
{status === "changes_requested" && (
|
||||
<Badge variant="warning" size="xs">
|
||||
<X className="h-3 w-3" />
|
||||
Changes Requested
|
||||
</Badge>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -285,66 +342,3 @@ export function ReviewHeader({
|
||||
);
|
||||
}
|
||||
|
||||
function PreviewControls({ preview }: { preview: PreviewState }) {
|
||||
if (preview.status === "building" || preview.isStarting) {
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 text-xs text-status-active-fg">
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
<span>Building...</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (preview.status === "running") {
|
||||
return (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<a
|
||||
href={preview.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1 text-xs text-status-success-fg hover:underline"
|
||||
>
|
||||
<CircleDot className="h-3 w-3" />
|
||||
Preview
|
||||
<ExternalLink className="h-2.5 w-2.5" />
|
||||
</a>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={preview.onStop}
|
||||
disabled={preview.isStopping}
|
||||
className="h-6 w-6 p-0"
|
||||
>
|
||||
<Square className="h-2.5 w-2.5" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (preview.status === "failed") {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={preview.onStart}
|
||||
className="h-7 text-xs text-status-error-fg"
|
||||
>
|
||||
<RotateCcw className="h-3 w-3" />
|
||||
Retry Preview
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={preview.onStart}
|
||||
disabled={preview.isStarting}
|
||||
className="h-7 text-xs"
|
||||
>
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
Preview
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ interface ReviewSidebarProps {
|
||||
files: FileDiff[];
|
||||
comments: ReviewComment[];
|
||||
onFileClick: (filePath: string) => void;
|
||||
onCommentClick?: (commentId: string) => void;
|
||||
selectedCommit: string | null;
|
||||
activeFiles: FileDiff[];
|
||||
commits: CommitInfo[];
|
||||
@@ -29,6 +30,7 @@ export function ReviewSidebar({
|
||||
files,
|
||||
comments,
|
||||
onFileClick,
|
||||
onCommentClick,
|
||||
selectedCommit,
|
||||
activeFiles,
|
||||
commits,
|
||||
@@ -63,6 +65,7 @@ export function ReviewSidebar({
|
||||
files={files}
|
||||
comments={comments}
|
||||
onFileClick={onFileClick}
|
||||
onCommentClick={onCommentClick}
|
||||
selectedCommit={selectedCommit}
|
||||
activeFiles={activeFiles}
|
||||
viewedFiles={viewedFiles}
|
||||
@@ -172,6 +175,7 @@ function FilesView({
|
||||
files,
|
||||
comments,
|
||||
onFileClick,
|
||||
onCommentClick,
|
||||
selectedCommit,
|
||||
activeFiles,
|
||||
viewedFiles,
|
||||
@@ -179,12 +183,13 @@ function FilesView({
|
||||
files: FileDiff[];
|
||||
comments: ReviewComment[];
|
||||
onFileClick: (filePath: string) => void;
|
||||
onCommentClick?: (commentId: string) => void;
|
||||
selectedCommit: string | null;
|
||||
activeFiles: FileDiff[];
|
||||
viewedFiles: Set<string>;
|
||||
}) {
|
||||
const unresolvedCount = comments.filter((c) => !c.resolved).length;
|
||||
const resolvedCount = comments.filter((c) => c.resolved).length;
|
||||
const unresolvedCount = comments.filter((c) => !c.resolved && !c.parentCommentId).length;
|
||||
const resolvedCount = comments.filter((c) => c.resolved && !c.parentCommentId).length;
|
||||
const activeFilePaths = new Set(activeFiles.map((f) => f.newPath));
|
||||
|
||||
const directoryGroups = useMemo(() => groupFilesByDirectory(files), [files]);
|
||||
@@ -213,29 +218,66 @@ function FilesView({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Comment summary */}
|
||||
{/* Discussions — individual threads */}
|
||||
{comments.length > 0 && (
|
||||
<div className="space-y-1.5">
|
||||
<h4 className="text-[10px] font-semibold text-muted-foreground uppercase tracking-wider">
|
||||
Discussions
|
||||
</h4>
|
||||
<div className="flex items-center gap-3 text-xs">
|
||||
<span className="flex items-center gap-1 text-muted-foreground">
|
||||
<MessageSquare className="h-3 w-3" />
|
||||
{comments.length}
|
||||
<h4 className="text-[10px] font-semibold text-muted-foreground uppercase tracking-wider flex items-center justify-between">
|
||||
<span>Discussions</span>
|
||||
<span className="flex items-center gap-2 font-normal normal-case">
|
||||
{unresolvedCount > 0 && (
|
||||
<span className="flex items-center gap-0.5 text-status-warning-fg">
|
||||
<Circle className="h-2.5 w-2.5" />
|
||||
{unresolvedCount}
|
||||
</span>
|
||||
)}
|
||||
{resolvedCount > 0 && (
|
||||
<span className="flex items-center gap-0.5 text-status-success-fg">
|
||||
<CheckCircle2 className="h-2.5 w-2.5" />
|
||||
{resolvedCount}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{resolvedCount > 0 && (
|
||||
<span className="flex items-center gap-1 text-status-success-fg">
|
||||
<CheckCircle2 className="h-3 w-3" />
|
||||
{resolvedCount}
|
||||
</span>
|
||||
)}
|
||||
{unresolvedCount > 0 && (
|
||||
<span className="flex items-center gap-1 text-status-warning-fg">
|
||||
<Circle className="h-3 w-3" />
|
||||
{unresolvedCount}
|
||||
</span>
|
||||
)}
|
||||
</h4>
|
||||
<div className="space-y-0.5">
|
||||
{comments
|
||||
.filter((c) => !c.parentCommentId)
|
||||
.map((thread) => {
|
||||
const replyCount = comments.filter(
|
||||
(c) => c.parentCommentId === thread.id,
|
||||
).length;
|
||||
return (
|
||||
<button
|
||||
key={thread.id}
|
||||
className={`
|
||||
flex w-full flex-col gap-0.5 rounded px-2 py-1.5 text-left
|
||||
transition-colors hover:bg-accent/50
|
||||
${thread.resolved ? "opacity-50" : ""}
|
||||
`}
|
||||
onClick={() => onCommentClick ? onCommentClick(thread.id) : onFileClick(thread.filePath)}
|
||||
>
|
||||
<div className="flex items-center gap-1.5 w-full min-w-0">
|
||||
{thread.resolved ? (
|
||||
<CheckCircle2 className="h-3 w-3 text-status-success-fg shrink-0" />
|
||||
) : (
|
||||
<MessageSquare className="h-3 w-3 text-status-warning-fg shrink-0" />
|
||||
)}
|
||||
<span className="text-[10px] font-mono text-muted-foreground truncate">
|
||||
{getFileName(thread.filePath)}:{thread.lineNumber}
|
||||
</span>
|
||||
{replyCount > 0 && (
|
||||
<span className="text-[9px] text-muted-foreground/70 shrink-0 ml-auto">
|
||||
{replyCount}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-[11px] text-foreground/80 truncate pl-[18px]">
|
||||
{thread.body.length > 60
|
||||
? thread.body.slice(0, 57) + "..."
|
||||
: thread.body}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -263,7 +305,7 @@ function FilesView({
|
||||
<div className="space-y-0.5">
|
||||
{group.files.map((file) => {
|
||||
const fileCommentCount = comments.filter(
|
||||
(c) => c.filePath === file.newPath,
|
||||
(c) => c.filePath === file.newPath && !c.parentCommentId,
|
||||
).length;
|
||||
const isInView = activeFilePaths.has(file.newPath);
|
||||
const dimmed = selectedCommit && !isInView;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useMemo, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { trpc } from "@/lib/trpc";
|
||||
@@ -18,6 +18,18 @@ export function ReviewTab({ initiativeId }: ReviewTabProps) {
|
||||
const [selectedCommit, setSelectedCommit] = useState<string | null>(null);
|
||||
const [viewedFiles, setViewedFiles] = useState<Set<string>>(new Set());
|
||||
const fileRefs = useRef<Map<string, HTMLDivElement>>(new Map());
|
||||
const headerRef = useRef<HTMLDivElement>(null);
|
||||
const [headerHeight, setHeaderHeight] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const el = headerRef.current;
|
||||
if (!el) return;
|
||||
const ro = new ResizeObserver(([entry]) => {
|
||||
setHeaderHeight(entry.borderBoxSize?.[0]?.blockSize ?? entry.target.getBoundingClientRect().height);
|
||||
});
|
||||
ro.observe(el, { box: 'border-box' });
|
||||
return () => ro.disconnect();
|
||||
}, []);
|
||||
|
||||
const toggleViewed = useCallback((filePath: string) => {
|
||||
setViewedFiles(prev => {
|
||||
@@ -45,14 +57,17 @@ export function ReviewTab({ initiativeId }: ReviewTabProps) {
|
||||
|
||||
// Fetch phases for this initiative
|
||||
const phasesQuery = trpc.listPhases.useQuery({ initiativeId });
|
||||
const pendingReviewPhases = useMemo(
|
||||
() => (phasesQuery.data ?? []).filter((p) => p.status === "pending_review"),
|
||||
const reviewablePhases = useMemo(
|
||||
() => (phasesQuery.data ?? []).filter((p) => p.status === "pending_review" || p.status === "completed"),
|
||||
[phasesQuery.data],
|
||||
);
|
||||
|
||||
// Select first pending review phase
|
||||
// Select first pending review phase, falling back to completed phases
|
||||
const [selectedPhaseId, setSelectedPhaseId] = useState<string | null>(null);
|
||||
const activePhaseId = selectedPhaseId ?? pendingReviewPhases[0]?.id ?? null;
|
||||
const defaultPhaseId = reviewablePhases.find((p) => p.status === "pending_review")?.id ?? reviewablePhases[0]?.id ?? null;
|
||||
const activePhaseId = selectedPhaseId ?? defaultPhaseId;
|
||||
const activePhase = reviewablePhases.find((p) => p.id === activePhaseId);
|
||||
const isActivePhaseCompleted = activePhase?.status === "completed";
|
||||
|
||||
// Fetch projects for this initiative (needed for preview)
|
||||
const projectsQuery = trpc.getInitiativeProjects.useQuery({ initiativeId });
|
||||
@@ -78,20 +93,14 @@ export function ReviewTab({ initiativeId }: ReviewTabProps) {
|
||||
);
|
||||
|
||||
// Preview state
|
||||
const previewsQuery = trpc.listPreviews.useQuery(
|
||||
{ initiativeId },
|
||||
{ refetchInterval: 3000 },
|
||||
);
|
||||
const previewsQuery = trpc.listPreviews.useQuery({ initiativeId });
|
||||
const existingPreview = previewsQuery.data?.find(
|
||||
(p) => p.phaseId === activePhaseId || p.initiativeId === initiativeId,
|
||||
);
|
||||
const [activePreviewId, setActivePreviewId] = useState<string | null>(null);
|
||||
const previewStatusQuery = trpc.getPreviewStatus.useQuery(
|
||||
{ previewId: activePreviewId ?? existingPreview?.id ?? "" },
|
||||
{
|
||||
enabled: !!(activePreviewId ?? existingPreview?.id),
|
||||
refetchInterval: 3000,
|
||||
},
|
||||
{ enabled: !!(activePreviewId ?? existingPreview?.id) },
|
||||
);
|
||||
const preview = previewStatusQuery.data ?? existingPreview;
|
||||
const sourceBranch = diffQuery.data?.sourceBranch ?? commitsQuery.data?.sourceBranch ?? "";
|
||||
@@ -99,6 +108,7 @@ export function ReviewTab({ initiativeId }: ReviewTabProps) {
|
||||
const startPreview = trpc.startPreview.useMutation({
|
||||
onSuccess: (data) => {
|
||||
setActivePreviewId(data.id);
|
||||
previewsQuery.refetch();
|
||||
toast.success(`Preview running at ${data.url}`);
|
||||
},
|
||||
onError: (err) => toast.error(`Preview failed: ${err.message}`),
|
||||
@@ -115,15 +125,13 @@ export function ReviewTab({ initiativeId }: ReviewTabProps) {
|
||||
|
||||
const previewState = firstProjectId && sourceBranch
|
||||
? {
|
||||
status: startPreview.isPending
|
||||
? ("building" as const)
|
||||
: preview?.status === "running"
|
||||
? ("running" as const)
|
||||
: preview?.status === "building"
|
||||
status: preview?.status === "running"
|
||||
? ("running" as const)
|
||||
: preview?.status === "failed"
|
||||
? ("failed" as const)
|
||||
: (startPreview.isPending || preview?.status === "building")
|
||||
? ("building" as const)
|
||||
: preview?.status === "failed"
|
||||
? ("failed" as const)
|
||||
: ("idle" as const),
|
||||
: ("idle" as const),
|
||||
url: preview?.url ?? undefined,
|
||||
onStart: () =>
|
||||
startPreview.mutate({
|
||||
@@ -157,6 +165,7 @@ export function ReviewTab({ initiativeId }: ReviewTabProps) {
|
||||
author: c.author,
|
||||
createdAt: typeof c.createdAt === 'string' ? c.createdAt : String(c.createdAt),
|
||||
resolved: c.resolved,
|
||||
parentCommentId: c.parentCommentId ?? null,
|
||||
}));
|
||||
}, [commentsQuery.data]);
|
||||
|
||||
@@ -179,6 +188,20 @@ export function ReviewTab({ initiativeId }: ReviewTabProps) {
|
||||
},
|
||||
});
|
||||
|
||||
const replyToCommentMutation = trpc.replyToReviewComment.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.listReviewComments.invalidate({ phaseId: activePhaseId! });
|
||||
},
|
||||
onError: (err) => toast.error(`Failed to post reply: ${err.message}`),
|
||||
});
|
||||
|
||||
const editCommentMutation = trpc.updateReviewComment.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.listReviewComments.invalidate({ phaseId: activePhaseId! });
|
||||
},
|
||||
onError: (err) => toast.error(`Failed to update comment: ${err.message}`),
|
||||
});
|
||||
|
||||
const approveMutation = trpc.approvePhaseReview.useMutation({
|
||||
onSuccess: () => {
|
||||
setStatus("approved");
|
||||
@@ -225,6 +248,14 @@ export function ReviewTab({ initiativeId }: ReviewTabProps) {
|
||||
unresolveCommentMutation.mutate({ id: commentId });
|
||||
}, [unresolveCommentMutation]);
|
||||
|
||||
const handleReplyComment = useCallback((parentCommentId: string, body: string) => {
|
||||
replyToCommentMutation.mutate({ parentCommentId, body });
|
||||
}, [replyToCommentMutation]);
|
||||
|
||||
const handleEditComment = useCallback((commentId: string, body: string) => {
|
||||
editCommentMutation.mutate({ id: commentId, body });
|
||||
}, [editCommentMutation]);
|
||||
|
||||
const handleApprove = useCallback(() => {
|
||||
if (!activePhaseId) return;
|
||||
approveMutation.mutate({ phaseId: activePhaseId });
|
||||
@@ -241,9 +272,7 @@ export function ReviewTab({ initiativeId }: ReviewTabProps) {
|
||||
|
||||
const handleRequestChanges = useCallback(() => {
|
||||
if (!activePhaseId) return;
|
||||
const summary = window.prompt("Optional: describe what needs to change (leave blank for comments only)");
|
||||
if (summary === null) return; // cancelled
|
||||
requestChangesMutation.mutate({ phaseId: activePhaseId, summary: summary || undefined });
|
||||
requestChangesMutation.mutate({ phaseId: activePhaseId });
|
||||
}, [activePhaseId, requestChangesMutation]);
|
||||
|
||||
const handleFileClick = useCallback((filePath: string) => {
|
||||
@@ -253,6 +282,16 @@ export function ReviewTab({ initiativeId }: ReviewTabProps) {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleCommentClick = useCallback((commentId: string) => {
|
||||
const el = document.querySelector(`[data-comment-id="${commentId}"]`);
|
||||
if (el) {
|
||||
el.scrollIntoView({ behavior: "instant", block: "center" });
|
||||
// Brief highlight flash
|
||||
el.classList.add("ring-2", "ring-primary/50");
|
||||
setTimeout(() => el.classList.remove("ring-2", "ring-primary/50"), 1500);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handlePhaseSelect = useCallback((id: string) => {
|
||||
setSelectedPhaseId(id);
|
||||
setSelectedCommit(null);
|
||||
@@ -260,7 +299,18 @@ export function ReviewTab({ initiativeId }: ReviewTabProps) {
|
||||
setViewedFiles(new Set());
|
||||
}, []);
|
||||
|
||||
const unresolvedCount = comments.filter((c) => !c.resolved).length;
|
||||
const unresolvedCount = comments.filter((c) => !c.resolved && !c.parentCommentId).length;
|
||||
|
||||
const activePhaseName =
|
||||
diffQuery.data?.phaseName ??
|
||||
reviewablePhases.find((p) => p.id === activePhaseId)?.name ??
|
||||
"Phase";
|
||||
|
||||
// All files from the full branch diff (for sidebar file list)
|
||||
const allFiles = useMemo(() => {
|
||||
if (!diffQuery.data?.rawDiff) return [];
|
||||
return parseUnifiedDiff(diffQuery.data.rawDiff);
|
||||
}, [diffQuery.data?.rawDiff]);
|
||||
|
||||
// Initiative-level review takes priority
|
||||
if (isInitiativePendingReview) {
|
||||
@@ -275,7 +325,7 @@ export function ReviewTab({ initiativeId }: ReviewTabProps) {
|
||||
);
|
||||
}
|
||||
|
||||
if (pendingReviewPhases.length === 0) {
|
||||
if (reviewablePhases.length === 0) {
|
||||
return (
|
||||
<div className="flex h-64 items-center justify-center text-muted-foreground">
|
||||
<p>No phases pending review</p>
|
||||
@@ -283,23 +333,17 @@ export function ReviewTab({ initiativeId }: ReviewTabProps) {
|
||||
);
|
||||
}
|
||||
|
||||
const activePhaseName =
|
||||
diffQuery.data?.phaseName ??
|
||||
pendingReviewPhases.find((p) => p.id === activePhaseId)?.name ??
|
||||
"Phase";
|
||||
|
||||
// All files from the full branch diff (for sidebar file list)
|
||||
const allFiles = useMemo(() => {
|
||||
if (!diffQuery.data?.rawDiff) return [];
|
||||
return parseUnifiedDiff(diffQuery.data.rawDiff);
|
||||
}, [diffQuery.data?.rawDiff]);
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-border bg-card">
|
||||
<div
|
||||
className="rounded-lg border border-border bg-card"
|
||||
style={{ '--review-header-h': `${headerHeight}px` } as React.CSSProperties}
|
||||
>
|
||||
{/* Header: phase selector + toolbar */}
|
||||
<ReviewHeader
|
||||
phases={pendingReviewPhases.map((p) => ({ id: p.id, name: p.name }))}
|
||||
ref={headerRef}
|
||||
phases={reviewablePhases.map((p) => ({ id: p.id, name: p.name, status: p.status }))}
|
||||
activePhaseId={activePhaseId}
|
||||
isReadOnly={isActivePhaseCompleted}
|
||||
onPhaseSelect={handlePhaseSelect}
|
||||
phaseName={activePhaseName}
|
||||
sourceBranch={sourceBranch}
|
||||
@@ -316,14 +360,21 @@ export function ReviewTab({ initiativeId }: ReviewTabProps) {
|
||||
/>
|
||||
|
||||
{/* Main content area — sidebar always rendered to preserve state */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-[260px_1fr] overflow-hidden rounded-b-lg">
|
||||
{/* Left: Sidebar — sticky so icon strip stays visible */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-[260px_1fr] rounded-b-lg">
|
||||
{/* Left: Sidebar — sticky to viewport, scrolls independently */}
|
||||
<div className="border-r border-border">
|
||||
<div className="sticky top-0 h-[calc(100vh-12rem)]">
|
||||
<div
|
||||
className="sticky overflow-hidden"
|
||||
style={{
|
||||
top: `${headerHeight}px`,
|
||||
maxHeight: `calc(100vh - ${headerHeight}px)`,
|
||||
}}
|
||||
>
|
||||
<ReviewSidebar
|
||||
files={allFiles}
|
||||
comments={comments}
|
||||
onFileClick={handleFileClick}
|
||||
onCommentClick={handleCommentClick}
|
||||
selectedCommit={selectedCommit}
|
||||
activeFiles={files}
|
||||
commits={commits}
|
||||
@@ -353,6 +404,8 @@ export function ReviewTab({ initiativeId }: ReviewTabProps) {
|
||||
onAddComment={handleAddComment}
|
||||
onResolveComment={handleResolveComment}
|
||||
onUnresolveComment={handleUnresolveComment}
|
||||
onReplyComment={handleReplyComment}
|
||||
onEditComment={handleEditComment}
|
||||
viewedFiles={viewedFiles}
|
||||
onToggleViewed={toggleViewed}
|
||||
onRegisterRef={registerFileRef}
|
||||
|
||||
@@ -34,6 +34,7 @@ export interface ReviewComment {
|
||||
author: string;
|
||||
createdAt: string;
|
||||
resolved: boolean;
|
||||
parentCommentId?: string | null;
|
||||
}
|
||||
|
||||
export type ReviewStatus = "pending" | "approved" | "changes_requested";
|
||||
|
||||
@@ -9,10 +9,16 @@ export { useAutoSave } from './useAutoSave.js';
|
||||
export { useDebounce, useDebounceWithImmediate } from './useDebounce.js';
|
||||
export { useLiveUpdates } from './useLiveUpdates.js';
|
||||
export { useRefineAgent } from './useRefineAgent.js';
|
||||
export { useConflictAgent } from './useConflictAgent.js';
|
||||
export { useSubscriptionWithErrorHandling } from './useSubscriptionWithErrorHandling.js';
|
||||
|
||||
export type {
|
||||
RefineAgentState,
|
||||
SpawnRefineAgentOptions,
|
||||
UseRefineAgentResult,
|
||||
} from './useRefineAgent.js';
|
||||
} from './useRefineAgent.js';
|
||||
|
||||
export type {
|
||||
ConflictAgentState,
|
||||
UseConflictAgentResult,
|
||||
} from './useConflictAgent.js';
|
||||
214
apps/web/src/hooks/useConflictAgent.ts
Normal file
214
apps/web/src/hooks/useConflictAgent.ts
Normal file
@@ -0,0 +1,214 @@
|
||||
import { useCallback, useMemo, useRef } from 'react';
|
||||
import { trpc } from '@/lib/trpc';
|
||||
import type { PendingQuestions } from '@codewalk-district/shared';
|
||||
|
||||
export type ConflictAgentState = 'none' | 'running' | 'waiting' | 'completed' | 'crashed';
|
||||
|
||||
type ConflictAgent = NonNullable<ReturnType<typeof trpc.getActiveConflictAgent.useQuery>['data']>;
|
||||
|
||||
export interface UseConflictAgentResult {
|
||||
agent: ConflictAgent | null;
|
||||
state: ConflictAgentState;
|
||||
questions: PendingQuestions | null;
|
||||
spawn: {
|
||||
mutate: (options: { initiativeId: string; provider?: string }) => void;
|
||||
isPending: boolean;
|
||||
error: Error | null;
|
||||
};
|
||||
resume: {
|
||||
mutate: (answers: Record<string, string>) => void;
|
||||
isPending: boolean;
|
||||
error: Error | null;
|
||||
};
|
||||
stop: {
|
||||
mutate: () => void;
|
||||
isPending: boolean;
|
||||
};
|
||||
dismiss: () => void;
|
||||
isLoading: boolean;
|
||||
refresh: () => void;
|
||||
}
|
||||
|
||||
export function useConflictAgent(initiativeId: string): UseConflictAgentResult {
|
||||
const utils = trpc.useUtils();
|
||||
|
||||
const agentQuery = trpc.getActiveConflictAgent.useQuery({ initiativeId });
|
||||
const agent = agentQuery.data ?? null;
|
||||
|
||||
const state: ConflictAgentState = useMemo(() => {
|
||||
if (!agent) return 'none';
|
||||
switch (agent.status) {
|
||||
case 'running':
|
||||
return 'running';
|
||||
case 'waiting_for_input':
|
||||
return 'waiting';
|
||||
case 'idle':
|
||||
return 'completed';
|
||||
case 'crashed':
|
||||
return 'crashed';
|
||||
default:
|
||||
return 'none';
|
||||
}
|
||||
}, [agent]);
|
||||
|
||||
const questionsQuery = trpc.getAgentQuestions.useQuery(
|
||||
{ id: agent?.id ?? '' },
|
||||
{ enabled: state === 'waiting' && !!agent },
|
||||
);
|
||||
|
||||
const spawnMutation = trpc.spawnConflictResolutionAgent.useMutation({
|
||||
onMutate: async () => {
|
||||
await utils.listAgents.cancel();
|
||||
await utils.getActiveConflictAgent.cancel({ initiativeId });
|
||||
|
||||
const previousAgents = utils.listAgents.getData();
|
||||
const previousConflictAgent = utils.getActiveConflictAgent.getData({ initiativeId });
|
||||
|
||||
const tempAgent = {
|
||||
id: `temp-${Date.now()}`,
|
||||
name: `conflict-${Date.now()}`,
|
||||
mode: 'execute' as const,
|
||||
status: 'running' as const,
|
||||
initiativeId,
|
||||
taskId: null,
|
||||
phaseId: null,
|
||||
provider: 'claude',
|
||||
accountId: null,
|
||||
instruction: null,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
userDismissedAt: null,
|
||||
completedAt: null,
|
||||
};
|
||||
|
||||
utils.listAgents.setData(undefined, (old = []) => [tempAgent, ...old]);
|
||||
utils.getActiveConflictAgent.setData({ initiativeId }, tempAgent as any);
|
||||
|
||||
return { previousAgents, previousConflictAgent };
|
||||
},
|
||||
onError: (_err, _variables, context) => {
|
||||
if (context?.previousAgents) {
|
||||
utils.listAgents.setData(undefined, context.previousAgents);
|
||||
}
|
||||
if (context?.previousConflictAgent !== undefined) {
|
||||
utils.getActiveConflictAgent.setData({ initiativeId }, context.previousConflictAgent);
|
||||
}
|
||||
},
|
||||
onSettled: () => {
|
||||
void utils.listAgents.invalidate();
|
||||
void utils.getActiveConflictAgent.invalidate({ initiativeId });
|
||||
},
|
||||
});
|
||||
|
||||
const resumeMutation = trpc.resumeAgent.useMutation({
|
||||
onSuccess: () => {
|
||||
void utils.listAgents.invalidate();
|
||||
},
|
||||
});
|
||||
|
||||
const stopMutation = trpc.stopAgent.useMutation({
|
||||
onSuccess: () => {
|
||||
void utils.listAgents.invalidate();
|
||||
void utils.listWaitingAgents.invalidate();
|
||||
},
|
||||
});
|
||||
|
||||
const dismissMutation = trpc.dismissAgent.useMutation({
|
||||
onMutate: async ({ id }) => {
|
||||
await utils.listAgents.cancel();
|
||||
await utils.getActiveConflictAgent.cancel({ initiativeId });
|
||||
|
||||
const previousAgents = utils.listAgents.getData();
|
||||
const previousConflictAgent = utils.getActiveConflictAgent.getData({ initiativeId });
|
||||
|
||||
utils.listAgents.setData(undefined, (old = []) => old.filter(a => a.id !== id));
|
||||
utils.getActiveConflictAgent.setData({ initiativeId }, null);
|
||||
|
||||
return { previousAgents, previousConflictAgent };
|
||||
},
|
||||
onError: (_err, _variables, context) => {
|
||||
if (context?.previousAgents) {
|
||||
utils.listAgents.setData(undefined, context.previousAgents);
|
||||
}
|
||||
if (context?.previousConflictAgent !== undefined) {
|
||||
utils.getActiveConflictAgent.setData({ initiativeId }, context.previousConflictAgent);
|
||||
}
|
||||
},
|
||||
onSettled: () => {
|
||||
void utils.listAgents.invalidate();
|
||||
void utils.getActiveConflictAgent.invalidate({ initiativeId });
|
||||
},
|
||||
});
|
||||
|
||||
const spawnMutateRef = useRef(spawnMutation.mutate);
|
||||
spawnMutateRef.current = spawnMutation.mutate;
|
||||
const agentRef = useRef(agent);
|
||||
agentRef.current = agent;
|
||||
const resumeMutateRef = useRef(resumeMutation.mutate);
|
||||
resumeMutateRef.current = resumeMutation.mutate;
|
||||
const stopMutateRef = useRef(stopMutation.mutate);
|
||||
stopMutateRef.current = stopMutation.mutate;
|
||||
const dismissMutateRef = useRef(dismissMutation.mutate);
|
||||
dismissMutateRef.current = dismissMutation.mutate;
|
||||
|
||||
const spawnFn = useCallback(({ initiativeId, provider }: { initiativeId: string; provider?: string }) => {
|
||||
spawnMutateRef.current({ initiativeId, provider });
|
||||
}, []);
|
||||
|
||||
const spawn = useMemo(() => ({
|
||||
mutate: spawnFn,
|
||||
isPending: spawnMutation.isPending,
|
||||
error: spawnMutation.error,
|
||||
}), [spawnFn, spawnMutation.isPending, spawnMutation.error]);
|
||||
|
||||
const resumeFn = useCallback((answers: Record<string, string>) => {
|
||||
const a = agentRef.current;
|
||||
if (a) {
|
||||
resumeMutateRef.current({ id: a.id, answers });
|
||||
}
|
||||
}, []);
|
||||
|
||||
const resume = useMemo(() => ({
|
||||
mutate: resumeFn,
|
||||
isPending: resumeMutation.isPending,
|
||||
error: resumeMutation.error,
|
||||
}), [resumeFn, resumeMutation.isPending, resumeMutation.error]);
|
||||
|
||||
const stopFn = useCallback(() => {
|
||||
const a = agentRef.current;
|
||||
if (a) {
|
||||
stopMutateRef.current({ id: a.id });
|
||||
}
|
||||
}, []);
|
||||
|
||||
const stop = useMemo(() => ({
|
||||
mutate: stopFn,
|
||||
isPending: stopMutation.isPending,
|
||||
}), [stopFn, stopMutation.isPending]);
|
||||
|
||||
const dismiss = useCallback(() => {
|
||||
const a = agentRef.current;
|
||||
if (a) {
|
||||
dismissMutateRef.current({ id: a.id });
|
||||
}
|
||||
}, []);
|
||||
|
||||
const refresh = useCallback(() => {
|
||||
void utils.getActiveConflictAgent.invalidate({ initiativeId });
|
||||
}, [utils, initiativeId]);
|
||||
|
||||
const isLoading = agentQuery.isLoading ||
|
||||
(state === 'waiting' && questionsQuery.isLoading);
|
||||
|
||||
return {
|
||||
agent,
|
||||
state,
|
||||
questions: questionsQuery.data ?? null,
|
||||
spawn,
|
||||
resume,
|
||||
stop,
|
||||
dismiss,
|
||||
isLoading,
|
||||
refresh,
|
||||
};
|
||||
}
|
||||
@@ -52,12 +52,12 @@ const INVALIDATION_MAP: Partial<Record<MutationName, QueryName[]>> = {
|
||||
|
||||
// --- Phases ---
|
||||
createPhase: ["listPhases", "listInitiativePhaseDependencies"],
|
||||
deletePhase: ["listPhases", "listInitiativeTasks", "listInitiativePhaseDependencies"],
|
||||
deletePhase: ["listPhases", "listInitiativeTasks", "listInitiativePhaseDependencies", "listChangeSets"],
|
||||
updatePhase: ["listPhases", "getPhase"],
|
||||
approvePhase: ["listPhases", "listInitiativeTasks"],
|
||||
queuePhase: ["listPhases"],
|
||||
createPhaseDependency: ["getPhaseDependencies", "listInitiativePhaseDependencies"],
|
||||
removePhaseDependency: ["getPhaseDependencies", "listInitiativePhaseDependencies"],
|
||||
createPhaseDependency: ["getPhaseDependencies", "listInitiativePhaseDependencies", "listPhaseTaskDependencies"],
|
||||
removePhaseDependency: ["getPhaseDependencies", "listInitiativePhaseDependencies", "listPhaseTaskDependencies"],
|
||||
|
||||
// --- Tasks ---
|
||||
createPhaseTask: ["listPhaseTasks", "listInitiativeTasks", "listTasks"],
|
||||
@@ -65,8 +65,10 @@ const INVALIDATION_MAP: Partial<Record<MutationName, QueryName[]>> = {
|
||||
createChildTasks: ["listTasks", "listInitiativeTasks", "listPhaseTasks"],
|
||||
queueTask: ["listTasks", "listInitiativeTasks", "listPhaseTasks"],
|
||||
|
||||
deleteTask: ["listTasks", "listInitiativeTasks", "listPhaseTasks", "listChangeSets"],
|
||||
|
||||
// --- Change Sets ---
|
||||
revertChangeSet: ["listPhases", "listPhaseTasks", "listInitiativeTasks", "listPages", "getPage", "listChangeSets", "getRootPage"],
|
||||
revertChangeSet: ["listPhases", "listPhaseTasks", "listInitiativeTasks", "listPages", "getPage", "listChangeSets", "getRootPage", "getChangeSet"],
|
||||
|
||||
// --- Pages ---
|
||||
updatePage: ["listPages", "getPage", "getRootPage"],
|
||||
|
||||
@@ -29,10 +29,12 @@ function InitiativeDetailPage() {
|
||||
|
||||
// Single SSE stream for all live updates
|
||||
useLiveUpdates([
|
||||
{ prefix: 'task:', invalidate: ['listPhases', 'listTasks', 'listInitiativeTasks'] },
|
||||
{ prefix: 'phase:', invalidate: ['listPhases', 'listTasks', 'listInitiativePhaseDependencies'] },
|
||||
{ prefix: 'agent:', invalidate: ['listAgents', 'getActiveRefineAgent'] },
|
||||
{ prefix: 'page:', invalidate: ['listPages', 'getPage', 'getRootPage'] },
|
||||
{ prefix: 'task:', invalidate: ['listPhases', 'listTasks', 'listInitiativeTasks', 'getPhaseDependencies', 'listPhaseTaskDependencies'] },
|
||||
{ prefix: 'phase:', invalidate: ['listPhases', 'listTasks', 'listInitiativePhaseDependencies', 'getPhaseDependencies'] },
|
||||
{ prefix: 'agent:', invalidate: ['listAgents', 'getActiveRefineAgent'] },
|
||||
{ prefix: 'page:', invalidate: ['listPages', 'getPage', 'getRootPage'] },
|
||||
{ prefix: 'changeset:', invalidate: ['getChangeSet', 'listChangeSets'] },
|
||||
{ prefix: 'preview:', invalidate: ['listPreviews', 'getPreviewStatus'] },
|
||||
]);
|
||||
|
||||
// tRPC queries
|
||||
|
||||
Reference in New Issue
Block a user