Merge branch 'main' into cw/unified-event-flow-conflict-1772795597661

# Conflicts:
#	apps/web/src/components/review/ReviewTab.tsx
#	apps/web/src/routes/initiatives/$id.tsx
This commit is contained in:
Lukas May
2026-03-06 12:16:07 +01:00
80 changed files with 4771 additions and 519 deletions

View File

@@ -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";

View File

@@ -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(

View File

@@ -1,6 +1,6 @@
import { useCallback, useEffect, useRef, useMemo } from "react";
import { motion, AnimatePresence } from "motion/react";
import { X, Trash2, MessageCircle } from "lucide-react";
import { X, Trash2, MessageCircle, RotateCw } from "lucide-react";
import type { ChatTarget } from "@/components/chat/ChatSlideOver";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
@@ -20,6 +20,7 @@ interface TaskSlideOverProps {
export function TaskSlideOver({ onOpenChat }: TaskSlideOverProps) {
const { selectedEntry, setSelectedTaskId } = useExecutionContext();
const queueTaskMutation = trpc.queueTask.useMutation();
const retryBlockedTaskMutation = trpc.retryBlockedTask.useMutation();
const deleteTaskMutation = trpc.deleteTask.useMutation();
const updateTaskMutation = trpc.updateTask.useMutation();
@@ -229,17 +230,32 @@ export function TaskSlideOver({ onOpenChat }: TaskSlideOverProps) {
{/* Footer */}
<div className="flex items-center gap-2 border-t border-border px-5 py-3">
<Button
variant="outline"
size="sm"
disabled={!canQueue}
onClick={() => {
queueTaskMutation.mutate({ taskId: task.id });
close();
}}
>
Queue Task
</Button>
{task.status === "blocked" ? (
<Button
variant="outline"
size="sm"
className="gap-1.5"
onClick={() => {
retryBlockedTaskMutation.mutate({ taskId: task.id });
close();
}}
>
<RotateCw className="h-3.5 w-3.5" />
Retry
</Button>
) : (
<Button
variant="outline"
size="sm"
disabled={!canQueue}
onClick={() => {
queueTaskMutation.mutate({ taskId: task.id });
close();
}}
>
Queue Task
</Button>
)}
<Button
variant="outline"
size="sm"

View File

@@ -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();

View File

@@ -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" });

View File

@@ -0,0 +1,182 @@
import { Loader2, AlertCircle, GitMerge, CheckCircle2, ChevronDown, ChevronRight, Terminal } from 'lucide-react';
import { 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);
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') {
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 justify-between">
<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</span>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => {
dismiss();
onResolved();
}}
className="h-7 text-xs"
>
Re-check Mergeability
</Button>
</div>
</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;
}

View File

@@ -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)}
/>

View File

@@ -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}
/>
))}

View File

@@ -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

View File

@@ -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">

View File

@@ -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>

View 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>
);
}

View File

@@ -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">
<div ref={ref} className="border-b border-border bg-card backdrop-blur-sm sticky top-0 z-20 rounded-t-lg shadow-[0_-50px_0_0_hsl(var(--background))]">
{/* 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-20 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>
);
}

View File

@@ -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;

View File

@@ -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,18 +93,14 @@ export function ReviewTab({ initiativeId }: ReviewTabProps) {
);
// Preview state
const previewsQuery = trpc.listPreviews.useQuery(
{ initiativeId },
);
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),
},
{ enabled: !!(activePreviewId ?? existingPreview?.id) },
);
const preview = previewStatusQuery.data ?? existingPreview;
const sourceBranch = diffQuery.data?.sourceBranch ?? commitsQuery.data?.sourceBranch ?? "";
@@ -97,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}`),
@@ -113,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({
@@ -155,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]);
@@ -177,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");
@@ -223,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 });
@@ -239,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) => {
@@ -251,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);
@@ -258,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) {
@@ -273,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>
@@ -281,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 overflow-hidden 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}
@@ -314,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]">
{/* 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}
@@ -351,6 +404,8 @@ export function ReviewTab({ initiativeId }: ReviewTabProps) {
onAddComment={handleAddComment}
onResolveComment={handleResolveComment}
onUnresolveComment={handleUnresolveComment}
onReplyComment={handleReplyComment}
onEditComment={handleEditComment}
viewedFiles={viewedFiles}
onToggleViewed={toggleViewed}
onRegisterRef={registerFileRef}

View File

@@ -34,6 +34,7 @@ export interface ReviewComment {
author: string;
createdAt: string;
resolved: boolean;
parentCommentId?: string | null;
}
export type ReviewStatus = "pending" | "approved" | "changes_requested";

View File

@@ -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';

View 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,
};
}

View File

@@ -52,7 +52,7 @@ 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"],
@@ -65,6 +65,8 @@ 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", "getChangeSet"],