Merge branch 'main' into cw/account-ui-conflict-1772803526476

# Conflicts:
#	apps/server/trpc/router.test.ts
#	docs/server-api.md
This commit is contained in:
Lukas May
2026-03-06 14:27:38 +01:00
117 changed files with 10348 additions and 862 deletions

View File

@@ -0,0 +1,230 @@
import { useState, useEffect } from "react";
import { Link } from "@tanstack/react-router";
import { trpc } from "@/lib/trpc";
import { cn } from "@/lib/utils";
import { Skeleton } from "@/components/Skeleton";
import { Button } from "@/components/ui/button";
import { StatusDot } from "@/components/StatusDot";
import { formatRelativeTime } from "@/lib/utils";
import { modeLabel } from "@/lib/labels";
export function AgentDetailsPanel({ agentId }: { agentId: string }) {
return (
<div className="h-full overflow-y-auto p-4 space-y-6">
<section>
<h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-3">Metadata</h3>
<MetadataSection agentId={agentId} />
</section>
<section>
<h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-3">Input Files</h3>
<InputFilesSection agentId={agentId} />
</section>
<section>
<h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-3">Effective Prompt</h3>
<EffectivePromptSection agentId={agentId} />
</section>
</div>
);
}
function MetadataSection({ agentId }: { agentId: string }) {
const query = trpc.getAgent.useQuery({ id: agentId });
if (query.isLoading) {
return (
<div className="space-y-2">
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} variant="line" />
))}
</div>
);
}
if (query.isError) {
return (
<div className="space-y-2">
<p className="text-sm text-destructive">{query.error.message}</p>
<Button variant="outline" size="sm" onClick={() => void query.refetch()}>Retry</Button>
</div>
);
}
const agent = query.data;
if (!agent) return null;
const showExitCode = !['idle', 'running', 'waiting_for_input'].includes(agent.status);
const rows: Array<{ label: string; value: React.ReactNode }> = [
{
label: 'Status',
value: (
<span className="flex items-center gap-1.5">
<StatusDot status={agent.status} size="sm" />
{agent.status}
</span>
),
},
{
label: 'Mode',
value: modeLabel(agent.mode),
},
{
label: 'Provider',
value: agent.provider,
},
{
label: 'Initiative',
value: agent.initiativeId ? (
<Link
to="/initiatives/$initiativeId"
params={{ initiativeId: agent.initiativeId }}
className="underline underline-offset-2"
>
{(agent as { initiativeName?: string | null }).initiativeName ?? agent.initiativeId}
</Link>
) : '—',
},
{
label: 'Task',
value: (agent as { taskName?: string | null }).taskName ?? (agent.taskId ? agent.taskId : '—'),
},
{
label: 'Created',
value: formatRelativeTime(String(agent.createdAt)),
},
];
if (showExitCode) {
rows.push({
label: 'Exit Code',
value: (
<span className={agent.exitCode === 1 ? 'text-destructive' : ''}>
{agent.exitCode ?? '—'}
</span>
),
});
}
return (
<div>
{rows.map(({ label, value }) => (
<div key={label} className="flex items-center gap-4 py-1.5 border-b border-border/30 last:border-0">
<span className="w-28 shrink-0 text-xs text-muted-foreground">{label}</span>
<span className="text-sm">{value}</span>
</div>
))}
</div>
);
}
function InputFilesSection({ agentId }: { agentId: string }) {
const query = trpc.getAgentInputFiles.useQuery({ id: agentId });
const [selectedFile, setSelectedFile] = useState<string | null>(null);
useEffect(() => {
setSelectedFile(null);
}, [agentId]);
useEffect(() => {
if (!query.data?.files) return;
if (selectedFile !== null) return;
const manifest = query.data.files.find(f => f.name === 'manifest.json');
setSelectedFile(manifest?.name ?? query.data.files[0]?.name ?? null);
}, [query.data?.files]);
if (query.isLoading) {
return (
<div className="space-y-2">
<Skeleton variant="line" />
<Skeleton variant="line" />
<Skeleton variant="line" />
</div>
);
}
if (query.isError) {
return (
<div className="space-y-2">
<p className="text-sm text-destructive">{query.error.message}</p>
<Button variant="outline" size="sm" onClick={() => void query.refetch()}>Retry</Button>
</div>
);
}
const data = query.data;
if (!data) return null;
if (data.reason === 'worktree_missing') {
return <p className="text-sm text-muted-foreground">Worktree no longer exists input files unavailable</p>;
}
if (data.reason === 'input_dir_missing') {
return <p className="text-sm text-muted-foreground">Input directory not found this agent may not have received input files</p>;
}
const { files } = data;
if (files.length === 0) {
return <p className="text-sm text-muted-foreground">No input files found</p>;
}
return (
<div className="flex flex-col md:flex-row gap-2 min-h-0">
{/* File list */}
<div className="md:w-48 shrink-0 overflow-y-auto space-y-0.5">
{files.map(file => (
<button
key={file.name}
onClick={() => setSelectedFile(file.name)}
className={cn(
"w-full text-left px-2 py-1 text-xs rounded truncate",
selectedFile === file.name
? "bg-muted font-medium"
: "hover:bg-muted/50 text-muted-foreground"
)}
>
{file.name}
</button>
))}
</div>
{/* Content pane */}
<pre className="flex-1 text-xs font-mono overflow-auto bg-terminal rounded p-3 min-h-0">
{files.find(f => f.name === selectedFile)?.content ?? ''}
</pre>
</div>
);
}
function EffectivePromptSection({ agentId }: { agentId: string }) {
const query = trpc.getAgentPrompt.useQuery({ id: agentId });
if (query.isLoading) {
return <Skeleton variant="rect" className="h-32 w-full" />;
}
if (query.isError) {
return (
<div className="space-y-2">
<p className="text-sm text-destructive">{query.error.message}</p>
<Button variant="outline" size="sm" onClick={() => void query.refetch()}>Retry</Button>
</div>
);
}
const data = query.data;
if (!data) return null;
if (data.reason === 'prompt_not_written') {
return <p className="text-sm text-muted-foreground">Prompt file not available agent may have been spawned before this feature was added</p>;
}
if (data.content) {
return (
<pre className="text-xs font-mono overflow-y-auto max-h-[400px] bg-terminal rounded p-3 whitespace-pre-wrap">
{data.content}
</pre>
);
}
return null;
}

View File

@@ -6,6 +6,7 @@ import { trpc } from "@/lib/trpc";
import { useSubscriptionWithErrorHandling } from "@/hooks";
import {
type ParsedMessage,
type TimestampedChunk,
getMessageStyling,
parseAgentOutput,
} from "@/lib/parse-agent-output";
@@ -21,8 +22,8 @@ export function AgentOutputViewer({ agentId, agentName, status, onStop }: AgentO
const [messages, setMessages] = useState<ParsedMessage[]>([]);
const [follow, setFollow] = useState(true);
const containerRef = useRef<HTMLDivElement>(null);
// Accumulate raw JSONL: initial query data + live subscription chunks
const rawBufferRef = useRef<string>('');
// Accumulate timestamped chunks: initial query data + live subscription chunks
const chunksRef = useRef<TimestampedChunk[]>([]);
// Load initial/historical output
const outputQuery = trpc.getAgentOutput.useQuery(
@@ -40,8 +41,8 @@ export function AgentOutputViewer({ agentId, agentName, status, onStop }: AgentO
// TrackedEnvelope shape: { id, data: { agentId, data: string } }
const raw = event?.data?.data ?? event?.data;
const chunk = typeof raw === 'string' ? raw : JSON.stringify(raw);
rawBufferRef.current += chunk;
setMessages(parseAgentOutput(rawBufferRef.current));
chunksRef.current = [...chunksRef.current, { content: chunk, createdAt: new Date().toISOString() }];
setMessages(parseAgentOutput(chunksRef.current));
},
onError: (error) => {
console.error('Agent output subscription error:', error);
@@ -54,14 +55,14 @@ export function AgentOutputViewer({ agentId, agentName, status, onStop }: AgentO
// Set initial output when query loads
useEffect(() => {
if (outputQuery.data) {
rawBufferRef.current = outputQuery.data;
chunksRef.current = outputQuery.data;
setMessages(parseAgentOutput(outputQuery.data));
}
}, [outputQuery.data]);
// Reset output when agent changes
useEffect(() => {
rawBufferRef.current = '';
chunksRef.current = [];
setMessages([]);
setFollow(true);
}, [agentId]);
@@ -160,57 +161,64 @@ export function AgentOutputViewer({ agentId, agentName, status, onStop }: AgentO
<div
ref={containerRef}
onScroll={handleScroll}
className="flex-1 overflow-y-auto bg-terminal p-4"
className="flex-1 overflow-y-auto overflow-x-hidden bg-terminal p-4"
>
{isLoading ? (
<div className="text-terminal-muted text-sm">Loading output...</div>
) : !hasOutput ? (
<div className="text-terminal-muted text-sm">No output yet...</div>
) : (
<div className="space-y-2">
<div className="space-y-2 min-w-0">
{messages.map((message, index) => (
<div key={index} className={getMessageStyling(message.type)}>
{message.type === 'system' && (
<div className="flex items-center gap-2">
<Badge variant="secondary" className="text-xs bg-terminal-border text-terminal-system">System</Badge>
<span className="text-xs text-terminal-muted">{message.content}</span>
<Timestamp date={message.timestamp} />
</div>
)}
{message.type === 'text' && (
<div className="font-mono text-sm whitespace-pre-wrap text-terminal-fg">
{message.content}
</div>
<>
<Timestamp date={message.timestamp} />
<div className="font-mono text-sm whitespace-pre-wrap break-words text-terminal-fg">
{message.content}
</div>
</>
)}
{message.type === 'tool_call' && (
<div className="border-l-2 border-terminal-tool pl-3 py-1">
<Badge variant="default" className="mb-1 text-xs">
{message.meta?.toolName}
</Badge>
<div className="font-mono text-xs text-terminal-muted whitespace-pre-wrap">
<div className="border-l-2 border-terminal-tool pl-3 py-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<Badge variant="default" className="text-xs">
{message.meta?.toolName}
</Badge>
<Timestamp date={message.timestamp} />
</div>
<div className="font-mono text-xs text-terminal-muted whitespace-pre-wrap break-words">
{message.content}
</div>
</div>
)}
{message.type === 'tool_result' && (
<div className="border-l-2 border-terminal-result pl-3 py-1 bg-white/[0.02]">
<div className="border-l-2 border-terminal-result pl-3 py-1 bg-white/[0.02] min-w-0">
<Badge variant="outline" className="mb-1 text-xs text-terminal-result border-terminal-result">
Result
</Badge>
<div className="font-mono text-xs text-terminal-muted whitespace-pre-wrap">
<div className="font-mono text-xs text-terminal-muted whitespace-pre-wrap break-words">
{message.content}
</div>
</div>
)}
{message.type === 'error' && (
<div className="border-l-2 border-terminal-error pl-3 py-1 bg-terminal-error/10">
<div className="border-l-2 border-terminal-error pl-3 py-1 bg-terminal-error/10 min-w-0">
<Badge variant="destructive" className="mb-1 text-xs">
Error
</Badge>
<div className="font-mono text-xs text-terminal-error whitespace-pre-wrap">
<div className="font-mono text-xs text-terminal-error whitespace-pre-wrap break-words">
{message.content}
</div>
</div>
@@ -228,6 +236,7 @@ export function AgentOutputViewer({ agentId, agentName, status, onStop }: AgentO
{message.meta?.duration && (
<span className="text-xs text-terminal-muted">{(message.meta.duration / 1000).toFixed(1)}s</span>
)}
<Timestamp date={message.timestamp} />
</div>
</div>
)}
@@ -239,3 +248,16 @@ export function AgentOutputViewer({ agentId, agentName, status, onStop }: AgentO
</div>
);
}
function formatTime(date: Date): string {
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false });
}
function Timestamp({ date }: { date?: Date }) {
if (!date) return null;
return (
<span className="shrink-0 text-[10px] text-terminal-muted/60 font-mono tabular-nums">
{formatTime(date)}
</span>
);
}

View File

@@ -1,6 +1,7 @@
import { MoreHorizontal } from "lucide-react";
import { Card } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {
DropdownMenu,
DropdownMenuContent,
@@ -20,6 +21,7 @@ export interface SerializedInitiative {
branch: string | null;
createdAt: string;
updatedAt: string;
projects?: Array<{ id: string; name: string }>;
activity: {
state: string;
activePhase?: { id: string; name: string };
@@ -30,11 +32,12 @@ export interface SerializedInitiative {
function activityVisual(state: string): { label: string; variant: StatusVariant; pulse: boolean } {
switch (state) {
case "executing": return { label: "Executing", variant: "active", pulse: true };
case "pending_review": return { label: "Pending Review", variant: "warning", pulse: true };
case "discussing": return { label: "Discussing", variant: "active", pulse: true };
case "detailing": return { label: "Detailing", variant: "active", pulse: true };
case "refining": return { label: "Refining", variant: "active", pulse: true };
case "executing": return { label: "Executing", variant: "active", pulse: true };
case "pending_review": return { label: "Pending Review", variant: "warning", pulse: true };
case "discussing": return { label: "Discussing", variant: "active", pulse: true };
case "detailing": return { label: "Detailing", variant: "active", pulse: true };
case "refining": return { label: "Refining", variant: "active", pulse: true };
case "resolving_conflict": return { label: "Resolving Conflict", variant: "urgent", pulse: true };
case "ready": return { label: "Ready", variant: "active", pulse: false };
case "blocked": return { label: "Blocked", variant: "error", pulse: false };
case "complete": return { label: "Complete", variant: "success", pulse: false };
@@ -87,11 +90,19 @@ export function InitiativeCard({ initiative, onClick }: InitiativeCardProps) {
className="p-4"
onClick={onClick}
>
{/* Row 1: Name + overflow menu */}
<div className="flex items-center justify-between">
<span className="min-w-0 truncate text-base font-bold">
{initiative.name}
</span>
{/* Row 1: Name + project pills + overflow menu */}
<div className="flex items-center justify-between gap-2">
<div className="flex min-w-0 items-center gap-2">
<span className="shrink-0 text-base font-bold">
{initiative.name}
</span>
{initiative.projects && initiative.projects.length > 0 &&
initiative.projects.map((p) => (
<Badge key={p.id} variant="outline" size="xs" className="shrink-0 font-normal">
{p.name}
</Badge>
))}
</div>
<div onClick={(e) => e.stopPropagation()}>
<DropdownMenu>
<DropdownMenuTrigger asChild>

View File

@@ -45,6 +45,10 @@ export function mapEntityStatus(rawStatus: string): StatusVariant {
case "medium":
return "warning";
// Urgent / conflict resolution
case "resolving_conflict":
return "urgent";
// Error / failed
case "crashed":
case "blocked":

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

@@ -253,13 +253,13 @@ export function ContentTab({ initiativeId, initiativeName }: ContentTabProps) {
{resolvedActivePageId && (
<>
{(isSaving || updateInitiativeMutation.isPending) && (
<div className="flex justify-end mb-2">
<div className="flex justify-end mb-2 h-4">
{(isSaving || updateInitiativeMutation.isPending) && (
<span className="text-xs text-muted-foreground">
Saving...
</span>
</div>
)}
)}
</div>
{activePageQuery.isSuccess && (
<input
value={pageTitle}

View File

@@ -1,4 +1,4 @@
import { useEffect, useRef, useCallback } from "react";
import { useEffect, useRef, useCallback, useMemo } from "react";
import { useEditor, EditorContent } from "@tiptap/react";
import type { Editor } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
@@ -36,33 +36,33 @@ export function TiptapEditor({
const onPageLinkDeletedRef = useRef(onPageLinkDeleted);
onPageLinkDeletedRef.current = onPageLinkDeleted;
const pageLinkDeletionDetector = createPageLinkDeletionDetector(onPageLinkDeletedRef);
const baseExtensions = [
StarterKit,
Table.configure({ resizable: true, cellMinWidth: 50 }),
TableRow,
TableCell,
TableHeader,
Placeholder.configure({
includeChildren: true,
placeholder: ({ node }) => {
if (node.type.name === 'heading') {
return `Heading ${node.attrs.level}`;
}
return "Type '/' for commands...";
},
}),
Link.configure({
openOnClick: false,
}),
SlashCommands,
BlockSelectionExtension,
];
const extensions = enablePageLinks
? [...baseExtensions, PageLinkExtension, pageLinkDeletionDetector]
: baseExtensions;
const extensions = useMemo(() => {
const detector = createPageLinkDeletionDetector(onPageLinkDeletedRef);
const base = [
StarterKit,
Table.configure({ resizable: true, cellMinWidth: 50 }),
TableRow,
TableCell,
TableHeader,
Placeholder.configure({
includeChildren: true,
placeholder: ({ node }) => {
if (node.type.name === 'heading') {
return `Heading ${node.attrs.level}`;
}
return "Type '/' for commands...";
},
}),
Link.configure({
openOnClick: false,
}),
SlashCommands,
BlockSelectionExtension,
];
return enablePageLinks
? [...base, PageLinkExtension, detector]
: base;
}, [enablePageLinks]);
const editor = useEditor(
{

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,18 +1,21 @@
import { useCallback, useEffect, useRef, useMemo } from "react";
import { useCallback, useEffect, useRef, useMemo, useState } 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";
import { StatusBadge } from "@/components/StatusBadge";
import { StatusDot } from "@/components/StatusDot";
import { TiptapEditor } from "@/components/editor/TiptapEditor";
import { AgentOutputViewer } from "@/components/AgentOutputViewer";
import { getCategoryConfig } from "@/lib/category";
import { markdownToTiptapJson } from "@/lib/markdown-to-tiptap";
import { useExecutionContext } from "./ExecutionContext";
import { trpc } from "@/lib/trpc";
import { cn } from "@/lib/utils";
type SlideOverTab = "details" | "logs";
interface TaskSlideOverProps {
onOpenChat?: (target: ChatTarget) => void;
}
@@ -20,11 +23,19 @@ 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();
const [tab, setTab] = useState<SlideOverTab>("details");
const close = useCallback(() => setSelectedTaskId(null), [setSelectedTaskId]);
// Reset tab when task changes
useEffect(() => {
setTab("details");
}, [selectedEntry?.task?.id]);
// Escape key closes
useEffect(() => {
if (!selectedEntry) return;
@@ -151,95 +162,137 @@ export function TaskSlideOver({ onOpenChat }: TaskSlideOverProps) {
</button>
</div>
{/* Tab bar */}
<div className="flex gap-4 border-b border-border px-5">
{(["details", "logs"] as const).map((t) => (
<button
key={t}
className={cn(
"relative pb-2 pt-3 text-sm font-medium transition-colors",
tab === t
? "text-foreground"
: "text-muted-foreground hover:text-foreground",
)}
onClick={() => setTab(t)}
>
{t === "details" ? "Details" : "Agent Logs"}
{tab === t && (
<span className="absolute inset-x-0 bottom-0 h-0.5 bg-primary" />
)}
</button>
))}
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto px-5 py-4 space-y-5">
{/* Metadata grid */}
<div className="grid grid-cols-2 gap-3 text-sm">
<MetaField label="Status">
<StatusBadge status={task.status} />
</MetaField>
<MetaField label="Category">
<CategoryBadge category={task.category} />
</MetaField>
<MetaField label="Priority">
<PriorityText priority={task.priority} />
</MetaField>
<MetaField label="Type">
<span className="font-medium">{task.type}</span>
</MetaField>
<MetaField label="Agent" span={2}>
<span className="font-medium">
{selectedEntry.agentName ?? "Unassigned"}
</span>
</MetaField>
</div>
<div className={cn("flex-1 min-h-0", tab === "details" ? "overflow-y-auto" : "flex flex-col")}>
{tab === "details" ? (
<div className="px-5 py-4 space-y-5">
{/* Metadata grid */}
<div className="grid grid-cols-2 gap-3 text-sm">
<MetaField label="Status">
<StatusBadge status={task.status} />
</MetaField>
<MetaField label="Category">
<CategoryBadge category={task.category} />
</MetaField>
<MetaField label="Priority">
<PriorityText priority={task.priority} />
</MetaField>
<MetaField label="Type">
<span className="font-medium">{task.type}</span>
</MetaField>
<MetaField label="Agent" span={2}>
<span className="font-medium">
{selectedEntry.agentName ?? "Unassigned"}
</span>
</MetaField>
</div>
{/* Description — editable tiptap */}
<Section title="Description">
<TiptapEditor
entityId={task.id}
content={editorContent}
onUpdate={handleDescriptionUpdate}
enablePageLinks={false}
/>
</Section>
{/* Description — editable tiptap */}
<Section title="Description">
<TiptapEditor
entityId={task.id}
content={editorContent}
onUpdate={handleDescriptionUpdate}
enablePageLinks={false}
/>
</Section>
{/* Dependencies */}
<Section title="Blocked By">
{dependencies.length === 0 ? (
<p className="text-sm text-muted-foreground">None</p>
) : (
<ul className="space-y-1.5">
{dependencies.map((dep) => (
<li
key={dep.name}
className="flex items-center gap-2 text-sm"
>
<StatusDot status={dep.status} size="sm" />
<span className="min-w-0 flex-1 truncate">
{dep.name}
</span>
</li>
))}
</ul>
)}
</Section>
{/* Dependencies */}
<Section title="Blocked By">
{dependencies.length === 0 ? (
<p className="text-sm text-muted-foreground">None</p>
) : (
<ul className="space-y-1.5">
{dependencies.map((dep) => (
<li
key={dep.name}
className="flex items-center gap-2 text-sm"
>
<StatusDot status={dep.status} size="sm" />
<span className="min-w-0 flex-1 truncate">
{dep.name}
</span>
</li>
))}
</ul>
)}
</Section>
{/* Blocks */}
<Section title="Blocks">
{dependents.length === 0 ? (
<p className="text-sm text-muted-foreground">None</p>
) : (
<ul className="space-y-1.5">
{dependents.map((dep) => (
<li
key={dep.name}
className="flex items-center gap-2 text-sm"
>
<StatusDot status={dep.status} size="sm" />
<span className="min-w-0 flex-1 truncate">
{dep.name}
</span>
</li>
))}
</ul>
)}
</Section>
{/* Blocks */}
<Section title="Blocks">
{dependents.length === 0 ? (
<p className="text-sm text-muted-foreground">None</p>
) : (
<ul className="space-y-1.5">
{dependents.map((dep) => (
<li
key={dep.name}
className="flex items-center gap-2 text-sm"
>
<StatusDot status={dep.status} size="sm" />
<span className="min-w-0 flex-1 truncate">
{dep.name}
</span>
</li>
))}
</ul>
)}
</Section>
</div>
) : (
<AgentLogsTab taskId={task.id} />
)}
</div>
{/* 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"
@@ -277,6 +330,43 @@ export function TaskSlideOver({ onOpenChat }: TaskSlideOverProps) {
);
}
// ---------------------------------------------------------------------------
// Agent Logs Tab
// ---------------------------------------------------------------------------
function AgentLogsTab({ taskId }: { taskId: string }) {
const { data: agent, isLoading } = trpc.getTaskAgent.useQuery(
{ taskId },
{ refetchOnWindowFocus: false },
);
if (isLoading) {
return (
<div className="flex items-center justify-center py-12 text-sm text-muted-foreground">
Loading...
</div>
);
}
if (!agent) {
return (
<div className="flex items-center justify-center py-12 text-sm text-muted-foreground">
No agent has been assigned to this task yet.
</div>
);
}
return (
<div className="flex-1 min-h-0">
<AgentOutputViewer
agentId={agent.id}
agentName={agent.name ?? undefined}
status={agent.status}
/>
</div>
);
}
// ---------------------------------------------------------------------------
// Small helpers
// ---------------------------------------------------------------------------

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,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<string | null>(null);
// 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;
}

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">
{/* 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,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 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}
@@ -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]">
{/* 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}

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

@@ -7,12 +7,19 @@
export { useAutoSave } from './useAutoSave.js';
export { useDebounce, useDebounceWithImmediate } from './useDebounce.js';
export { useLiveUpdates } from './useLiveUpdates.js';
export { useLiveUpdates, INITIATIVE_LIST_RULES } from './useLiveUpdates.js';
export type { LiveUpdateRule } 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

@@ -15,6 +15,18 @@ export interface LiveUpdateRule {
*
* Encapsulates error toast + reconnect config so pages don't duplicate boilerplate.
*/
/**
* Reusable rules for any page displaying initiative cards.
* Covers all event prefixes that can change derived initiative activity state.
*/
export const INITIATIVE_LIST_RULES: LiveUpdateRule[] = [
{ prefix: 'initiative:', invalidate: ['listInitiatives'] },
{ prefix: 'task:', invalidate: ['listInitiatives'] },
{ prefix: 'phase:', invalidate: ['listInitiatives'] },
{ prefix: 'agent:', invalidate: ['listInitiatives'] },
{ prefix: 'merge:', invalidate: ['listInitiatives'] },
];
export function useLiveUpdates(rules: LiveUpdateRule[]) {
const utils = trpc.useUtils();

View File

@@ -44,20 +44,24 @@ const INVALIDATION_MAP: Partial<Record<MutationName, QueryName[]>> = {
spawnArchitectDiscuss: ["listAgents"],
spawnArchitectPlan: ["listAgents"],
spawnArchitectDetail: ["listAgents", "listInitiativeTasks"],
spawnConflictResolutionAgent: ["listAgents", "listInitiatives", "getInitiative"],
// --- Initiatives ---
createInitiative: ["listInitiatives"],
updateInitiative: ["listInitiatives", "getInitiative"],
updateInitiativeProjects: ["getInitiative"],
approveInitiativeReview: ["listInitiatives", "getInitiative"],
requestInitiativeChanges: ["listInitiatives", "getInitiative"],
// --- Phases ---
createPhase: ["listPhases", "listInitiativePhaseDependencies"],
deletePhase: ["listPhases", "listInitiativeTasks", "listInitiativePhaseDependencies"],
deletePhase: ["listPhases", "listInitiativeTasks", "listInitiativePhaseDependencies", "listChangeSets"],
updatePhase: ["listPhases", "getPhase"],
approvePhase: ["listPhases", "listInitiativeTasks"],
approvePhase: ["listPhases", "listInitiativeTasks", "listInitiatives"],
requestPhaseChanges: ["listPhases", "listInitiativeTasks", "listPhaseTasks", "getInitiative"],
queuePhase: ["listPhases"],
createPhaseDependency: ["getPhaseDependencies", "listInitiativePhaseDependencies"],
removePhaseDependency: ["getPhaseDependencies", "listInitiativePhaseDependencies"],
createPhaseDependency: ["getPhaseDependencies", "listInitiativePhaseDependencies", "listPhaseTaskDependencies"],
removePhaseDependency: ["getPhaseDependencies", "listInitiativePhaseDependencies", "listPhaseTaskDependencies"],
// --- Tasks ---
createPhaseTask: ["listPhaseTasks", "listInitiativeTasks", "listTasks"],
@@ -65,11 +69,17 @@ 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"],
// NOTE: getPage omitted — useAutoSave handles optimistic updates for the
// active page, and SSE `page:updated` events cover external changes.
// Including getPage here caused double-invalidation (mutation + SSE) and
// refetch storms that flickered the editor.
updatePage: ["listPages", "getRootPage"],
createPage: ["listPages", "getRootPage"],
deletePage: ["listPages", "getRootPage"],

View File

@@ -7,6 +7,7 @@ export interface ParsedMessage {
| "session_end"
| "error";
content: string;
timestamp?: Date;
meta?: {
toolName?: string;
isError?: boolean;
@@ -60,108 +61,135 @@ export function getMessageStyling(type: ParsedMessage["type"]): string {
}
}
export function parseAgentOutput(raw: string): ParsedMessage[] {
const lines = raw.split("\n").filter(Boolean);
/**
* A chunk of raw JSONL content with an optional timestamp from the DB.
*/
export interface TimestampedChunk {
content: string;
createdAt: string;
}
/**
* Parse agent output. Accepts either a flat string (legacy) or timestamped chunks.
* When chunks have timestamps, each parsed message inherits the chunk's timestamp.
*/
export function parseAgentOutput(raw: string | TimestampedChunk[]): ParsedMessage[] {
const chunks: { content: string; timestamp?: Date }[] =
typeof raw === "string"
? [{ content: raw }]
: raw.map((c) => ({ content: c.content, timestamp: new Date(c.createdAt) }));
const parsedMessages: ParsedMessage[] = [];
for (const line of lines) {
try {
const event = JSON.parse(line);
for (const chunk of chunks) {
const lines = chunk.content.split("\n").filter(Boolean);
for (const line of lines) {
try {
const event = JSON.parse(line);
// System initialization
if (event.type === "system" && event.session_id) {
parsedMessages.push({
type: "system",
content: `Session started: ${event.session_id}`,
});
}
// Assistant messages with text and tool calls
else if (
event.type === "assistant" &&
Array.isArray(event.message?.content)
) {
for (const block of event.message.content) {
if (block.type === "text" && block.text) {
parsedMessages.push({
type: "text",
content: block.text,
});
} else if (block.type === "tool_use") {
parsedMessages.push({
type: "tool_call",
content: formatToolCall(block),
meta: { toolName: block.name },
});
}
// System initialization
if (event.type === "system" && event.session_id) {
parsedMessages.push({
type: "system",
content: `Session started: ${event.session_id}`,
timestamp: chunk.timestamp,
});
}
}
// User messages with tool results
else if (
event.type === "user" &&
Array.isArray(event.message?.content)
) {
for (const block of event.message.content) {
if (block.type === "tool_result") {
const rawContent = block.content;
const output =
typeof rawContent === "string"
? rawContent
: Array.isArray(rawContent)
? rawContent
.map((c: any) => c.text ?? JSON.stringify(c))
.join("\n")
: (event.tool_use_result?.stdout || "");
const stderr = event.tool_use_result?.stderr;
if (stderr) {
// Assistant messages with text and tool calls
else if (
event.type === "assistant" &&
Array.isArray(event.message?.content)
) {
for (const block of event.message.content) {
if (block.type === "text" && block.text) {
parsedMessages.push({
type: "error",
content: stderr,
meta: { isError: true },
type: "text",
content: block.text,
timestamp: chunk.timestamp,
});
} else if (output) {
const displayOutput =
output.length > 1000
? output.substring(0, 1000) + "\n... (truncated)"
: output;
} else if (block.type === "tool_use") {
parsedMessages.push({
type: "tool_result",
content: displayOutput,
type: "tool_call",
content: formatToolCall(block),
timestamp: chunk.timestamp,
meta: { toolName: block.name },
});
}
}
}
}
// Legacy streaming format
else if (event.type === "stream_event" && event.event?.delta?.text) {
// User messages with tool results
else if (
event.type === "user" &&
Array.isArray(event.message?.content)
) {
for (const block of event.message.content) {
if (block.type === "tool_result") {
const rawContent = block.content;
const output =
typeof rawContent === "string"
? rawContent
: Array.isArray(rawContent)
? rawContent
.map((c: any) => c.text ?? JSON.stringify(c))
.join("\n")
: (event.tool_use_result?.stdout || "");
const stderr = event.tool_use_result?.stderr;
if (stderr) {
parsedMessages.push({
type: "error",
content: stderr,
timestamp: chunk.timestamp,
meta: { isError: true },
});
} else if (output) {
const displayOutput =
output.length > 1000
? output.substring(0, 1000) + "\n... (truncated)"
: output;
parsedMessages.push({
type: "tool_result",
content: displayOutput,
timestamp: chunk.timestamp,
});
}
}
}
}
// Legacy streaming format
else if (event.type === "stream_event" && event.event?.delta?.text) {
parsedMessages.push({
type: "text",
content: event.event.delta.text,
timestamp: chunk.timestamp,
});
}
// Session completion
else if (event.type === "result") {
parsedMessages.push({
type: "session_end",
content: event.is_error ? "Session failed" : "Session completed",
timestamp: chunk.timestamp,
meta: {
isError: event.is_error,
cost: event.total_cost_usd,
duration: event.duration_ms,
},
});
}
} catch {
// Not JSON, display as-is
parsedMessages.push({
type: "text",
content: event.event.delta.text,
type: "error",
content: line,
timestamp: chunk.timestamp,
meta: { isError: true },
});
}
// Session completion
else if (event.type === "result") {
parsedMessages.push({
type: "session_end",
content: event.is_error ? "Session failed" : "Session completed",
meta: {
isError: event.is_error,
cost: event.total_cost_usd,
duration: event.duration_ms,
},
});
}
} catch {
// Not JSON, display as-is
parsedMessages.push({
type: "error",
content: line,
meta: { isError: true },
});
}
}
return parsedMessages;

View File

@@ -1,4 +1,4 @@
import { useState } from "react";
import { useState, useEffect } from "react";
import { createFileRoute, useNavigate, useSearch } from "@tanstack/react-router";
import { motion } from "motion/react";
import { AlertCircle, RefreshCw, Terminal, Users } from "lucide-react";
@@ -9,8 +9,9 @@ import { Skeleton } from "@/components/Skeleton";
import { toast } from "sonner";
import { trpc } from "@/lib/trpc";
import { AgentOutputViewer } from "@/components/AgentOutputViewer";
import { AgentDetailsPanel } from "@/components/AgentDetailsPanel";
import { AgentActions } from "@/components/AgentActions";
import { formatRelativeTime } from "@/lib/utils";
import { formatRelativeTime, cn } from "@/lib/utils";
import { modeLabel } from "@/lib/labels";
import { StatusDot } from "@/components/StatusDot";
import { useLiveUpdates } from "@/hooks";
@@ -29,7 +30,12 @@ export const Route = createFileRoute("/agents")({
function AgentsPage() {
const [selectedAgentId, setSelectedAgentId] = useState<string | null>(null);
const [activeTab, setActiveTab] = useState<'output' | 'details'>('output');
const { filter } = useSearch({ from: "/agents" });
useEffect(() => {
setActiveTab('output');
}, [selectedAgentId]);
const navigate = useNavigate();
// Live updates
@@ -308,15 +314,49 @@ function AgentsPage() {
)}
</div>
{/* Right: Output Viewer */}
{/* Right: Output/Details Viewer */}
<div className="min-h-0 overflow-hidden">
{selectedAgent ? (
<AgentOutputViewer
agentId={selectedAgent.id}
agentName={selectedAgent.name}
status={selectedAgent.status}
onStop={handleStop}
/>
<div className="flex flex-col min-h-0 h-full">
{/* Tab bar */}
<div className="flex shrink-0 border-b border-terminal-border">
<button
className={cn(
"px-4 py-2 text-sm font-medium",
activeTab === 'output'
? "border-b-2 border-primary text-foreground"
: "text-muted-foreground hover:text-foreground"
)}
onClick={() => setActiveTab('output')}
>
Output
</button>
<button
className={cn(
"px-4 py-2 text-sm font-medium",
activeTab === 'details'
? "border-b-2 border-primary text-foreground"
: "text-muted-foreground hover:text-foreground"
)}
onClick={() => setActiveTab('details')}
>
Details
</button>
</div>
{/* Panel content */}
<div className="flex-1 min-h-0 overflow-hidden">
{activeTab === 'output' ? (
<AgentOutputViewer
agentId={selectedAgent.id}
agentName={selectedAgent.name}
status={selectedAgent.status}
onStop={handleStop}
/>
) : (
<AgentDetailsPanel agentId={selectedAgent.id} />
)}
</div>
</div>
) : (
<div className="flex h-full flex-col items-center justify-center gap-3 rounded-lg border border-dashed">
<Terminal className="h-10 w-10 text-muted-foreground/30" />

View File

@@ -1,3 +1,4 @@
import { useMemo } from "react";
import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { motion } from "motion/react";
import { AlertCircle } from "lucide-react";
@@ -11,6 +12,7 @@ import { ExecutionTab } from "@/components/ExecutionTab";
import { ReviewTab } from "@/components/review";
import { PipelineTab } from "@/components/pipeline";
import { useLiveUpdates } from "@/hooks";
import type { LiveUpdateRule } from "@/hooks";
type Tab = "content" | "plan" | "execution" | "review";
const TABS: Tab[] = ["content", "plan", "execution", "review"];
@@ -27,13 +29,17 @@ function InitiativeDetailPage() {
const { tab: activeTab } = Route.useSearch();
const navigate = useNavigate();
// 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'] },
]);
// Single SSE stream for all live updates — memoized to avoid re-subscribe on render
const liveUpdateRules = useMemo<LiveUpdateRule[]>(() => [
{ prefix: 'initiative:', invalidate: ['getInitiative'] },
{ prefix: 'task:', invalidate: ['listPhases', 'listTasks', 'listInitiativeTasks', 'getPhaseDependencies', 'listPhaseTaskDependencies'] },
{ prefix: 'phase:', invalidate: ['listPhases', 'listTasks', 'listInitiativePhaseDependencies', 'getPhaseDependencies'] },
{ prefix: 'agent:', invalidate: ['listAgents', 'getActiveRefineAgent', 'getTaskAgent', 'getActiveConflictAgent'] },
{ prefix: 'page:', invalidate: ['listPages', 'getPage', 'getRootPage'] },
{ prefix: 'changeset:', invalidate: ['getChangeSet', 'listChangeSets'] },
{ prefix: 'preview:', invalidate: ['listPreviews', 'getPreviewStatus'] },
], []);
useLiveUpdates(liveUpdateRules);
// tRPC queries
const initiativeQuery = trpc.getInitiative.useQuery({ id });

View File

@@ -5,7 +5,7 @@ import { Plus } from "lucide-react";
import { Button } from "@/components/ui/button";
import { InitiativeList } from "@/components/InitiativeList";
import { CreateInitiativeDialog } from "@/components/CreateInitiativeDialog";
import { useLiveUpdates } from "@/hooks";
import { useLiveUpdates, INITIATIVE_LIST_RULES } from "@/hooks";
import { trpc } from "@/lib/trpc";
export const Route = createFileRoute("/initiatives/")({
@@ -29,10 +29,7 @@ function DashboardPage() {
const projectsQuery = trpc.listProjects.useQuery();
// Single SSE stream for live updates
useLiveUpdates([
{ prefix: 'task:', invalidate: ['listInitiatives'] },
{ prefix: 'phase:', invalidate: ['listInitiatives'] },
]);
useLiveUpdates(INITIATIVE_LIST_RULES);
return (
<motion.div