feat(web): Pipeline visualization, phase content editing, and review tab
Pipeline view groups phases by dependency depth with DAG visualization. Phase detail panel with Tiptap rich content editor and auto-save. Code review tab with diff viewer and comment threads (dummy data). Centralized live updates hook replaces scattered subscription boilerplate. Extract agent output parsing into shared utility. Inbox detail panel, account cards, and agent action components.
This commit is contained in:
199
packages/web/src/components/AccountCard.tsx
Normal file
199
packages/web/src/components/AccountCard.tsx
Normal file
@@ -0,0 +1,199 @@
|
||||
import { CheckCircle2, XCircle, AlertTriangle } from "lucide-react";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
||||
function formatResetTime(isoDate: string): string {
|
||||
const now = Date.now();
|
||||
const target = new Date(isoDate).getTime();
|
||||
const diffMs = target - now;
|
||||
if (diffMs <= 0) return "now";
|
||||
|
||||
const totalMinutes = Math.floor(diffMs / 60_000);
|
||||
const totalHours = Math.floor(totalMinutes / 60);
|
||||
const totalDays = Math.floor(totalHours / 24);
|
||||
|
||||
if (totalDays > 0) {
|
||||
const remainingHours = totalHours - totalDays * 24;
|
||||
return `in ${totalDays}d ${remainingHours}h`;
|
||||
}
|
||||
const remainingMinutes = totalMinutes - totalHours * 60;
|
||||
return `in ${totalHours}h ${remainingMinutes}m`;
|
||||
}
|
||||
|
||||
function capitalize(s: string): string {
|
||||
return s.charAt(0).toUpperCase() + s.slice(1);
|
||||
}
|
||||
|
||||
function UsageBar({
|
||||
label,
|
||||
utilization,
|
||||
resetsAt,
|
||||
}: {
|
||||
label: string;
|
||||
utilization: number;
|
||||
resetsAt: string | null;
|
||||
}) {
|
||||
const color =
|
||||
utilization >= 90
|
||||
? "bg-destructive"
|
||||
: utilization >= 70
|
||||
? "bg-yellow-500"
|
||||
: "bg-green-500";
|
||||
const resetText = resetsAt ? formatResetTime(resetsAt) : null;
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<span className="w-20 shrink-0 text-muted-foreground">{label}</span>
|
||||
<div className="h-2 flex-1 rounded-full bg-muted">
|
||||
<div
|
||||
className={`h-2 rounded-full ${color}`}
|
||||
style={{ width: `${Math.min(utilization, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="w-12 shrink-0 text-right">
|
||||
{utilization.toFixed(0)}%
|
||||
</span>
|
||||
{resetText && (
|
||||
<span className="shrink-0 text-muted-foreground">
|
||||
resets {resetText}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export type AccountData = {
|
||||
id: string;
|
||||
email: string;
|
||||
provider: string;
|
||||
credentialsValid: boolean;
|
||||
tokenValid: boolean;
|
||||
tokenExpiresAt: string | null;
|
||||
subscriptionType: string | null;
|
||||
error: string | null;
|
||||
usage: {
|
||||
five_hour: { utilization: number; resets_at: string | null } | null;
|
||||
seven_day: { utilization: number; resets_at: string | null } | null;
|
||||
seven_day_sonnet: {
|
||||
utilization: number;
|
||||
resets_at: string | null;
|
||||
} | null;
|
||||
seven_day_opus: { utilization: number; resets_at: string | null } | null;
|
||||
extra_usage: {
|
||||
is_enabled: boolean;
|
||||
monthly_limit: number | null;
|
||||
used_credits: number | null;
|
||||
utilization: number | null;
|
||||
} | null;
|
||||
} | null;
|
||||
isExhausted: boolean;
|
||||
exhaustedUntil: string | null;
|
||||
lastUsedAt: string | null;
|
||||
agentCount: number;
|
||||
activeAgentCount: number;
|
||||
};
|
||||
|
||||
export function AccountCard({ account }: { account: AccountData }) {
|
||||
const statusIcon = !account.credentialsValid ? (
|
||||
<XCircle className="h-5 w-5 shrink-0 text-destructive" />
|
||||
) : account.isExhausted ? (
|
||||
<AlertTriangle className="h-5 w-5 shrink-0 text-yellow-500" />
|
||||
) : (
|
||||
<CheckCircle2 className="h-5 w-5 shrink-0 text-green-500" />
|
||||
);
|
||||
|
||||
const statusText = !account.credentialsValid
|
||||
? "Invalid credentials"
|
||||
: account.isExhausted
|
||||
? `Exhausted until ${account.exhaustedUntil ? new Date(account.exhaustedUntil).toLocaleTimeString() : "unknown"}`
|
||||
: "Available";
|
||||
|
||||
const usage = account.usage;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="space-y-3 py-4">
|
||||
{/* Header row */}
|
||||
<div className="flex items-start gap-3">
|
||||
{statusIcon}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-sm font-medium">{account.email}</span>
|
||||
<Badge variant="outline">{account.provider}</Badge>
|
||||
{account.subscriptionType && (
|
||||
<Badge variant="secondary">
|
||||
{capitalize(account.subscriptionType)}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-1 flex items-center gap-3 text-xs text-muted-foreground">
|
||||
<span>
|
||||
{account.agentCount} agent
|
||||
{account.agentCount !== 1 ? "s" : ""} (
|
||||
{account.activeAgentCount} active)
|
||||
</span>
|
||||
<span>{statusText}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Usage bars */}
|
||||
{usage && (
|
||||
<div className="space-y-1.5 pl-8">
|
||||
{usage.five_hour && (
|
||||
<UsageBar
|
||||
label="Session (5h)"
|
||||
utilization={usage.five_hour.utilization}
|
||||
resetsAt={usage.five_hour.resets_at}
|
||||
/>
|
||||
)}
|
||||
{usage.seven_day && (
|
||||
<UsageBar
|
||||
label="Weekly (7d)"
|
||||
utilization={usage.seven_day.utilization}
|
||||
resetsAt={usage.seven_day.resets_at}
|
||||
/>
|
||||
)}
|
||||
{usage.seven_day_sonnet &&
|
||||
usage.seven_day_sonnet.utilization > 0 && (
|
||||
<UsageBar
|
||||
label="Sonnet (7d)"
|
||||
utilization={usage.seven_day_sonnet.utilization}
|
||||
resetsAt={usage.seven_day_sonnet.resets_at}
|
||||
/>
|
||||
)}
|
||||
{usage.seven_day_opus && usage.seven_day_opus.utilization > 0 && (
|
||||
<UsageBar
|
||||
label="Opus (7d)"
|
||||
utilization={usage.seven_day_opus.utilization}
|
||||
resetsAt={usage.seven_day_opus.resets_at}
|
||||
/>
|
||||
)}
|
||||
{usage.extra_usage && usage.extra_usage.is_enabled && (
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<span className="w-20 shrink-0 text-muted-foreground">
|
||||
Extra usage
|
||||
</span>
|
||||
<span>
|
||||
${((usage.extra_usage.used_credits ?? 0) / 100).toFixed(2)}{" "}
|
||||
used
|
||||
{usage.extra_usage.monthly_limit != null && (
|
||||
<>
|
||||
{" "}
|
||||
/ $
|
||||
{(usage.extra_usage.monthly_limit / 100).toFixed(2)} limit
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error message */}
|
||||
{account.error && (
|
||||
<p className="pl-8 text-xs text-destructive">{account.error}</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -16,11 +16,8 @@ interface ActionMenuProps {
|
||||
}
|
||||
|
||||
export function ActionMenu({ initiativeId, onDelete }: ActionMenuProps) {
|
||||
const utils = trpc.useUtils();
|
||||
|
||||
const archiveMutation = trpc.updateInitiative.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.listInitiatives.invalidate();
|
||||
onDelete?.();
|
||||
toast.success("Initiative archived");
|
||||
},
|
||||
|
||||
73
packages/web/src/components/AgentActions.tsx
Normal file
73
packages/web/src/components/AgentActions.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { MoreHorizontal } from "lucide-react";
|
||||
|
||||
interface AgentActionsProps {
|
||||
agentId: string;
|
||||
status: string;
|
||||
isDismissed: boolean;
|
||||
onStop: (id: string) => void;
|
||||
onDelete: (id: string) => void;
|
||||
onDismiss: (id: string) => void;
|
||||
onGoToInbox: () => void;
|
||||
}
|
||||
|
||||
export function AgentActions({
|
||||
agentId,
|
||||
status,
|
||||
isDismissed,
|
||||
onStop,
|
||||
onDelete,
|
||||
onDismiss,
|
||||
onGoToInbox,
|
||||
}: AgentActionsProps) {
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-6 w-6">
|
||||
<MoreHorizontal className="h-3.5 w-3.5" />
|
||||
<span className="sr-only">Agent actions</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{status === "waiting_for_input" && (
|
||||
<>
|
||||
<DropdownMenuItem onClick={onGoToInbox}>
|
||||
Go to Inbox
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
{(status === "running" || status === "waiting_for_input") && (
|
||||
<DropdownMenuItem onClick={() => onStop(agentId)}>
|
||||
Stop
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{!isDismissed &&
|
||||
["stopped", "crashed", "idle"].includes(status) && (
|
||||
<DropdownMenuItem onClick={() => onDismiss(agentId)}>
|
||||
Dismiss
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{(isDismissed ||
|
||||
["stopped", "crashed", "idle"].includes(status)) && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
className="text-destructive"
|
||||
onClick={() => onDelete(agentId)}
|
||||
>
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
@@ -4,69 +4,17 @@ import { Badge } from "@/components/ui/badge";
|
||||
import { ArrowDown, Pause, Play, AlertCircle } from "lucide-react";
|
||||
import { trpc } from "@/lib/trpc";
|
||||
import { useSubscriptionWithErrorHandling } from "@/hooks";
|
||||
import {
|
||||
type ParsedMessage,
|
||||
getMessageStyling,
|
||||
parseAgentOutput,
|
||||
} from "@/lib/parse-agent-output";
|
||||
|
||||
interface AgentOutputViewerProps {
|
||||
agentId: string;
|
||||
agentName?: string;
|
||||
}
|
||||
|
||||
function formatToolCall(toolUse: any): string {
|
||||
const { name, input } = toolUse;
|
||||
|
||||
if (name === 'Bash') {
|
||||
return `$ ${input.command}${input.description ? '\n# ' + input.description : ''}`;
|
||||
}
|
||||
|
||||
if (name === 'Read') {
|
||||
return `📄 Read: ${input.file_path}${input.offset ? ` (lines ${input.offset}-${input.offset + (input.limit || 10)})` : ''}`;
|
||||
}
|
||||
|
||||
if (name === 'Edit') {
|
||||
return `✏️ Edit: ${input.file_path}\n${input.old_string.substring(0, 100)}${input.old_string.length > 100 ? '...' : ''}\n→ ${input.new_string.substring(0, 100)}${input.new_string.length > 100 ? '...' : ''}`;
|
||||
}
|
||||
|
||||
if (name === 'Write') {
|
||||
return `📝 Write: ${input.file_path} (${input.content.length} chars)`;
|
||||
}
|
||||
|
||||
if (name === 'Task') {
|
||||
return `🤖 ${input.subagent_type}: ${input.description}\n${input.prompt?.substring(0, 200)}${input.prompt && input.prompt.length > 200 ? '...' : ''}`;
|
||||
}
|
||||
|
||||
// Generic fallback
|
||||
return `${name}: ${JSON.stringify(input, null, 2)}`;
|
||||
}
|
||||
|
||||
function getMessageStyling(type: ParsedMessage['type']): string {
|
||||
switch (type) {
|
||||
case 'system':
|
||||
return 'mb-1';
|
||||
case 'text':
|
||||
return 'mb-1';
|
||||
case 'tool_call':
|
||||
return 'mb-2';
|
||||
case 'tool_result':
|
||||
return 'mb-2';
|
||||
case 'error':
|
||||
return 'mb-2';
|
||||
case 'session_end':
|
||||
return 'mb-2';
|
||||
default:
|
||||
return 'mb-1';
|
||||
}
|
||||
}
|
||||
|
||||
interface ParsedMessage {
|
||||
type: 'text' | 'system' | 'tool_call' | 'tool_result' | 'session_end' | 'error';
|
||||
content: string;
|
||||
meta?: {
|
||||
toolName?: string;
|
||||
isError?: boolean;
|
||||
cost?: number;
|
||||
duration?: number;
|
||||
};
|
||||
}
|
||||
|
||||
export function AgentOutputViewer({ agentId, agentName }: AgentOutputViewerProps) {
|
||||
const [messages, setMessages] = useState<ParsedMessage[]>([]);
|
||||
const [follow, setFollow] = useState(true);
|
||||
@@ -101,100 +49,7 @@ export function AgentOutputViewer({ agentId, agentName }: AgentOutputViewerProps
|
||||
// Set initial output when query loads
|
||||
useEffect(() => {
|
||||
if (outputQuery.data) {
|
||||
const lines = outputQuery.data.split("\n").filter(Boolean);
|
||||
const parsedMessages: ParsedMessage[] = [];
|
||||
|
||||
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 }
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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,
|
||||
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
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Legacy streaming format
|
||||
else if (event.type === "stream_event" && event.event?.delta?.text) {
|
||||
parsedMessages.push({
|
||||
type: 'text',
|
||||
content: event.event.delta.text
|
||||
});
|
||||
}
|
||||
|
||||
// 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 }
|
||||
});
|
||||
}
|
||||
}
|
||||
setMessages(parsedMessages);
|
||||
setMessages(parseAgentOutput(outputQuery.data));
|
||||
}
|
||||
}, [outputQuery.data]);
|
||||
|
||||
@@ -233,7 +88,7 @@ export function AgentOutputViewer({ agentId, agentName }: AgentOutputViewerProps
|
||||
const hasOutput = messages.length > 0;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-[600px] rounded-lg border overflow-hidden">
|
||||
<div className="flex flex-col h-full rounded-lg border overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between border-b bg-zinc-900 px-4 py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
|
||||
@@ -30,13 +30,31 @@ export function CreateInitiativeDialog({
|
||||
const utils = trpc.useUtils();
|
||||
|
||||
const createMutation = trpc.createInitiative.useMutation({
|
||||
onMutate: async ({ name }) => {
|
||||
await utils.listInitiatives.cancel();
|
||||
const previousInitiatives = utils.listInitiatives.getData();
|
||||
const tempInitiative = {
|
||||
id: `temp-${Date.now()}`,
|
||||
name: name.trim(),
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
mergeRequiresApproval: true,
|
||||
mergeTarget: 'main',
|
||||
projects: [],
|
||||
};
|
||||
utils.listInitiatives.setData(undefined, (old = []) => [tempInitiative, ...old]);
|
||||
return { previousInitiatives };
|
||||
},
|
||||
onSuccess: () => {
|
||||
utils.listInitiatives.invalidate();
|
||||
onOpenChange(false);
|
||||
toast.success("Initiative created");
|
||||
},
|
||||
onError: (err) => {
|
||||
onError: (err, _variables, context) => {
|
||||
if (context?.previousInitiatives) {
|
||||
utils.listInitiatives.setData(undefined, context.previousInitiatives);
|
||||
}
|
||||
setError(err.message);
|
||||
toast.error("Failed to create initiative");
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -1,17 +1,27 @@
|
||||
import { useState, useMemo, useRef, useEffect } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { trpc } from "@/lib/trpc";
|
||||
import { topologicalSortPhases, type DependencyEdge } from "@codewalk-district/shared";
|
||||
import {
|
||||
ExecutionProvider,
|
||||
PhaseActions,
|
||||
PhasesList,
|
||||
ProgressSidebar,
|
||||
BreakdownSection,
|
||||
TaskModal,
|
||||
type PhaseData,
|
||||
} from "@/components/execution";
|
||||
import { PhaseSidebarItem } from "@/components/execution/PhaseSidebarItem";
|
||||
import {
|
||||
PhaseDetailPanel,
|
||||
PhaseDetailEmpty,
|
||||
} from "@/components/execution/PhaseDetailPanel";
|
||||
import { Skeleton } from "@/components/Skeleton";
|
||||
|
||||
interface ExecutionTabProps {
|
||||
initiativeId: string;
|
||||
phases: PhaseData[];
|
||||
phasesLoading: boolean;
|
||||
phasesLoaded: boolean;
|
||||
dependencyEdges: DependencyEdge[];
|
||||
}
|
||||
|
||||
export function ExecutionTab({
|
||||
@@ -19,30 +29,295 @@ export function ExecutionTab({
|
||||
phases,
|
||||
phasesLoading,
|
||||
phasesLoaded,
|
||||
dependencyEdges,
|
||||
}: ExecutionTabProps) {
|
||||
// Topological sort
|
||||
const sortedPhases = useMemo(
|
||||
() => topologicalSortPhases(phases, dependencyEdges),
|
||||
[phases, dependencyEdges],
|
||||
);
|
||||
|
||||
// Build dependency name map from bulk edges
|
||||
const depNamesByPhase = useMemo(() => {
|
||||
const map = new Map<string, string[]>();
|
||||
const phaseIndex = new Map(sortedPhases.map((p, i) => [p.id, i + 1]));
|
||||
for (const edge of dependencyEdges) {
|
||||
const depIdx = phaseIndex.get(edge.dependsOnPhaseId);
|
||||
if (!depIdx) continue;
|
||||
const existing = map.get(edge.phaseId) ?? [];
|
||||
existing.push(`Phase ${depIdx}`);
|
||||
map.set(edge.phaseId, existing);
|
||||
}
|
||||
return map;
|
||||
}, [dependencyEdges, sortedPhases]);
|
||||
|
||||
// Decompose agent tracking: map phaseId → most recent active decompose agent
|
||||
const agentsQuery = trpc.listAgents.useQuery();
|
||||
const allAgents = agentsQuery.data ?? [];
|
||||
|
||||
// Default to first incomplete phase
|
||||
const firstIncompleteId = useMemo(() => {
|
||||
const found = sortedPhases.find((p) => p.status !== "completed");
|
||||
return found?.id ?? sortedPhases[0]?.id ?? null;
|
||||
}, [sortedPhases]);
|
||||
|
||||
const [selectedPhaseId, setSelectedPhaseId] = useState<string | null>(null);
|
||||
const [isAddingPhase, setIsAddingPhase] = useState(false);
|
||||
|
||||
const deletePhase = trpc.deletePhase.useMutation({
|
||||
onSuccess: () => {
|
||||
setSelectedPhaseId(null);
|
||||
toast.success("Phase deleted");
|
||||
},
|
||||
onError: () => {
|
||||
toast.error("Failed to delete phase");
|
||||
},
|
||||
});
|
||||
|
||||
const createPhase = trpc.createPhase.useMutation({
|
||||
onSuccess: () => {
|
||||
setIsAddingPhase(false);
|
||||
toast.success("Phase created");
|
||||
},
|
||||
onError: () => {
|
||||
toast.error("Failed to create phase");
|
||||
},
|
||||
});
|
||||
|
||||
function handleStartAdd() {
|
||||
setIsAddingPhase(true);
|
||||
}
|
||||
|
||||
function handleConfirmAdd(name: string) {
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed) {
|
||||
setIsAddingPhase(false);
|
||||
return;
|
||||
}
|
||||
createPhase.mutate({ initiativeId, name: trimmed });
|
||||
}
|
||||
|
||||
function handleCancelAdd() {
|
||||
setIsAddingPhase(false);
|
||||
}
|
||||
|
||||
// Resolve actual selected ID (use default if none explicitly selected)
|
||||
const activePhaseId = selectedPhaseId ?? firstIncompleteId;
|
||||
const activePhase = sortedPhases.find((p) => p.id === activePhaseId) ?? null;
|
||||
const activeDisplayIndex = activePhase
|
||||
? sortedPhases.indexOf(activePhase) + 1
|
||||
: 0;
|
||||
|
||||
// Fetch all tasks for the initiative in one query (for sidebar counts)
|
||||
const allTasksQuery = trpc.listInitiativeTasks.useQuery(
|
||||
{ initiativeId },
|
||||
{ enabled: phasesLoaded && sortedPhases.length > 0 },
|
||||
);
|
||||
const allTasks = allTasksQuery.data ?? [];
|
||||
|
||||
// Group tasks and counts by phaseId
|
||||
const { taskCountsByPhase, tasksByPhase } = useMemo(() => {
|
||||
const counts: Record<string, { complete: number; total: number }> = {};
|
||||
const grouped: Record<string, typeof allTasks> = {};
|
||||
for (const task of allTasks) {
|
||||
const pid = task.phaseId;
|
||||
if (!pid) continue;
|
||||
if (!counts[pid]) counts[pid] = { complete: 0, total: 0 };
|
||||
counts[pid].total++;
|
||||
if (task.status === "completed") counts[pid].complete++;
|
||||
if (!grouped[pid]) grouped[pid] = [];
|
||||
grouped[pid].push(task);
|
||||
}
|
||||
return { taskCountsByPhase: counts, tasksByPhase: grouped };
|
||||
}, [allTasks]);
|
||||
|
||||
// Map phaseId → most recent active decompose agent
|
||||
const decomposeAgentByPhase = useMemo(() => {
|
||||
const map = new Map<string, (typeof allAgents)[number]>();
|
||||
// Build taskId → phaseId lookup from allTasks
|
||||
const taskPhaseMap = new Map<string, string>();
|
||||
for (const t of allTasks) {
|
||||
if (t.phaseId) taskPhaseMap.set(t.id, t.phaseId);
|
||||
}
|
||||
const candidates = allAgents.filter(
|
||||
(a) =>
|
||||
a.mode === "decompose" &&
|
||||
a.initiativeId === initiativeId &&
|
||||
["running", "waiting_for_input", "idle"].includes(a.status) &&
|
||||
!a.userDismissedAt,
|
||||
);
|
||||
for (const agent of candidates) {
|
||||
const phaseId = taskPhaseMap.get(agent.taskId ?? "");
|
||||
if (!phaseId) continue;
|
||||
const existing = map.get(phaseId);
|
||||
if (
|
||||
!existing ||
|
||||
new Date(agent.createdAt).getTime() >
|
||||
new Date(existing.createdAt).getTime()
|
||||
) {
|
||||
map.set(phaseId, agent);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}, [allAgents, allTasks, initiativeId]);
|
||||
|
||||
// Phase IDs that have zero tasks (eligible for breakdown)
|
||||
const phasesWithoutTasks = useMemo(
|
||||
() =>
|
||||
sortedPhases
|
||||
.filter((p) => !taskCountsByPhase[p.id]?.total)
|
||||
.map((p) => p.id),
|
||||
[sortedPhases, taskCountsByPhase],
|
||||
);
|
||||
|
||||
// Build display indices map for PhaseDetailPanel
|
||||
const allDisplayIndices = useMemo(
|
||||
() => new Map(sortedPhases.map((p, i) => [p.id, i + 1])),
|
||||
[sortedPhases],
|
||||
);
|
||||
|
||||
// No phases yet and not adding — show breakdown section
|
||||
if (phasesLoaded && sortedPhases.length === 0 && !isAddingPhase) {
|
||||
return (
|
||||
<ExecutionProvider>
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[1fr_340px]">
|
||||
{/* Left column: Phases */}
|
||||
<BreakdownSection
|
||||
initiativeId={initiativeId}
|
||||
phasesLoaded={phasesLoaded}
|
||||
phases={sortedPhases}
|
||||
onAddPhase={handleStartAdd}
|
||||
/>
|
||||
<TaskModal />
|
||||
</ExecutionProvider>
|
||||
);
|
||||
}
|
||||
|
||||
const nextNumber = sortedPhases.length + 1;
|
||||
|
||||
return (
|
||||
<ExecutionProvider>
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[260px_1fr]">
|
||||
{/* Left: Phase sidebar */}
|
||||
<div className="space-y-0">
|
||||
<div className="flex items-center justify-between border-b border-border pb-3">
|
||||
<h2 className="text-lg font-semibold">Phases</h2>
|
||||
<PhaseActions initiativeId={initiativeId} phases={phases} />
|
||||
</div>
|
||||
|
||||
<PhasesList
|
||||
<h2 className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
Phases
|
||||
</h2>
|
||||
<PhaseActions
|
||||
initiativeId={initiativeId}
|
||||
phases={phases}
|
||||
phasesLoading={phasesLoading}
|
||||
phasesLoaded={phasesLoaded}
|
||||
phases={sortedPhases}
|
||||
onAddPhase={handleStartAdd}
|
||||
phasesWithoutTasks={phasesWithoutTasks}
|
||||
decomposeAgentByPhase={decomposeAgentByPhase}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Right column: Progress + Decisions */}
|
||||
<ProgressSidebar phases={phases} />
|
||||
{phasesLoading ? (
|
||||
<div className="space-y-1 pt-2">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-14 w-full" />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-0.5 pt-2">
|
||||
{sortedPhases.map((phase, index) => (
|
||||
<PhaseSidebarItem
|
||||
key={phase.id}
|
||||
phase={phase}
|
||||
displayIndex={index + 1}
|
||||
taskCount={
|
||||
taskCountsByPhase[phase.id] ?? { complete: 0, total: 0 }
|
||||
}
|
||||
dependencies={depNamesByPhase.get(phase.id) ?? []}
|
||||
isSelected={phase.id === activePhaseId}
|
||||
onClick={() => setSelectedPhaseId(phase.id)}
|
||||
/>
|
||||
))}
|
||||
{isAddingPhase && (
|
||||
<NewPhaseEntry
|
||||
number={nextNumber}
|
||||
onConfirm={handleConfirmAdd}
|
||||
onCancel={handleCancelAdd}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right: Phase detail */}
|
||||
<div className="min-h-[400px]">
|
||||
{activePhase ? (
|
||||
<PhaseDetailPanel
|
||||
key={activePhase.id}
|
||||
phase={activePhase}
|
||||
phases={sortedPhases}
|
||||
displayIndex={activeDisplayIndex}
|
||||
allDisplayIndices={allDisplayIndices}
|
||||
initiativeId={initiativeId}
|
||||
tasks={tasksByPhase[activePhase.id] ?? []}
|
||||
tasksLoading={allTasksQuery.isLoading}
|
||||
onDelete={() => deletePhase.mutate({ id: activePhase.id })}
|
||||
decomposeAgent={decomposeAgentByPhase.get(activePhase.id) ?? null}
|
||||
/>
|
||||
) : (
|
||||
<PhaseDetailEmpty />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TaskModal />
|
||||
</ExecutionProvider>
|
||||
);
|
||||
}
|
||||
|
||||
/** Editable placeholder entry that looks like a PhaseSidebarItem */
|
||||
function NewPhaseEntry({
|
||||
number,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}: {
|
||||
number: number;
|
||||
onConfirm: (name: string) => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
const [name, setName] = useState("");
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
inputRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
function handleBlur() {
|
||||
const trimmed = name.trim();
|
||||
if (trimmed) {
|
||||
onConfirm(trimmed);
|
||||
} else {
|
||||
onCancel();
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex w-full flex-col gap-0.5 rounded-md border-l-2 border-primary bg-accent px-3 py-2">
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="shrink-0 text-sm font-medium">Phase {number}:</span>
|
||||
<input
|
||||
ref={inputRef}
|
||||
className="min-w-0 flex-1 bg-transparent text-sm font-medium outline-none placeholder:text-muted-foreground"
|
||||
placeholder="Phase name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
const trimmed = name.trim();
|
||||
if (trimmed) onConfirm(trimmed);
|
||||
else onCancel();
|
||||
}
|
||||
if (e.key === "Escape") onCancel();
|
||||
}}
|
||||
onBlur={handleBlur}
|
||||
/>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">0/0 tasks</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
171
packages/web/src/components/InboxDetailPanel.tsx
Normal file
171
packages/web/src/components/InboxDetailPanel.tsx
Normal file
@@ -0,0 +1,171 @@
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { ChevronLeft } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { QuestionForm } from "@/components/QuestionForm";
|
||||
import { formatRelativeTime } from "@/lib/utils";
|
||||
|
||||
interface InboxDetailPanelProps {
|
||||
agent: {
|
||||
id: string;
|
||||
name: string;
|
||||
status: string;
|
||||
taskId: string | null;
|
||||
updatedAt: string;
|
||||
};
|
||||
message: {
|
||||
id: string;
|
||||
content: string;
|
||||
requiresResponse: boolean;
|
||||
} | null;
|
||||
questions:
|
||||
| {
|
||||
id: string;
|
||||
question: string;
|
||||
options: any;
|
||||
multiSelect: boolean;
|
||||
}[]
|
||||
| null;
|
||||
isLoadingQuestions: boolean;
|
||||
questionsError: string | null;
|
||||
onBack: () => void;
|
||||
onSubmitAnswers: (answers: Record<string, string>) => void;
|
||||
onDismissQuestions: () => void;
|
||||
onDismissMessage: () => void;
|
||||
isSubmitting: boolean;
|
||||
isDismissingQuestions: boolean;
|
||||
isDismissingMessage: boolean;
|
||||
submitError: string | null;
|
||||
dismissMessageError: string | null;
|
||||
}
|
||||
|
||||
export function InboxDetailPanel({
|
||||
agent,
|
||||
message,
|
||||
questions,
|
||||
isLoadingQuestions,
|
||||
questionsError,
|
||||
onBack,
|
||||
onSubmitAnswers,
|
||||
onDismissQuestions,
|
||||
onDismissMessage,
|
||||
isSubmitting,
|
||||
isDismissingQuestions,
|
||||
isDismissingMessage,
|
||||
submitError,
|
||||
dismissMessageError,
|
||||
}: InboxDetailPanelProps) {
|
||||
return (
|
||||
<div className="space-y-4 rounded-lg border border-border p-4">
|
||||
{/* Mobile back button */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="lg:hidden"
|
||||
onClick={onBack}
|
||||
>
|
||||
<ChevronLeft className="mr-1 h-4 w-4" />
|
||||
Back to list
|
||||
</Button>
|
||||
|
||||
{/* Detail Header */}
|
||||
<div className="border-b border-border pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-bold">
|
||||
{agent.name}{" "}
|
||||
<span className="font-normal text-muted-foreground">
|
||||
→ You
|
||||
</span>
|
||||
</h3>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatRelativeTime(agent.updatedAt)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Task:{" "}
|
||||
{agent.taskId ? (
|
||||
<Link
|
||||
to="/initiatives"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
{agent.taskId}
|
||||
</Link>
|
||||
) : (
|
||||
"\u2014"
|
||||
)}
|
||||
</p>
|
||||
{agent.taskId && (
|
||||
<Link
|
||||
to="/initiatives"
|
||||
className="mt-1 inline-block text-xs text-primary hover:underline"
|
||||
>
|
||||
View in context →
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Question Form or Notification Content */}
|
||||
{isLoadingQuestions && (
|
||||
<div className="py-4 text-center text-sm text-muted-foreground">
|
||||
Loading questions...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{questionsError && (
|
||||
<div className="py-4 text-center text-sm text-destructive">
|
||||
Failed to load questions: {questionsError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{questions && questions.length > 0 && (
|
||||
<QuestionForm
|
||||
questions={questions}
|
||||
onSubmit={onSubmitAnswers}
|
||||
onCancel={onBack}
|
||||
onDismiss={onDismissQuestions}
|
||||
isSubmitting={isSubmitting}
|
||||
isDismissing={isDismissingQuestions}
|
||||
/>
|
||||
)}
|
||||
|
||||
{submitError && (
|
||||
<p className="text-sm text-destructive">Error: {submitError}</p>
|
||||
)}
|
||||
|
||||
{/* Notification message (no questions / requiresResponse=false) */}
|
||||
{message && !message.requiresResponse && !isLoadingQuestions && (
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm">{message.content}</p>
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onDismissMessage}
|
||||
disabled={isDismissingMessage}
|
||||
>
|
||||
{isDismissingMessage ? "Dismissing..." : "Dismiss"}
|
||||
</Button>
|
||||
</div>
|
||||
{dismissMessageError && (
|
||||
<p className="text-sm text-destructive">
|
||||
Error: {dismissMessageError}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* No questions and requires response -- message content only */}
|
||||
{message &&
|
||||
message.requiresResponse &&
|
||||
questions &&
|
||||
questions.length === 0 &&
|
||||
!isLoadingQuestions && (
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm">{message.content}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Waiting for structured questions...
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -49,6 +49,7 @@ export function InboxList({
|
||||
const [sort, setSort] = useState<SortValue>("newest");
|
||||
|
||||
// Join agents with their latest message (match message.senderId to agent.id)
|
||||
// Also include agents with waiting_for_input status even if they don't have messages
|
||||
const joined = useMemo(() => {
|
||||
const latestByAgent = new Map<string, Message>();
|
||||
|
||||
@@ -64,7 +65,19 @@ export function InboxList({
|
||||
for (const agent of agents) {
|
||||
const msg = latestByAgent.get(agent.id);
|
||||
if (msg) {
|
||||
// Agent has a message
|
||||
entries.push({ agent, message: msg });
|
||||
} else if (agent.status === 'waiting_for_input') {
|
||||
// Agent is waiting for input but has no message - create a placeholder message for questions
|
||||
const placeholderMessage: Message = {
|
||||
id: `questions-${agent.id}`,
|
||||
senderId: agent.id,
|
||||
content: "Agent has questions that need answers",
|
||||
requiresResponse: true,
|
||||
status: "pending",
|
||||
createdAt: agent.updatedAt, // Use agent's updated time
|
||||
};
|
||||
entries.push({ agent, message: placeholderMessage });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -25,10 +25,8 @@ export function InitiativeHeader({
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [editIds, setEditIds] = useState<string[]>([]);
|
||||
|
||||
const utils = trpc.useUtils();
|
||||
const updateMutation = trpc.updateInitiativeProjects.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.getInitiative.invalidate({ id: initiative.id });
|
||||
setEditing(false);
|
||||
toast.success("Projects updated");
|
||||
},
|
||||
|
||||
@@ -3,14 +3,14 @@ import { ChevronDown, ChevronRight } from "lucide-react";
|
||||
import { StatusBadge } from "@/components/StatusBadge";
|
||||
import { DependencyIndicator } from "@/components/DependencyIndicator";
|
||||
import { TaskRow, type SerializedTask } from "@/components/TaskRow";
|
||||
import { PhaseContentEditor } from "@/components/editor/PhaseContentEditor";
|
||||
|
||||
/** Phase shape as returned by tRPC (Date fields serialized to string over JSON) */
|
||||
interface SerializedPhase {
|
||||
id: string;
|
||||
initiativeId: string;
|
||||
number: number;
|
||||
name: string;
|
||||
description: string | null;
|
||||
content: string | null;
|
||||
status: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
@@ -59,9 +59,9 @@ export function PhaseAccordion({
|
||||
<ChevronRight className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
|
||||
{/* Phase number + name */}
|
||||
{/* Phase name */}
|
||||
<span className="min-w-0 flex-1 truncate font-medium">
|
||||
Phase {phase.number}: {phase.name}
|
||||
{phase.name}
|
||||
</span>
|
||||
|
||||
{/* Task count */}
|
||||
@@ -82,9 +82,10 @@ export function PhaseAccordion({
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Expanded task list */}
|
||||
{/* Expanded content editor + task list */}
|
||||
{expanded && (
|
||||
<div className="pb-3 pl-10 pr-4">
|
||||
<PhaseContentEditor phaseId={phase.id} initiativeId={phase.initiativeId} />
|
||||
{tasks.map((entry, idx) => (
|
||||
<TaskRow
|
||||
key={entry.task.id}
|
||||
|
||||
@@ -14,14 +14,18 @@ interface QuestionFormProps {
|
||||
questions: QuestionFormQuestion[];
|
||||
onSubmit: (answers: Record<string, string>) => void;
|
||||
onCancel: () => void;
|
||||
onDismiss?: () => void;
|
||||
isSubmitting?: boolean;
|
||||
isDismissing?: boolean;
|
||||
}
|
||||
|
||||
export function QuestionForm({
|
||||
questions,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
onDismiss,
|
||||
isSubmitting = false,
|
||||
isDismissing = false,
|
||||
}: QuestionFormProps) {
|
||||
const [answers, setAnswers] = useState<Record<string, string>>(() => {
|
||||
const initial: Record<string, string> = {};
|
||||
@@ -75,13 +79,22 @@ export function QuestionForm({
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onCancel}
|
||||
disabled={isSubmitting}
|
||||
disabled={isSubmitting || isDismissing}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
{onDismiss && (
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={onDismiss}
|
||||
disabled={isSubmitting || isDismissing}
|
||||
>
|
||||
{isDismissing ? "Dismissing..." : "Dismiss"}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
disabled={!allAnswered || isSubmitting}
|
||||
disabled={!allAnswered || isSubmitting || isDismissing}
|
||||
>
|
||||
{isSubmitting ? "Sending..." : "Send Answers"}
|
||||
</Button>
|
||||
|
||||
@@ -26,11 +26,8 @@ export function RegisterProjectDialog({
|
||||
const [url, setUrl] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const utils = trpc.useUtils();
|
||||
|
||||
const registerMutation = trpc.registerProject.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.listProjects.invalidate();
|
||||
onOpenChange(false);
|
||||
toast.success("Project registered");
|
||||
},
|
||||
|
||||
@@ -9,6 +9,7 @@ const statusStyles: Record<string, string> = {
|
||||
|
||||
// Phase statuses
|
||||
pending: "bg-gray-100 text-gray-800 hover:bg-gray-100/80 border-gray-200",
|
||||
approved: "bg-amber-100 text-amber-800 hover:bg-amber-100/80 border-amber-200",
|
||||
in_progress: "bg-blue-100 text-blue-800 hover:bg-blue-100/80 border-blue-200",
|
||||
blocked: "bg-red-100 text-red-800 hover:bg-red-100/80 border-red-200",
|
||||
};
|
||||
|
||||
238
packages/web/src/components/editor/BlockDragHandle.tsx
Normal file
238
packages/web/src/components/editor/BlockDragHandle.tsx
Normal file
@@ -0,0 +1,238 @@
|
||||
import { useState, useRef, useCallback } from "react";
|
||||
import type { Editor } from "@tiptap/react";
|
||||
import { GripVertical, Plus } from "lucide-react";
|
||||
import { NodeSelection, TextSelection } from "@tiptap/pm/state";
|
||||
import { Fragment, Slice, type Node as PmNode } from "@tiptap/pm/model";
|
||||
import {
|
||||
blockSelectionKey,
|
||||
getBlockRange,
|
||||
} from "./BlockSelectionExtension";
|
||||
|
||||
interface BlockDragHandleProps {
|
||||
editor: Editor | null;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function BlockDragHandle({ editor, children }: BlockDragHandleProps) {
|
||||
const blockIndexRef = useRef<number | null>(null);
|
||||
const savedBlockSelRef = useRef<{
|
||||
anchorIndex: number;
|
||||
headIndex: number;
|
||||
} | null>(null);
|
||||
const blockElRef = useRef<HTMLElement | null>(null);
|
||||
const [handlePos, setHandlePos] = useState<{
|
||||
top: number;
|
||||
height: number;
|
||||
} | null>(null);
|
||||
|
||||
// Track which block the mouse is over
|
||||
const onMouseMove = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
// If hovering the handle itself, keep current position
|
||||
if ((e.target as HTMLElement).closest("[data-block-handle-row]")) return;
|
||||
|
||||
const editorEl = (e.currentTarget as HTMLElement).querySelector(
|
||||
".ProseMirror",
|
||||
);
|
||||
if (!editorEl || !editor) return;
|
||||
|
||||
// Walk from event target up to a direct child of .ProseMirror
|
||||
let target = e.target as HTMLElement;
|
||||
while (
|
||||
target &&
|
||||
target !== editorEl &&
|
||||
target.parentElement !== editorEl
|
||||
) {
|
||||
target = target.parentElement!;
|
||||
}
|
||||
|
||||
if (
|
||||
target &&
|
||||
target !== editorEl &&
|
||||
target.parentElement === editorEl
|
||||
) {
|
||||
blockElRef.current = target;
|
||||
const editorRect = editorEl.getBoundingClientRect();
|
||||
const blockRect = target.getBoundingClientRect();
|
||||
setHandlePos({
|
||||
top: blockRect.top - editorRect.top,
|
||||
height: blockRect.height,
|
||||
});
|
||||
|
||||
// Track top-level block index for block selection
|
||||
try {
|
||||
const pos = editor.view.posAtDOM(target, 0);
|
||||
blockIndexRef.current = editor.view.state.doc
|
||||
.resolve(pos)
|
||||
.index(0);
|
||||
} catch {
|
||||
blockIndexRef.current = null;
|
||||
}
|
||||
}
|
||||
// Don't clear -- only onMouseLeave clears
|
||||
},
|
||||
[editor],
|
||||
);
|
||||
|
||||
const onMouseLeave = useCallback(() => {
|
||||
setHandlePos(null);
|
||||
blockElRef.current = null;
|
||||
blockIndexRef.current = null;
|
||||
}, []);
|
||||
|
||||
// Click on drag handle -> select block (Shift+click extends)
|
||||
const onHandleClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
if (!editor) return;
|
||||
const idx = blockIndexRef.current;
|
||||
if (idx == null) return;
|
||||
|
||||
// Use saved state from mousedown (PM may have cleared it due to focus change)
|
||||
const existing = savedBlockSelRef.current;
|
||||
|
||||
let newSel;
|
||||
if (e.shiftKey && existing) {
|
||||
newSel = { anchorIndex: existing.anchorIndex, headIndex: idx };
|
||||
} else {
|
||||
newSel = { anchorIndex: idx, headIndex: idx };
|
||||
}
|
||||
|
||||
const tr = editor.view.state.tr.setMeta(blockSelectionKey, newSel);
|
||||
tr.setMeta("blockSelectionInternal", true);
|
||||
editor.view.dispatch(tr);
|
||||
// Refocus editor so Shift+Arrow keys reach PM's handleKeyDown
|
||||
editor.view.focus();
|
||||
},
|
||||
[editor],
|
||||
);
|
||||
|
||||
// Add a new empty paragraph below the hovered block
|
||||
const onHandleAdd = useCallback(() => {
|
||||
if (!editor || !blockElRef.current) return;
|
||||
const view = editor.view;
|
||||
try {
|
||||
const pos = view.posAtDOM(blockElRef.current, 0);
|
||||
const $pos = view.state.doc.resolve(pos);
|
||||
const after = $pos.after($pos.depth);
|
||||
const paragraph = view.state.schema.nodes.paragraph.create();
|
||||
const tr = view.state.tr.insert(after, paragraph);
|
||||
// Place cursor inside the new paragraph
|
||||
tr.setSelection(TextSelection.create(tr.doc, after + 1));
|
||||
view.dispatch(tr);
|
||||
view.focus();
|
||||
} catch {
|
||||
// posAtDOM can throw if the element isn't in the editor
|
||||
}
|
||||
}, [editor]);
|
||||
|
||||
// Initiate ProseMirror-native drag when handle is dragged
|
||||
const onHandleDragStart = useCallback(
|
||||
(e: React.DragEvent) => {
|
||||
if (!editor || !blockElRef.current) return;
|
||||
|
||||
const view = editor.view;
|
||||
const el = blockElRef.current;
|
||||
// Use saved state from mousedown (PM may have cleared it due to focus change)
|
||||
const bsel = savedBlockSelRef.current;
|
||||
|
||||
try {
|
||||
// Multi-block drag: if block selection is active and hovered block is in range
|
||||
if (bsel && blockIndexRef.current != null) {
|
||||
const from = Math.min(bsel.anchorIndex, bsel.headIndex);
|
||||
const to = Math.max(bsel.anchorIndex, bsel.headIndex);
|
||||
|
||||
if (
|
||||
blockIndexRef.current >= from &&
|
||||
blockIndexRef.current <= to
|
||||
) {
|
||||
const blockRange = getBlockRange(view.state, bsel);
|
||||
if (blockRange) {
|
||||
const nodes: PmNode[] = [];
|
||||
let idx = 0;
|
||||
view.state.doc.forEach((node) => {
|
||||
if (idx >= from && idx <= to) nodes.push(node);
|
||||
idx++;
|
||||
});
|
||||
|
||||
const sel = TextSelection.create(
|
||||
view.state.doc,
|
||||
blockRange.fromPos,
|
||||
blockRange.toPos,
|
||||
);
|
||||
const tr = view.state.tr.setSelection(sel);
|
||||
tr.setMeta("blockSelectionInternal", true);
|
||||
view.dispatch(tr);
|
||||
|
||||
view.dragging = {
|
||||
slice: new Slice(Fragment.from(nodes), 0, 0),
|
||||
move: true,
|
||||
};
|
||||
|
||||
e.dataTransfer.effectAllowed = "move";
|
||||
e.dataTransfer.setDragImage(el, 0, 0);
|
||||
e.dataTransfer.setData("application/x-pm-drag", "true");
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Single-block drag (existing behavior)
|
||||
const pos = view.posAtDOM(el, 0);
|
||||
const $pos = view.state.doc.resolve(pos);
|
||||
const before = $pos.before($pos.depth);
|
||||
|
||||
const sel = NodeSelection.create(view.state.doc, before);
|
||||
view.dispatch(view.state.tr.setSelection(sel));
|
||||
|
||||
view.dragging = { slice: sel.content(), move: true };
|
||||
|
||||
e.dataTransfer.effectAllowed = "move";
|
||||
e.dataTransfer.setDragImage(el, 0, 0);
|
||||
e.dataTransfer.setData("application/x-pm-drag", "true");
|
||||
} catch {
|
||||
// posAtDOM can throw if the element isn't in the editor
|
||||
}
|
||||
},
|
||||
[editor],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative"
|
||||
onMouseMove={onMouseMove}
|
||||
onMouseLeave={onMouseLeave}
|
||||
>
|
||||
{handlePos && (
|
||||
<div
|
||||
data-block-handle-row
|
||||
className="absolute left-0 flex items-start z-10"
|
||||
style={{ top: handlePos.top + 1 }}
|
||||
>
|
||||
<div
|
||||
onClick={onHandleAdd}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
className="flex items-center justify-center w-5 h-6 cursor-pointer rounded hover:bg-muted"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5 text-muted-foreground/60" />
|
||||
</div>
|
||||
<div
|
||||
data-drag-handle
|
||||
draggable
|
||||
onMouseDown={() => {
|
||||
if (editor) {
|
||||
savedBlockSelRef.current =
|
||||
blockSelectionKey.getState(editor.view.state) ?? null;
|
||||
}
|
||||
}}
|
||||
onClick={onHandleClick}
|
||||
onDragStart={onHandleDragStart}
|
||||
className="flex items-center justify-center w-5 h-6 cursor-grab rounded hover:bg-muted"
|
||||
>
|
||||
<GripVertical className="h-3.5 w-3.5 text-muted-foreground/60" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useCallback } from "react";
|
||||
import { useState, useCallback, useMemo } from "react";
|
||||
import { Check, ChevronDown, ChevronRight, AlertTriangle } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { trpc } from "@/lib/trpc";
|
||||
@@ -18,33 +18,51 @@ export function ContentProposalReview({
|
||||
onDismiss,
|
||||
}: ContentProposalReviewProps) {
|
||||
const [accepted, setAccepted] = useState<Set<string>>(new Set());
|
||||
const [acceptError, setAcceptError] = useState<string | null>(null);
|
||||
const utils = trpc.useUtils();
|
||||
|
||||
const acceptMutation = trpc.acceptProposal.useMutation({
|
||||
onMutate: async ({ id }) => {
|
||||
await utils.listProposals.cancel({ agentId });
|
||||
const previousProposals = utils.listProposals.getData({ agentId });
|
||||
utils.listProposals.setData({ agentId }, (old = []) =>
|
||||
old.map(p => p.id === id ? { ...p, status: 'accepted' as const } : p)
|
||||
);
|
||||
return { previousProposals };
|
||||
},
|
||||
onSuccess: () => {
|
||||
void utils.listProposals.invalidate();
|
||||
void utils.listPages.invalidate();
|
||||
void utils.getPage.invalidate();
|
||||
void utils.listAgents.invalidate();
|
||||
setAcceptError(null);
|
||||
},
|
||||
onError: (err, _variables, context) => {
|
||||
if (context?.previousProposals) {
|
||||
utils.listProposals.setData({ agentId }, context.previousProposals);
|
||||
}
|
||||
setAcceptError(err.message);
|
||||
},
|
||||
});
|
||||
|
||||
const acceptAllMutation = trpc.acceptAllProposals.useMutation({
|
||||
onSuccess: () => {
|
||||
void utils.listProposals.invalidate();
|
||||
void utils.listPages.invalidate();
|
||||
void utils.getPage.invalidate();
|
||||
void utils.listAgents.invalidate();
|
||||
onSuccess: (result) => {
|
||||
if (result.failed > 0) {
|
||||
setAcceptError(`${result.failed} proposal(s) failed: ${result.errors.join('; ')}`);
|
||||
} else {
|
||||
setAcceptError(null);
|
||||
onDismiss();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const dismissAllMutation = trpc.dismissAllProposals.useMutation({
|
||||
onSuccess: () => {
|
||||
void utils.listProposals.invalidate();
|
||||
void utils.listAgents.invalidate();
|
||||
// Note: onDismiss() is not called here because the backend auto-dismiss
|
||||
// will set userDismissedAt when all proposals are resolved
|
||||
onMutate: async () => {
|
||||
await utils.listProposals.cancel({ agentId });
|
||||
const previousProposals = utils.listProposals.getData({ agentId });
|
||||
utils.listProposals.setData({ agentId }, []);
|
||||
return { previousProposals };
|
||||
},
|
||||
onError: (_err, _variables, context) => {
|
||||
if (context?.previousProposals) {
|
||||
utils.listProposals.setData({ agentId }, context.previousProposals);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -64,6 +82,20 @@ export function ContentProposalReview({
|
||||
dismissAllMutation.mutate({ agentId });
|
||||
}, [dismissAllMutation, agentId]);
|
||||
|
||||
// Batch-fetch page updatedAt timestamps for staleness check (eliminates N+1)
|
||||
const pageTargetIds = useMemo(() => {
|
||||
const ids = new Set<string>();
|
||||
for (const p of proposals) {
|
||||
if (p.targetType === 'page' && p.targetId) ids.add(p.targetId);
|
||||
}
|
||||
return [...ids];
|
||||
}, [proposals]);
|
||||
|
||||
const pageUpdatedAtMap = trpc.getPageUpdatedAtMap.useQuery(
|
||||
{ ids: pageTargetIds },
|
||||
{ enabled: pageTargetIds.length > 0 },
|
||||
);
|
||||
|
||||
const allAccepted = proposals.every((p) => accepted.has(p.id) || p.status === 'accepted');
|
||||
|
||||
return (
|
||||
@@ -94,6 +126,13 @@ export function ContentProposalReview({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{acceptError && (
|
||||
<div className="flex items-center gap-1.5 text-xs text-destructive bg-destructive/10 rounded px-2 py-1.5">
|
||||
<AlertTriangle className="h-3 w-3 shrink-0" />
|
||||
<span>{acceptError}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
{proposals.map((proposal) => (
|
||||
<ProposalCard
|
||||
@@ -101,6 +140,9 @@ export function ContentProposalReview({
|
||||
proposal={proposal}
|
||||
isAccepted={accepted.has(proposal.id) || proposal.status === 'accepted'}
|
||||
agentCreatedAt={agentCreatedAt}
|
||||
pageUpdatedAt={proposal.targetType === 'page' && proposal.targetId
|
||||
? pageUpdatedAtMap.data?.[proposal.targetId] ?? null
|
||||
: null}
|
||||
onAccept={() => handleAccept(proposal)}
|
||||
isAccepting={acceptMutation.isPending}
|
||||
/>
|
||||
@@ -114,6 +156,7 @@ interface ProposalCardProps {
|
||||
proposal: Proposal;
|
||||
isAccepted: boolean;
|
||||
agentCreatedAt: Date;
|
||||
pageUpdatedAt: string | null;
|
||||
onAccept: () => void;
|
||||
isAccepting: boolean;
|
||||
}
|
||||
@@ -122,17 +165,12 @@ function ProposalCard({
|
||||
proposal,
|
||||
isAccepted,
|
||||
agentCreatedAt,
|
||||
pageUpdatedAt,
|
||||
onAccept,
|
||||
isAccepting,
|
||||
}: ProposalCardProps) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
// Check if target page was modified since agent started (page proposals only)
|
||||
const pageQuery = trpc.getPage.useQuery(
|
||||
{ id: proposal.targetId ?? '' },
|
||||
{ enabled: proposal.targetType === 'page' && !!proposal.targetId },
|
||||
);
|
||||
const pageUpdatedAt = pageQuery.data?.updatedAt;
|
||||
const isStale =
|
||||
proposal.targetType === 'page' &&
|
||||
pageUpdatedAt && new Date(pageUpdatedAt) > agentCreatedAt;
|
||||
|
||||
@@ -7,16 +7,9 @@ import { TiptapEditor } from "./TiptapEditor";
|
||||
import { PageTitleProvider } from "./PageTitleContext";
|
||||
import { PageTree } from "./PageTree";
|
||||
import { RefineAgentPanel } from "./RefineAgentPanel";
|
||||
import { DeleteSubpageDialog } from "./DeleteSubpageDialog";
|
||||
import { Skeleton } from "@/components/Skeleton";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
|
||||
interface ContentTabProps {
|
||||
initiativeId: string;
|
||||
@@ -30,30 +23,14 @@ interface DeleteConfirmation {
|
||||
|
||||
export function ContentTab({ initiativeId, initiativeName }: ContentTabProps) {
|
||||
const utils = trpc.useUtils();
|
||||
const handleSaved = useCallback(() => {
|
||||
void utils.listPages.invalidate({ initiativeId });
|
||||
}, [utils, initiativeId]);
|
||||
const { save, flush, isSaving } = useAutoSave({ onSaved: handleSaved });
|
||||
const { save, flush, isSaving } = useAutoSave();
|
||||
|
||||
// Get or create root page
|
||||
const rootPageQuery = trpc.getRootPage.useQuery({ initiativeId });
|
||||
const allPagesQuery = trpc.listPages.useQuery({ initiativeId });
|
||||
const createPageMutation = trpc.createPage.useMutation({
|
||||
onSuccess: () => {
|
||||
void utils.listPages.invalidate({ initiativeId });
|
||||
},
|
||||
});
|
||||
const deletePageMutation = trpc.deletePage.useMutation({
|
||||
onSuccess: () => {
|
||||
void utils.listPages.invalidate({ initiativeId });
|
||||
},
|
||||
});
|
||||
|
||||
const updateInitiativeMutation = trpc.updateInitiative.useMutation({
|
||||
onSuccess: () => {
|
||||
void utils.getInitiative.invalidate({ id: initiativeId });
|
||||
},
|
||||
});
|
||||
const createPageMutation = trpc.createPage.useMutation();
|
||||
const deletePageMutation = trpc.deletePage.useMutation();
|
||||
const updateInitiativeMutation = trpc.updateInitiative.useMutation();
|
||||
const initiativeNameTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const pendingInitiativeNameRef = useRef<string | null>(null);
|
||||
|
||||
@@ -158,7 +135,7 @@ export function ContentTab({ initiativeId, initiativeName }: ContentTabProps) {
|
||||
setActivePageId(pageId);
|
||||
}, []);
|
||||
|
||||
// Slash command: /subpage — creates a page and inserts a link at cursor
|
||||
// Slash command: /subpage -- creates a page and inserts a link at cursor
|
||||
const handleSubpageCreate = useCallback(
|
||||
async (editor: Editor) => {
|
||||
editorRef.current = editor;
|
||||
@@ -193,7 +170,7 @@ export function ContentTab({ initiativeId, initiativeName }: ContentTabProps) {
|
||||
const allPages = allPagesQuery.data ?? [];
|
||||
const exists = allPages.some((p) => p.id === pageId);
|
||||
if (!exists) {
|
||||
// Page doesn't exist — redo the deletion so the stale link is removed
|
||||
// Page doesn't exist -- redo the deletion so the stale link is removed
|
||||
redo();
|
||||
return;
|
||||
}
|
||||
@@ -228,7 +205,7 @@ export function ContentTab({ initiativeId, initiativeName }: ContentTabProps) {
|
||||
);
|
||||
}
|
||||
|
||||
// Error — server likely needs restart or migration hasn't applied
|
||||
// Error -- server likely needs restart or migration hasn't applied
|
||||
if (rootPageQuery.isError) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center gap-3 py-12 text-center">
|
||||
@@ -271,7 +248,7 @@ export function ContentTab({ initiativeId, initiativeName }: ContentTabProps) {
|
||||
|
||||
{/* Editor area */}
|
||||
<div className="flex-1 min-w-0">
|
||||
{/* Refine agent panel — sits above editor */}
|
||||
{/* Refine agent panel -- sits above editor */}
|
||||
<RefineAgentPanel initiativeId={initiativeId} />
|
||||
|
||||
{resolvedActivePageId && (
|
||||
@@ -295,7 +272,7 @@ export function ContentTab({ initiativeId, initiativeName }: ContentTabProps) {
|
||||
{activePageQuery.isSuccess && (
|
||||
<TiptapEditor
|
||||
key={resolvedActivePageId}
|
||||
pageId={resolvedActivePageId}
|
||||
entityId={resolvedActivePageId}
|
||||
content={activePageQuery.data?.content ?? null}
|
||||
onUpdate={handleEditorUpdate}
|
||||
onPageLinkClick={handleNavigate}
|
||||
@@ -318,30 +295,15 @@ export function ContentTab({ initiativeId, initiativeName }: ContentTabProps) {
|
||||
</div>
|
||||
|
||||
{/* Delete subpage confirmation dialog */}
|
||||
<Dialog
|
||||
<DeleteSubpageDialog
|
||||
open={deleteConfirm !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) dismissDeleteConfirm();
|
||||
}}
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete subpage?</DialogTitle>
|
||||
<DialogDescription>
|
||||
You removed the link to “{allPages.find((p) => p.id === deleteConfirm?.pageId)?.title ?? "Untitled"}”.
|
||||
Do you also want to delete the subpage and all its content?
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={dismissDeleteConfirm}>
|
||||
Keep subpage
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={confirmDeleteSubpage}>
|
||||
Delete subpage
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
pageName={
|
||||
allPages.find((p) => p.id === deleteConfirm?.pageId)?.title ??
|
||||
"Untitled"
|
||||
}
|
||||
onConfirm={confirmDeleteSubpage}
|
||||
onCancel={dismissDeleteConfirm}
|
||||
/>
|
||||
</PageTitleProvider>
|
||||
</>
|
||||
);
|
||||
|
||||
50
packages/web/src/components/editor/DeleteSubpageDialog.tsx
Normal file
50
packages/web/src/components/editor/DeleteSubpageDialog.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
|
||||
interface DeleteSubpageDialogProps {
|
||||
open: boolean;
|
||||
pageName: string;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export function DeleteSubpageDialog({
|
||||
open,
|
||||
pageName,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}: DeleteSubpageDialogProps) {
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(isOpen) => {
|
||||
if (!isOpen) onCancel();
|
||||
}}
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete subpage?</DialogTitle>
|
||||
<DialogDescription>
|
||||
You removed the link to “{pageName}”. Do you also want
|
||||
to delete the subpage and all its content?
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onCancel}>
|
||||
Keep subpage
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={onConfirm}>
|
||||
Delete subpage
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { Extension } from "@tiptap/react";
|
||||
import { Plugin, PluginKey } from "@tiptap/pm/state";
|
||||
import type { MutableRefObject } from "react";
|
||||
|
||||
export function createPageLinkDeletionDetector(
|
||||
onPageLinkDeletedRef: MutableRefObject<
|
||||
((pageId: string, redo: () => void) => void) | undefined
|
||||
>,
|
||||
) {
|
||||
return Extension.create({
|
||||
name: "pageLinkDeletionDetector",
|
||||
addStorage() {
|
||||
return { skipDetection: false };
|
||||
},
|
||||
addProseMirrorPlugins() {
|
||||
const tiptapEditor = this.editor;
|
||||
return [
|
||||
new Plugin({
|
||||
key: new PluginKey("pageLinkDeletionDetector"),
|
||||
appendTransaction(_transactions, oldState, newState) {
|
||||
if (oldState.doc.eq(newState.doc)) return null;
|
||||
|
||||
const oldLinks = new Set<string>();
|
||||
oldState.doc.descendants((node) => {
|
||||
if (node.type.name === "pageLink" && node.attrs.pageId) {
|
||||
oldLinks.add(node.attrs.pageId);
|
||||
}
|
||||
});
|
||||
|
||||
const newLinks = new Set<string>();
|
||||
newState.doc.descendants((node) => {
|
||||
if (node.type.name === "pageLink" && node.attrs.pageId) {
|
||||
newLinks.add(node.attrs.pageId);
|
||||
}
|
||||
});
|
||||
|
||||
for (const removedPageId of oldLinks) {
|
||||
if (!newLinks.has(removedPageId)) {
|
||||
// Fire async to avoid dispatching during appendTransaction
|
||||
setTimeout(() => {
|
||||
if (
|
||||
tiptapEditor.storage.pageLinkDeletionDetector.skipDetection
|
||||
) {
|
||||
tiptapEditor.storage.pageLinkDeletionDetector.skipDetection =
|
||||
false;
|
||||
return;
|
||||
}
|
||||
// Undo the deletion immediately so the link reappears
|
||||
tiptapEditor.commands.undo();
|
||||
// Pass a redo function so the caller can re-delete if confirmed
|
||||
onPageLinkDeletedRef.current?.(removedPageId, () => {
|
||||
tiptapEditor.storage.pageLinkDeletionDetector.skipDetection =
|
||||
true;
|
||||
tiptapEditor.commands.redo();
|
||||
});
|
||||
}, 0);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
}),
|
||||
];
|
||||
},
|
||||
});
|
||||
}
|
||||
47
packages/web/src/components/editor/PhaseContentEditor.tsx
Normal file
47
packages/web/src/components/editor/PhaseContentEditor.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import { useCallback } from "react";
|
||||
import { trpc } from "@/lib/trpc";
|
||||
import { usePhaseAutoSave } from "@/hooks/usePhaseAutoSave";
|
||||
import { TiptapEditor } from "./TiptapEditor";
|
||||
import { Skeleton } from "@/components/Skeleton";
|
||||
|
||||
interface PhaseContentEditorProps {
|
||||
phaseId: string;
|
||||
initiativeId?: string;
|
||||
}
|
||||
|
||||
export function PhaseContentEditor({ phaseId }: PhaseContentEditorProps) {
|
||||
const { save, isSaving } = usePhaseAutoSave();
|
||||
|
||||
const phaseQuery = trpc.getPhase.useQuery({ id: phaseId });
|
||||
|
||||
const handleUpdate = useCallback(
|
||||
(json: string) => {
|
||||
save(phaseId, { content: json });
|
||||
},
|
||||
[phaseId, save],
|
||||
);
|
||||
|
||||
if (phaseQuery.isLoading) {
|
||||
return <Skeleton className="h-32 w-full" />;
|
||||
}
|
||||
|
||||
if (phaseQuery.isError) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mb-3">
|
||||
{isSaving && (
|
||||
<div className="flex justify-end mb-1">
|
||||
<span className="text-xs text-muted-foreground">Saving...</span>
|
||||
</div>
|
||||
)}
|
||||
<TiptapEditor
|
||||
entityId={phaseId}
|
||||
content={phaseQuery.data?.content ?? null}
|
||||
onUpdate={handleUpdate}
|
||||
enablePageLinks={false}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -12,7 +12,7 @@ interface RefineAgentPanelProps {
|
||||
|
||||
export function RefineAgentPanel({ initiativeId }: RefineAgentPanelProps) {
|
||||
// All agent logic is now encapsulated in the hook
|
||||
const { state, agent, questions, proposals, spawn, resume, dismiss, refresh } = useRefineAgent(initiativeId);
|
||||
const { state, agent, questions, proposals, spawn, resume, stop, dismiss, refresh } = useRefineAgent(initiativeId);
|
||||
|
||||
// spawn.mutate and resume.mutate are stable (ref-backed in useRefineAgent),
|
||||
// so these callbacks won't change on every render.
|
||||
@@ -87,7 +87,9 @@ export function RefineAgentPanel({ initiativeId }: RefineAgentPanelProps) {
|
||||
onCancel={() => {
|
||||
// Can't cancel mid-question — just dismiss
|
||||
}}
|
||||
onDismiss={() => stop.mutate()}
|
||||
isSubmitting={resume.isPending}
|
||||
isDismissing={stop.isPending}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -14,6 +14,7 @@ export const SlashCommands = Extension.create({
|
||||
addStorage() {
|
||||
return {
|
||||
onSubpageCreate: null as ((editor: unknown) => void) | null,
|
||||
hideSubpage: false,
|
||||
};
|
||||
},
|
||||
|
||||
@@ -36,10 +37,14 @@ export const SlashCommands = Extension.create({
|
||||
// Execute the selected command
|
||||
props.action(editor);
|
||||
},
|
||||
items: ({ query }: { query: string }): SlashCommandItem[] => {
|
||||
return slashCommandItems.filter((item) =>
|
||||
items: ({ query, editor }: { query: string; editor: ReturnType<typeof import("@tiptap/react").useEditor> }): SlashCommandItem[] => {
|
||||
let items = slashCommandItems.filter((item) =>
|
||||
item.label.toLowerCase().includes(query.toLowerCase()),
|
||||
);
|
||||
if (editor.storage.slashCommands?.hideSubpage) {
|
||||
items = items.filter((item) => !item.isSubpage);
|
||||
}
|
||||
return items;
|
||||
},
|
||||
render: () => {
|
||||
let component: ReactRenderer<SlashCommandListRef> | null = null;
|
||||
|
||||
@@ -1,25 +1,21 @@
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import { useEditor, EditorContent, Extension } from "@tiptap/react";
|
||||
import { useEffect, useRef, useCallback } from "react";
|
||||
import { useEditor, EditorContent } from "@tiptap/react";
|
||||
import type { Editor } from "@tiptap/react";
|
||||
import { GripVertical, Plus } from "lucide-react";
|
||||
import StarterKit from "@tiptap/starter-kit";
|
||||
import Placeholder from "@tiptap/extension-placeholder";
|
||||
import Link from "@tiptap/extension-link";
|
||||
import { Table, TableRow, TableCell, TableHeader } from "@tiptap/extension-table";
|
||||
import { Plugin, PluginKey, NodeSelection, TextSelection } from "@tiptap/pm/state";
|
||||
import { Fragment, Slice, type Node as PmNode } from "@tiptap/pm/model";
|
||||
import { SlashCommands } from "./SlashCommands";
|
||||
import { PageLinkExtension } from "./PageLinkExtension";
|
||||
import {
|
||||
BlockSelectionExtension,
|
||||
blockSelectionKey,
|
||||
getBlockRange,
|
||||
} from "./BlockSelectionExtension";
|
||||
import { BlockSelectionExtension } from "./BlockSelectionExtension";
|
||||
import { createPageLinkDeletionDetector } from "./PageLinkDeletionDetector";
|
||||
import { BlockDragHandle } from "./BlockDragHandle";
|
||||
|
||||
interface TiptapEditorProps {
|
||||
content: string | null;
|
||||
onUpdate: (json: string) => void;
|
||||
pageId: string;
|
||||
entityId: string;
|
||||
enablePageLinks?: boolean;
|
||||
onPageLinkClick?: (pageId: string) => void;
|
||||
onSubpageCreate?: (
|
||||
editor: Editor,
|
||||
@@ -30,7 +26,8 @@ interface TiptapEditorProps {
|
||||
export function TiptapEditor({
|
||||
content,
|
||||
onUpdate,
|
||||
pageId,
|
||||
entityId,
|
||||
enablePageLinks = true,
|
||||
onPageLinkClick,
|
||||
onSubpageCreate,
|
||||
onPageLinkDeleted,
|
||||
@@ -38,12 +35,10 @@ export function TiptapEditor({
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const onPageLinkDeletedRef = useRef(onPageLinkDeleted);
|
||||
onPageLinkDeletedRef.current = onPageLinkDeleted;
|
||||
const blockIndexRef = useRef<number | null>(null);
|
||||
const savedBlockSelRef = useRef<{ anchorIndex: number; headIndex: number } | null>(null);
|
||||
|
||||
const editor = useEditor(
|
||||
{
|
||||
extensions: [
|
||||
const pageLinkDeletionDetector = createPageLinkDeletionDetector(onPageLinkDeletedRef);
|
||||
|
||||
const baseExtensions = [
|
||||
StarterKit,
|
||||
Table.configure({ resizable: true, cellMinWidth: 50 }),
|
||||
TableRow,
|
||||
@@ -62,65 +57,16 @@ export function TiptapEditor({
|
||||
openOnClick: false,
|
||||
}),
|
||||
SlashCommands,
|
||||
PageLinkExtension,
|
||||
BlockSelectionExtension,
|
||||
// Detect pageLink node deletions by comparing old/new doc state
|
||||
Extension.create({
|
||||
name: "pageLinkDeletionDetector",
|
||||
addStorage() {
|
||||
return { skipDetection: false };
|
||||
},
|
||||
addProseMirrorPlugins() {
|
||||
const tiptapEditor = this.editor;
|
||||
return [
|
||||
new Plugin({
|
||||
key: new PluginKey("pageLinkDeletionDetector"),
|
||||
appendTransaction(_transactions, oldState, newState) {
|
||||
if (oldState.doc.eq(newState.doc)) return null;
|
||||
|
||||
const oldLinks = new Set<string>();
|
||||
oldState.doc.descendants((node) => {
|
||||
if (node.type.name === "pageLink" && node.attrs.pageId) {
|
||||
oldLinks.add(node.attrs.pageId);
|
||||
}
|
||||
});
|
||||
|
||||
const newLinks = new Set<string>();
|
||||
newState.doc.descendants((node) => {
|
||||
if (node.type.name === "pageLink" && node.attrs.pageId) {
|
||||
newLinks.add(node.attrs.pageId);
|
||||
}
|
||||
});
|
||||
|
||||
for (const removedPageId of oldLinks) {
|
||||
if (!newLinks.has(removedPageId)) {
|
||||
// Fire async to avoid dispatching during appendTransaction
|
||||
setTimeout(() => {
|
||||
if (tiptapEditor.storage.pageLinkDeletionDetector.skipDetection) {
|
||||
tiptapEditor.storage.pageLinkDeletionDetector.skipDetection = false;
|
||||
return;
|
||||
}
|
||||
// Undo the deletion immediately so the link reappears
|
||||
tiptapEditor.commands.undo();
|
||||
// Pass a redo function so the caller can re-delete if confirmed
|
||||
onPageLinkDeletedRef.current?.(
|
||||
removedPageId,
|
||||
() => {
|
||||
tiptapEditor.storage.pageLinkDeletionDetector.skipDetection = true;
|
||||
tiptapEditor.commands.redo();
|
||||
},
|
||||
);
|
||||
}, 0);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
}),
|
||||
];
|
||||
},
|
||||
}),
|
||||
],
|
||||
|
||||
const extensions = enablePageLinks
|
||||
? [...baseExtensions, PageLinkExtension, pageLinkDeletionDetector]
|
||||
: baseExtensions;
|
||||
|
||||
const editor = useEditor(
|
||||
{
|
||||
extensions,
|
||||
content: content ? JSON.parse(content) : undefined,
|
||||
onUpdate: ({ editor: e }) => {
|
||||
onUpdate(JSON.stringify(e.getJSON()));
|
||||
@@ -132,17 +78,20 @@ export function TiptapEditor({
|
||||
},
|
||||
},
|
||||
},
|
||||
[pageId],
|
||||
[entityId],
|
||||
);
|
||||
|
||||
// Wire the onSubpageCreate callback into editor storage
|
||||
useEffect(() => {
|
||||
if (editor && onSubpageCreate) {
|
||||
if (editor) {
|
||||
if (onSubpageCreate) {
|
||||
editor.storage.slashCommands.onSubpageCreate = (ed: Editor) => {
|
||||
onSubpageCreate(ed);
|
||||
};
|
||||
}
|
||||
}, [editor, onSubpageCreate]);
|
||||
editor.storage.slashCommands.hideSubpage = !enablePageLinks;
|
||||
}
|
||||
}, [editor, onSubpageCreate, enablePageLinks]);
|
||||
|
||||
// Handle page link clicks via custom event
|
||||
const handlePageLinkClick = useCallback(
|
||||
@@ -163,199 +112,11 @@ export function TiptapEditor({
|
||||
el.removeEventListener("page-link-click", handlePageLinkClick);
|
||||
}, [handlePageLinkClick]);
|
||||
|
||||
// Floating drag handle: track which block the mouse is over
|
||||
const [handlePos, setHandlePos] = useState<{ top: number; height: number } | null>(null);
|
||||
const blockElRef = useRef<HTMLElement | null>(null);
|
||||
|
||||
const onMouseMove = useCallback((e: React.MouseEvent) => {
|
||||
// If hovering the handle itself, keep current position
|
||||
if ((e.target as HTMLElement).closest("[data-block-handle-row]")) return;
|
||||
|
||||
const editorEl = containerRef.current?.querySelector(".ProseMirror");
|
||||
if (!editorEl || !editor) return;
|
||||
|
||||
// Walk from event target up to a direct child of .ProseMirror
|
||||
let target = e.target as HTMLElement;
|
||||
while (target && target !== editorEl && target.parentElement !== editorEl) {
|
||||
target = target.parentElement!;
|
||||
}
|
||||
|
||||
if (target && target !== editorEl && target.parentElement === editorEl) {
|
||||
blockElRef.current = target;
|
||||
const editorRect = editorEl.getBoundingClientRect();
|
||||
const blockRect = target.getBoundingClientRect();
|
||||
setHandlePos({
|
||||
top: blockRect.top - editorRect.top,
|
||||
height: blockRect.height,
|
||||
});
|
||||
|
||||
// Track top-level block index for block selection
|
||||
try {
|
||||
const pos = editor.view.posAtDOM(target, 0);
|
||||
blockIndexRef.current = editor.view.state.doc.resolve(pos).index(0);
|
||||
} catch {
|
||||
blockIndexRef.current = null;
|
||||
}
|
||||
}
|
||||
// Don't clear — only onMouseLeave clears
|
||||
}, [editor]);
|
||||
|
||||
const onMouseLeave = useCallback(() => {
|
||||
setHandlePos(null);
|
||||
blockElRef.current = null;
|
||||
blockIndexRef.current = null;
|
||||
}, []);
|
||||
|
||||
// Click on drag handle → select block (Shift+click extends)
|
||||
const onHandleClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
if (!editor) return;
|
||||
const idx = blockIndexRef.current;
|
||||
if (idx == null) return;
|
||||
|
||||
// Use saved state from mousedown (PM may have cleared it due to focus change)
|
||||
const existing = savedBlockSelRef.current;
|
||||
|
||||
let newSel;
|
||||
if (e.shiftKey && existing) {
|
||||
newSel = { anchorIndex: existing.anchorIndex, headIndex: idx };
|
||||
} else {
|
||||
newSel = { anchorIndex: idx, headIndex: idx };
|
||||
}
|
||||
|
||||
const tr = editor.view.state.tr.setMeta(blockSelectionKey, newSel);
|
||||
tr.setMeta("blockSelectionInternal", true);
|
||||
editor.view.dispatch(tr);
|
||||
// Refocus editor so Shift+Arrow keys reach PM's handleKeyDown
|
||||
editor.view.focus();
|
||||
},
|
||||
[editor],
|
||||
);
|
||||
|
||||
// Add a new empty paragraph below the hovered block
|
||||
const onHandleAdd = useCallback(() => {
|
||||
if (!editor || !blockElRef.current) return;
|
||||
const view = editor.view;
|
||||
try {
|
||||
const pos = view.posAtDOM(blockElRef.current, 0);
|
||||
const $pos = view.state.doc.resolve(pos);
|
||||
const after = $pos.after($pos.depth);
|
||||
const paragraph = view.state.schema.nodes.paragraph.create();
|
||||
const tr = view.state.tr.insert(after, paragraph);
|
||||
// Place cursor inside the new paragraph
|
||||
tr.setSelection(TextSelection.create(tr.doc, after + 1));
|
||||
view.dispatch(tr);
|
||||
view.focus();
|
||||
} catch {
|
||||
// posAtDOM can throw if the element isn't in the editor
|
||||
}
|
||||
}, [editor]);
|
||||
|
||||
// Initiate ProseMirror-native drag when handle is dragged
|
||||
const onHandleDragStart = useCallback(
|
||||
(e: React.DragEvent) => {
|
||||
if (!editor || !blockElRef.current) return;
|
||||
|
||||
const view = editor.view;
|
||||
const el = blockElRef.current;
|
||||
// Use saved state from mousedown (PM may have cleared it due to focus change)
|
||||
const bsel = savedBlockSelRef.current;
|
||||
|
||||
try {
|
||||
// Multi-block drag: if block selection is active and hovered block is in range
|
||||
if (bsel && blockIndexRef.current != null) {
|
||||
const from = Math.min(bsel.anchorIndex, bsel.headIndex);
|
||||
const to = Math.max(bsel.anchorIndex, bsel.headIndex);
|
||||
|
||||
if (blockIndexRef.current >= from && blockIndexRef.current <= to) {
|
||||
const blockRange = getBlockRange(view.state, bsel);
|
||||
if (blockRange) {
|
||||
const nodes: PmNode[] = [];
|
||||
let idx = 0;
|
||||
view.state.doc.forEach((node) => {
|
||||
if (idx >= from && idx <= to) nodes.push(node);
|
||||
idx++;
|
||||
});
|
||||
|
||||
const sel = TextSelection.create(
|
||||
view.state.doc,
|
||||
blockRange.fromPos,
|
||||
blockRange.toPos,
|
||||
);
|
||||
const tr = view.state.tr.setSelection(sel);
|
||||
tr.setMeta("blockSelectionInternal", true);
|
||||
view.dispatch(tr);
|
||||
|
||||
view.dragging = {
|
||||
slice: new Slice(Fragment.from(nodes), 0, 0),
|
||||
move: true,
|
||||
};
|
||||
|
||||
e.dataTransfer.effectAllowed = "move";
|
||||
e.dataTransfer.setDragImage(el, 0, 0);
|
||||
e.dataTransfer.setData("application/x-pm-drag", "true");
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Single-block drag (existing behavior)
|
||||
const pos = view.posAtDOM(el, 0);
|
||||
const $pos = view.state.doc.resolve(pos);
|
||||
const before = $pos.before($pos.depth);
|
||||
|
||||
const sel = NodeSelection.create(view.state.doc, before);
|
||||
view.dispatch(view.state.tr.setSelection(sel));
|
||||
|
||||
view.dragging = { slice: sel.content(), move: true };
|
||||
|
||||
e.dataTransfer.effectAllowed = "move";
|
||||
e.dataTransfer.setDragImage(el, 0, 0);
|
||||
e.dataTransfer.setData("application/x-pm-drag", "true");
|
||||
} catch {
|
||||
// posAtDOM can throw if the element isn't in the editor
|
||||
}
|
||||
},
|
||||
[editor],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="relative"
|
||||
onMouseMove={onMouseMove}
|
||||
onMouseLeave={onMouseLeave}
|
||||
>
|
||||
{handlePos && (
|
||||
<div
|
||||
data-block-handle-row
|
||||
className="absolute left-0 flex items-start z-10"
|
||||
style={{ top: handlePos.top + 1 }}
|
||||
>
|
||||
<div
|
||||
onClick={onHandleAdd}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
className="flex items-center justify-center w-5 h-6 cursor-pointer rounded hover:bg-muted"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5 text-muted-foreground/60" />
|
||||
</div>
|
||||
<div
|
||||
data-drag-handle
|
||||
draggable
|
||||
onMouseDown={() => {
|
||||
if (editor) {
|
||||
savedBlockSelRef.current = blockSelectionKey.getState(editor.view.state) ?? null;
|
||||
}
|
||||
}}
|
||||
onClick={onHandleClick}
|
||||
onDragStart={onHandleDragStart}
|
||||
className="flex items-center justify-center w-5 h-6 cursor-grab rounded hover:bg-muted"
|
||||
>
|
||||
<GripVertical className="h-3.5 w-3.5 text-muted-foreground/60" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div ref={containerRef}>
|
||||
<BlockDragHandle editor={editor}>
|
||||
<EditorContent editor={editor} />
|
||||
</BlockDragHandle>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -43,6 +43,12 @@ export const slashCommandItems: SlashCommandItem[] = [
|
||||
description: "Ordered list",
|
||||
action: (editor) => editor.chain().focus().toggleOrderedList().run(),
|
||||
},
|
||||
{
|
||||
label: "Inline Code",
|
||||
icon: "`c`",
|
||||
description: "Inline code (Cmd+E)",
|
||||
action: (editor) => editor.chain().focus().toggleCode().run(),
|
||||
},
|
||||
{
|
||||
label: "Code Block",
|
||||
icon: "<>",
|
||||
|
||||
@@ -1,22 +1,23 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { Loader2, Sparkles } from "lucide-react";
|
||||
import { Loader2, Plus, Sparkles } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { trpc } from "@/lib/trpc";
|
||||
import { useSpawnMutation } from "@/hooks/useSpawnMutation";
|
||||
import { ContentProposalReview } from "@/components/editor/ContentProposalReview";
|
||||
|
||||
interface BreakdownSectionProps {
|
||||
initiativeId: string;
|
||||
phasesLoaded: boolean;
|
||||
phases: Array<{ status: string }>;
|
||||
onAddPhase?: () => void;
|
||||
}
|
||||
|
||||
export function BreakdownSection({
|
||||
initiativeId,
|
||||
phasesLoaded,
|
||||
phases,
|
||||
onAddPhase,
|
||||
}: BreakdownSectionProps) {
|
||||
const utils = trpc.useUtils();
|
||||
|
||||
// Breakdown agent tracking
|
||||
const agentsQuery = trpc.listAgents.useQuery();
|
||||
const allAgents = agentsQuery.data ?? [];
|
||||
@@ -25,7 +26,7 @@ export function BreakdownSection({
|
||||
.filter(
|
||||
(a) =>
|
||||
a.mode === "breakdown" &&
|
||||
a.taskId === initiativeId &&
|
||||
a.initiativeId === initiativeId &&
|
||||
["running", "waiting_for_input", "idle"].includes(a.status),
|
||||
)
|
||||
.sort(
|
||||
@@ -37,27 +38,55 @@ export function BreakdownSection({
|
||||
|
||||
const isBreakdownRunning = breakdownAgent?.status === "running";
|
||||
|
||||
// Query proposals when we have a completed breakdown agent
|
||||
const proposalsQuery = trpc.listProposals.useQuery(
|
||||
{ agentId: breakdownAgent?.id ?? "" },
|
||||
{ enabled: !!breakdownAgent && breakdownAgent.status === "idle" },
|
||||
);
|
||||
const pendingProposals = useMemo(
|
||||
() => (proposalsQuery.data ?? []).filter((p) => p.status === "pending"),
|
||||
[proposalsQuery.data],
|
||||
);
|
||||
|
||||
const dismissMutation = trpc.dismissAgent.useMutation();
|
||||
|
||||
const breakdownSpawn = useSpawnMutation(trpc.spawnArchitectBreakdown.useMutation, {
|
||||
onSuccess: () => {
|
||||
void utils.listAgents.invalidate();
|
||||
},
|
||||
showToast: false, // We show our own error UI
|
||||
showToast: false,
|
||||
});
|
||||
|
||||
const handleBreakdown = useCallback(() => {
|
||||
breakdownSpawn.spawn({ initiativeId });
|
||||
}, [initiativeId, breakdownSpawn]);
|
||||
|
||||
// Don't render if we have phases
|
||||
if (phasesLoaded && phases.length > 0) {
|
||||
return null;
|
||||
}
|
||||
const handleDismiss = useCallback(() => {
|
||||
if (!breakdownAgent) return;
|
||||
dismissMutation.mutate({ id: breakdownAgent.id });
|
||||
}, [breakdownAgent, dismissMutation]);
|
||||
|
||||
// Don't render during loading
|
||||
if (!phasesLoaded) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// If phases exist and no pending proposals to review, hide section
|
||||
if (phases.length > 0 && pendingProposals.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Show proposal review when breakdown agent completed with pending proposals
|
||||
if (breakdownAgent?.status === "idle" && pendingProposals.length > 0) {
|
||||
return (
|
||||
<div className="py-4">
|
||||
<ContentProposalReview
|
||||
proposals={pendingProposals}
|
||||
agentCreatedAt={new Date(breakdownAgent.createdAt)}
|
||||
agentId={breakdownAgent.id}
|
||||
onDismiss={handleDismiss}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="py-8 text-center space-y-3">
|
||||
<p className="text-muted-foreground">No phases yet</p>
|
||||
@@ -67,6 +96,7 @@ export function BreakdownSection({
|
||||
Breaking down initiative...
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@@ -79,6 +109,21 @@ export function BreakdownSection({
|
||||
? "Starting..."
|
||||
: "Break Down Initiative"}
|
||||
</Button>
|
||||
{onAddPhase && (
|
||||
<>
|
||||
<span className="text-xs text-muted-foreground">or</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onAddPhase}
|
||||
className="gap-1.5"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
Add Phase
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{breakdownSpawn.isError && (
|
||||
<p className="text-xs text-destructive">
|
||||
|
||||
@@ -21,9 +21,8 @@ export interface FlatTaskEntry {
|
||||
export interface PhaseData {
|
||||
id: string;
|
||||
initiativeId: string;
|
||||
number: number;
|
||||
name: string;
|
||||
description: string | null;
|
||||
content: string | null;
|
||||
status: string;
|
||||
createdAt: string | Date;
|
||||
updatedAt: string | Date;
|
||||
|
||||
@@ -1,59 +1,72 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { Loader2, Plus, Sparkles } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { trpc } from "@/lib/trpc";
|
||||
|
||||
interface PhaseActionsProps {
|
||||
initiativeId: string;
|
||||
phases: Array<{ id: string; status: string }>;
|
||||
onAddPhase: () => void;
|
||||
phasesWithoutTasks: string[];
|
||||
decomposeAgentByPhase: Map<string, { id: string; status: string }>;
|
||||
}
|
||||
|
||||
export function PhaseActions({ initiativeId, phases }: PhaseActionsProps) {
|
||||
const queuePhaseMutation = trpc.queuePhase.useMutation();
|
||||
export function PhaseActions({
|
||||
onAddPhase,
|
||||
phasesWithoutTasks,
|
||||
decomposeAgentByPhase,
|
||||
}: PhaseActionsProps) {
|
||||
const decomposeMutation = trpc.spawnArchitectDecompose.useMutation();
|
||||
|
||||
// Breakdown agent tracking for status display
|
||||
const agentsQuery = trpc.listAgents.useQuery();
|
||||
const allAgents = agentsQuery.data ?? [];
|
||||
const breakdownAgent = useMemo(() => {
|
||||
const candidates = allAgents
|
||||
.filter(
|
||||
(a) =>
|
||||
a.mode === "breakdown" &&
|
||||
a.taskId === initiativeId &&
|
||||
["running", "waiting_for_input", "idle"].includes(a.status),
|
||||
)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
|
||||
// Phases eligible for breakdown: no tasks AND no active decompose agent
|
||||
const eligiblePhaseIds = useMemo(
|
||||
() => phasesWithoutTasks.filter((id) => !decomposeAgentByPhase.has(id)),
|
||||
[phasesWithoutTasks, decomposeAgentByPhase],
|
||||
);
|
||||
return candidates[0] ?? null;
|
||||
}, [allAgents, initiativeId]);
|
||||
|
||||
const isBreakdownRunning = breakdownAgent?.status === "running";
|
||||
const hasPendingPhases = phases.some((p) => p.status === "pending");
|
||||
|
||||
const handleQueueAll = useCallback(() => {
|
||||
const pendingPhases = phases.filter((p) => p.status === "pending");
|
||||
for (const phase of pendingPhases) {
|
||||
queuePhaseMutation.mutate({ phaseId: phase.id });
|
||||
// Count of phases currently being decomposed
|
||||
const activeDecomposeCount = useMemo(() => {
|
||||
let count = 0;
|
||||
for (const [, agent] of decomposeAgentByPhase) {
|
||||
if (agent.status === "running" || agent.status === "waiting_for_input") {
|
||||
count++;
|
||||
}
|
||||
}, [phases, queuePhaseMutation]);
|
||||
}
|
||||
return count;
|
||||
}, [decomposeAgentByPhase]);
|
||||
|
||||
const handleBreakdownAll = useCallback(() => {
|
||||
for (const phaseId of eligiblePhaseIds) {
|
||||
decomposeMutation.mutate({ phaseId });
|
||||
}
|
||||
}, [eligiblePhaseIds, decomposeMutation]);
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
{isBreakdownRunning && (
|
||||
{activeDecomposeCount > 0 && (
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
Breaking down...
|
||||
Decomposing ({activeDecomposeCount})
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={onAddPhase}
|
||||
title="Add phase"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={!hasPendingPhases}
|
||||
onClick={handleQueueAll}
|
||||
disabled={eligiblePhaseIds.length === 0}
|
||||
onClick={handleBreakdownAll}
|
||||
className="gap-1.5"
|
||||
>
|
||||
Queue All
|
||||
<Sparkles className="h-3.5 w-3.5" />
|
||||
Breakdown All
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
369
packages/web/src/components/execution/PhaseDetailPanel.tsx
Normal file
369
packages/web/src/components/execution/PhaseDetailPanel.tsx
Normal file
@@ -0,0 +1,369 @@
|
||||
import { useEffect, useState, useRef, useMemo, useCallback } from "react";
|
||||
import { Loader2, MoreHorizontal, Plus, Sparkles, Trash2, X } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { trpc } from "@/lib/trpc";
|
||||
import { StatusBadge } from "@/components/StatusBadge";
|
||||
import { TaskRow, type SerializedTask } from "@/components/TaskRow";
|
||||
import { PhaseContentEditor } from "@/components/editor/PhaseContentEditor";
|
||||
import { ContentProposalReview } from "@/components/editor/ContentProposalReview";
|
||||
import { Skeleton } from "@/components/Skeleton";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { sortByPriorityAndQueueTime } from "@codewalk-district/shared";
|
||||
import { useExecutionContext, type FlatTaskEntry } from "./ExecutionContext";
|
||||
|
||||
interface PhaseDetailPanelProps {
|
||||
phase: {
|
||||
id: string;
|
||||
initiativeId: string;
|
||||
name: string;
|
||||
content: string | null;
|
||||
status: string;
|
||||
};
|
||||
phases: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
status: string;
|
||||
}>;
|
||||
displayIndex: number;
|
||||
allDisplayIndices: Map<string, number>;
|
||||
initiativeId: string;
|
||||
tasks: SerializedTask[];
|
||||
tasksLoading: boolean;
|
||||
onDelete?: () => void;
|
||||
decomposeAgent: {
|
||||
id: string;
|
||||
status: string;
|
||||
createdAt: string | Date;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export function PhaseDetailPanel({
|
||||
phase,
|
||||
phases,
|
||||
displayIndex,
|
||||
allDisplayIndices,
|
||||
initiativeId,
|
||||
tasks,
|
||||
tasksLoading,
|
||||
onDelete,
|
||||
decomposeAgent,
|
||||
}: PhaseDetailPanelProps) {
|
||||
const { setSelectedTaskId, handleTaskCounts, handleRegisterTasks } =
|
||||
useExecutionContext();
|
||||
const [isEditingTitle, setIsEditingTitle] = useState(false);
|
||||
const [editName, setEditName] = useState(phase.name);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const updatePhase = trpc.updatePhase.useMutation();
|
||||
|
||||
function startEditing() {
|
||||
setEditName(phase.name);
|
||||
setIsEditingTitle(true);
|
||||
setTimeout(() => inputRef.current?.select(), 0);
|
||||
}
|
||||
|
||||
function saveTitle() {
|
||||
const trimmed = editName.trim();
|
||||
if (!trimmed || trimmed === phase.name) {
|
||||
setEditName(phase.name);
|
||||
setIsEditingTitle(false);
|
||||
return;
|
||||
}
|
||||
updatePhase.mutate(
|
||||
{ id: phase.id, name: trimmed },
|
||||
{
|
||||
onSuccess: () => {
|
||||
setIsEditingTitle(false);
|
||||
toast.success("Phase renamed");
|
||||
},
|
||||
onError: () => {
|
||||
setEditName(phase.name);
|
||||
setIsEditingTitle(false);
|
||||
toast.error("Failed to rename phase");
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function cancelEditing() {
|
||||
setEditName(phase.name);
|
||||
setIsEditingTitle(false);
|
||||
}
|
||||
|
||||
const addDependency = trpc.createPhaseDependency.useMutation({
|
||||
onSuccess: () => toast.success("Dependency added"),
|
||||
onError: () => toast.error("Failed to add dependency"),
|
||||
});
|
||||
|
||||
const removeDependency = trpc.removePhaseDependency.useMutation({
|
||||
onSuccess: () => toast.success("Dependency removed"),
|
||||
onError: () => toast.error("Failed to remove dependency"),
|
||||
});
|
||||
|
||||
const depsQuery = trpc.getPhaseDependencies.useQuery({ phaseId: phase.id });
|
||||
const dependencyIds = depsQuery.data?.dependencies ?? [];
|
||||
|
||||
// Resolve dependency IDs to phase objects
|
||||
const resolvedDeps = dependencyIds
|
||||
.map((depId) => phases.find((p) => p.id === depId))
|
||||
.filter(Boolean) as Array<{ id: string; name: string; status: string }>;
|
||||
|
||||
// Phases available to add as dependencies (exclude self + already-added)
|
||||
const availableDeps = useMemo(
|
||||
() => phases.filter((p) => p.id !== phase.id && !dependencyIds.includes(p.id)),
|
||||
[phases, phase.id, dependencyIds],
|
||||
);
|
||||
|
||||
// Propagate task counts and entries to ExecutionContext
|
||||
useEffect(() => {
|
||||
const complete = tasks.filter((t) => t.status === "completed").length;
|
||||
handleTaskCounts(phase.id, { complete, total: tasks.length });
|
||||
|
||||
const entries: FlatTaskEntry[] = tasks.map((task) => ({
|
||||
task,
|
||||
phaseName: `Phase ${displayIndex}: ${phase.name}`,
|
||||
agentName: null,
|
||||
blockedBy: [],
|
||||
dependents: [],
|
||||
}));
|
||||
handleRegisterTasks(phase.id, entries);
|
||||
}, [tasks, phase.id, displayIndex, phase.name, handleTaskCounts, handleRegisterTasks]);
|
||||
|
||||
// --- Proposals for decompose agent ---
|
||||
const proposalsQuery = trpc.listProposals.useQuery(
|
||||
{ agentId: decomposeAgent?.id ?? "" },
|
||||
{ enabled: !!decomposeAgent && decomposeAgent.status === "idle" },
|
||||
);
|
||||
const pendingProposals = useMemo(
|
||||
() => (proposalsQuery.data ?? []).filter((p) => p.status === "pending"),
|
||||
[proposalsQuery.data],
|
||||
);
|
||||
|
||||
// --- Decompose spawn ---
|
||||
const decomposeMutation = trpc.spawnArchitectDecompose.useMutation();
|
||||
|
||||
const handleDecompose = useCallback(() => {
|
||||
decomposeMutation.mutate({ phaseId: phase.id });
|
||||
}, [phase.id, decomposeMutation]);
|
||||
|
||||
// --- Dismiss handler for proposal review ---
|
||||
const dismissMutation = trpc.dismissAgent.useMutation();
|
||||
const handleDismissDecompose = useCallback(() => {
|
||||
if (!decomposeAgent) return;
|
||||
dismissMutation.mutate({ id: decomposeAgent.id });
|
||||
}, [decomposeAgent, dismissMutation]);
|
||||
|
||||
const sortedTasks = sortByPriorityAndQueueTime(tasks);
|
||||
const hasTasks = tasks.length > 0;
|
||||
const isDecomposeRunning =
|
||||
decomposeAgent?.status === "running" ||
|
||||
decomposeAgent?.status === "waiting_for_input";
|
||||
const showBreakdownButton =
|
||||
!decomposeAgent && !hasTasks;
|
||||
const showProposals =
|
||||
decomposeAgent?.status === "idle" && pendingProposals.length > 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3">
|
||||
{isEditingTitle ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-lg font-semibold">Phase {displayIndex}:</span>
|
||||
<input
|
||||
ref={inputRef}
|
||||
className="border-b border-border bg-transparent text-lg font-semibold outline-none focus:border-primary"
|
||||
value={editName}
|
||||
onChange={(e) => setEditName(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") saveTitle();
|
||||
if (e.key === "Escape") cancelEditing();
|
||||
}}
|
||||
onBlur={saveTitle}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<h3
|
||||
className="cursor-pointer text-lg font-semibold hover:text-primary"
|
||||
onClick={startEditing}
|
||||
title="Click to rename"
|
||||
>
|
||||
Phase {displayIndex}: {phase.name}
|
||||
</h3>
|
||||
)}
|
||||
<StatusBadge status={phase.status} />
|
||||
|
||||
{/* Breakdown button in header */}
|
||||
{showBreakdownButton && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleDecompose}
|
||||
disabled={decomposeMutation.isPending}
|
||||
className="gap-1.5"
|
||||
>
|
||||
<Sparkles className="h-3.5 w-3.5" />
|
||||
{decomposeMutation.isPending ? "Starting..." : "Breakdown"}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Running indicator in header */}
|
||||
{isDecomposeRunning && (
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
Breaking down...
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="ml-auto h-7 w-7">
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
className="text-destructive"
|
||||
onClick={() => {
|
||||
if (window.confirm(`Delete "${phase.name}"? All tasks in this phase will also be deleted.`)) {
|
||||
onDelete?.();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Delete Phase
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
{/* Tiptap Editor */}
|
||||
<PhaseContentEditor phaseId={phase.id} initiativeId={initiativeId} />
|
||||
|
||||
{/* Dependencies */}
|
||||
<div>
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<h4 className="text-sm font-medium text-muted-foreground">
|
||||
Dependencies
|
||||
</h4>
|
||||
{availableDeps.length > 0 && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-5 w-5">
|
||||
<Plus className="h-3 w-3" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start">
|
||||
{availableDeps.map((p) => (
|
||||
<DropdownMenuItem
|
||||
key={p.id}
|
||||
onClick={() =>
|
||||
addDependency.mutate({
|
||||
phaseId: phase.id,
|
||||
dependsOnPhaseId: p.id,
|
||||
})
|
||||
}
|
||||
>
|
||||
Phase {allDisplayIndices.get(p.id) ?? "?"}: {p.name}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</div>
|
||||
{resolvedDeps.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground">No dependencies</p>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{resolvedDeps.map((dep) => (
|
||||
<div
|
||||
key={dep.id}
|
||||
className="flex items-center gap-2 text-sm"
|
||||
>
|
||||
<span
|
||||
className={
|
||||
dep.status === "completed"
|
||||
? "text-green-600"
|
||||
: "text-muted-foreground"
|
||||
}
|
||||
>
|
||||
{dep.status === "completed" ? "\u25CF" : "\u25CB"}
|
||||
</span>
|
||||
<span>
|
||||
Phase {allDisplayIndices.get(dep.id) ?? "?"}: {dep.name}
|
||||
</span>
|
||||
<StatusBadge status={dep.status} className="text-[10px]" />
|
||||
<button
|
||||
className="ml-1 text-muted-foreground hover:text-destructive"
|
||||
onClick={() =>
|
||||
removeDependency.mutate({
|
||||
phaseId: phase.id,
|
||||
dependsOnPhaseId: dep.id,
|
||||
})
|
||||
}
|
||||
title="Remove dependency"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Decompose proposals */}
|
||||
{showProposals && (
|
||||
<ContentProposalReview
|
||||
proposals={pendingProposals as any}
|
||||
agentCreatedAt={new Date(decomposeAgent!.createdAt)}
|
||||
agentId={decomposeAgent!.id}
|
||||
onDismiss={handleDismissDecompose}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Tasks */}
|
||||
<div>
|
||||
<h4 className="mb-2 text-sm font-medium text-muted-foreground">
|
||||
Tasks ({tasks.filter((t) => t.status === "completed").length}/
|
||||
{tasks.length})
|
||||
</h4>
|
||||
{tasksLoading ? (
|
||||
<div className="space-y-1">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-8 w-full" />
|
||||
))}
|
||||
</div>
|
||||
) : sortedTasks.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No tasks yet</p>
|
||||
) : (
|
||||
<div>
|
||||
{sortedTasks.map((task, idx) => (
|
||||
<TaskRow
|
||||
key={task.id}
|
||||
task={task}
|
||||
agentName={null}
|
||||
blockedBy={[]}
|
||||
isLast={idx === sortedTasks.length - 1}
|
||||
onClick={() => setSelectedTaskId(task.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PhaseDetailEmpty() {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center text-muted-foreground">
|
||||
<p>Select a phase to view details</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
57
packages/web/src/components/execution/PhaseSidebarItem.tsx
Normal file
57
packages/web/src/components/execution/PhaseSidebarItem.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import { StatusBadge } from "@/components/StatusBadge";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface PhaseSidebarItemProps {
|
||||
phase: {
|
||||
id: string;
|
||||
name: string;
|
||||
status: string;
|
||||
};
|
||||
displayIndex: number;
|
||||
taskCount: { complete: number; total: number };
|
||||
dependencies: string[];
|
||||
isSelected: boolean;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
export function PhaseSidebarItem({
|
||||
phase,
|
||||
displayIndex,
|
||||
taskCount,
|
||||
dependencies,
|
||||
isSelected,
|
||||
onClick,
|
||||
}: PhaseSidebarItemProps) {
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
"flex w-full flex-col gap-0.5 rounded-md px-3 py-2 text-left transition-colors",
|
||||
isSelected
|
||||
? "border-l-2 border-primary bg-accent"
|
||||
: "border-l-2 border-transparent hover:bg-accent/50",
|
||||
)}
|
||||
onClick={onClick}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="min-w-0 flex-1 truncate text-sm font-medium">
|
||||
Phase {displayIndex}: {phase.name}
|
||||
</span>
|
||||
<StatusBadge status={phase.status} className="shrink-0 text-[10px]" />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span>
|
||||
{taskCount.total === 0
|
||||
? "Needs decomposition"
|
||||
: `${taskCount.complete}/${taskCount.total} tasks`}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{dependencies.length > 0 && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
depends on: {dependencies.join(", ")}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -9,9 +9,8 @@ interface PhaseWithTasksProps {
|
||||
phase: {
|
||||
id: string;
|
||||
initiativeId: string;
|
||||
number: number;
|
||||
name: string;
|
||||
description: string | null;
|
||||
content: string | null;
|
||||
status: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
@@ -78,13 +77,13 @@ function PhaseWithTasksInner({
|
||||
|
||||
const entries: FlatTaskEntry[] = tasks.map((task) => ({
|
||||
task,
|
||||
phaseName: `Phase ${phase.number}: ${phase.name}`,
|
||||
phaseName: phase.name,
|
||||
agentName: null,
|
||||
blockedBy: [],
|
||||
dependents: [],
|
||||
}));
|
||||
registerTasks(phase.id, entries);
|
||||
}, [tasks, phase.id, phase.number, phase.name, onTaskCounts, registerTasks]);
|
||||
}, [tasks, phase.id, phase.name, onTaskCounts, registerTasks]);
|
||||
|
||||
const sortedTasks = sortByPriorityAndQueueTime(tasks);
|
||||
const taskEntries = sortedTasks.map((task) => ({
|
||||
|
||||
@@ -50,9 +50,8 @@ export function PhasesList({
|
||||
const serializedPhase = {
|
||||
id: phase.id,
|
||||
initiativeId: phase.initiativeId,
|
||||
number: phase.number,
|
||||
name: phase.name,
|
||||
description: phase.description,
|
||||
content: phase.content,
|
||||
status: phase.status,
|
||||
createdAt: String(phase.createdAt),
|
||||
updatedAt: String(phase.updatedAt),
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
export { ExecutionProvider, useExecutionContext } from "./ExecutionContext";
|
||||
export { BreakdownSection } from "./BreakdownSection";
|
||||
export { PhaseActions } from "./PhaseActions";
|
||||
export { PhasesList } from "./PhasesList";
|
||||
export { PhaseWithTasks } from "./PhaseWithTasks";
|
||||
export { ProgressSidebar } from "./ProgressSidebar";
|
||||
export { PhaseSidebarItem } from "./PhaseSidebarItem";
|
||||
export { PhaseDetailPanel, PhaseDetailEmpty } from "./PhaseDetailPanel";
|
||||
export { TaskModal } from "./TaskModal";
|
||||
export type { TaskCounts, FlatTaskEntry, PhaseData } from "./ExecutionContext";
|
||||
37
packages/web/src/components/pipeline/PipelineGraph.tsx
Normal file
37
packages/web/src/components/pipeline/PipelineGraph.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import type { PipelineColumn } from "@codewalk-district/shared";
|
||||
import { PipelineStageColumn } from "./PipelineStageColumn";
|
||||
import type { SerializedTask } from "@/components/TaskRow";
|
||||
|
||||
interface PipelineGraphProps {
|
||||
columns: PipelineColumn<{
|
||||
id: string;
|
||||
name: string;
|
||||
status: string;
|
||||
createdAt: string | Date;
|
||||
}>[];
|
||||
tasksByPhase: Record<string, SerializedTask[]>;
|
||||
}
|
||||
|
||||
export function PipelineGraph({ columns, tasksByPhase }: PipelineGraphProps) {
|
||||
return (
|
||||
<div className="overflow-x-auto pb-4">
|
||||
<div className="flex min-w-max items-start gap-0">
|
||||
{columns.map((column, idx) => (
|
||||
<div key={column.depth} className="flex items-start">
|
||||
{/* Connector arrow between columns */}
|
||||
{idx > 0 && (
|
||||
<div className="flex items-center self-center py-4">
|
||||
<div className="h-px w-6 bg-border" />
|
||||
<div className="h-0 w-0 border-y-[4px] border-l-[6px] border-y-transparent border-l-border" />
|
||||
</div>
|
||||
)}
|
||||
<PipelineStageColumn
|
||||
phases={column.phases}
|
||||
tasksByPhase={tasksByPhase}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
52
packages/web/src/components/pipeline/PipelinePhaseGroup.tsx
Normal file
52
packages/web/src/components/pipeline/PipelinePhaseGroup.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import { Play } from "lucide-react";
|
||||
import { StatusDot } from "@/components/StatusDot";
|
||||
import { trpc } from "@/lib/trpc";
|
||||
import { sortByPriorityAndQueueTime } from "@codewalk-district/shared";
|
||||
import { PipelineTaskCard } from "./PipelineTaskCard";
|
||||
import type { SerializedTask } from "@/components/TaskRow";
|
||||
|
||||
interface PipelinePhaseGroupProps {
|
||||
phase: {
|
||||
id: string;
|
||||
name: string;
|
||||
status: string;
|
||||
};
|
||||
tasks: SerializedTask[];
|
||||
}
|
||||
|
||||
export function PipelinePhaseGroup({ phase, tasks }: PipelinePhaseGroupProps) {
|
||||
const queuePhase = trpc.queuePhase.useMutation();
|
||||
const sorted = sortByPriorityAndQueueTime(tasks);
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-border bg-card overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-2 px-3 py-2 border-b border-border bg-muted/30">
|
||||
<StatusDot status={phase.status} size="sm" />
|
||||
<span className="min-w-0 flex-1 truncate text-sm font-medium">
|
||||
{phase.name}
|
||||
</span>
|
||||
{phase.status === "pending" && (
|
||||
<button
|
||||
onClick={() => queuePhase.mutate({ phaseId: phase.id })}
|
||||
title="Queue phase"
|
||||
className="shrink-0"
|
||||
>
|
||||
<Play className="h-3.5 w-3.5 text-muted-foreground hover:text-foreground" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tasks */}
|
||||
<div className="py-1">
|
||||
{sorted.length === 0 ? (
|
||||
<p className="px-3 py-1 text-xs text-muted-foreground">No tasks</p>
|
||||
) : (
|
||||
sorted.map((task) => (
|
||||
<PipelineTaskCard key={task.id} task={task} />
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
25
packages/web/src/components/pipeline/PipelineStageColumn.tsx
Normal file
25
packages/web/src/components/pipeline/PipelineStageColumn.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import { PipelinePhaseGroup } from "./PipelinePhaseGroup";
|
||||
import type { SerializedTask } from "@/components/TaskRow";
|
||||
|
||||
interface PipelineStageColumnProps {
|
||||
phases: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
status: string;
|
||||
}>;
|
||||
tasksByPhase: Record<string, SerializedTask[]>;
|
||||
}
|
||||
|
||||
export function PipelineStageColumn({ phases, tasksByPhase }: PipelineStageColumnProps) {
|
||||
return (
|
||||
<div className="flex w-64 shrink-0 flex-col gap-3">
|
||||
{phases.map((phase) => (
|
||||
<PipelinePhaseGroup
|
||||
key={phase.id}
|
||||
phase={phase}
|
||||
tasks={tasksByPhase[phase.id] ?? []}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
114
packages/web/src/components/pipeline/PipelineTab.tsx
Normal file
114
packages/web/src/components/pipeline/PipelineTab.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { trpc } from "@/lib/trpc";
|
||||
import {
|
||||
groupPhasesByDependencyLevel,
|
||||
type DependencyEdge,
|
||||
} from "@codewalk-district/shared";
|
||||
import {
|
||||
ExecutionProvider,
|
||||
useExecutionContext,
|
||||
TaskModal,
|
||||
BreakdownSection,
|
||||
type PhaseData,
|
||||
type FlatTaskEntry,
|
||||
} from "@/components/execution";
|
||||
import type { SerializedTask } from "@/components/TaskRow";
|
||||
import { PipelineGraph } from "./PipelineGraph";
|
||||
|
||||
interface PipelineTabProps {
|
||||
initiativeId: string;
|
||||
phases: PhaseData[];
|
||||
phasesLoading: boolean;
|
||||
}
|
||||
|
||||
export function PipelineTab({ initiativeId, phases, phasesLoading }: PipelineTabProps) {
|
||||
return (
|
||||
<ExecutionProvider>
|
||||
<PipelineTabInner
|
||||
initiativeId={initiativeId}
|
||||
phases={phases}
|
||||
phasesLoading={phasesLoading}
|
||||
/>
|
||||
<TaskModal />
|
||||
</ExecutionProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function PipelineTabInner({ initiativeId, phases, phasesLoading }: PipelineTabProps) {
|
||||
const { handleRegisterTasks, handleTaskCounts } = useExecutionContext();
|
||||
|
||||
// Fetch all tasks for the initiative
|
||||
const tasksQuery = trpc.listInitiativeTasks.useQuery(
|
||||
{ initiativeId },
|
||||
{ enabled: phases.length > 0 },
|
||||
);
|
||||
const allTasks = (tasksQuery.data ?? []) as SerializedTask[];
|
||||
|
||||
// Fetch dependency edges
|
||||
const depsQuery = trpc.listInitiativePhaseDependencies.useQuery(
|
||||
{ initiativeId },
|
||||
{ enabled: phases.length > 0 },
|
||||
);
|
||||
const dependencyEdges: DependencyEdge[] = depsQuery.data ?? [];
|
||||
|
||||
// Group tasks by phaseId
|
||||
const tasksByPhase = useMemo(() => {
|
||||
const map: Record<string, SerializedTask[]> = {};
|
||||
for (const task of allTasks) {
|
||||
if (task.phaseId) {
|
||||
if (!map[task.phaseId]) map[task.phaseId] = [];
|
||||
map[task.phaseId].push(task);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}, [allTasks]);
|
||||
|
||||
// Compute pipeline columns
|
||||
const columns = useMemo(
|
||||
() => groupPhasesByDependencyLevel(phases, dependencyEdges),
|
||||
[phases, dependencyEdges],
|
||||
);
|
||||
|
||||
// Register tasks with ExecutionContext for TaskModal
|
||||
useEffect(() => {
|
||||
for (const phase of phases) {
|
||||
const phaseTasks = tasksByPhase[phase.id] ?? [];
|
||||
const entries: FlatTaskEntry[] = phaseTasks.map((task) => ({
|
||||
task,
|
||||
phaseName: phase.name,
|
||||
agentName: null,
|
||||
blockedBy: [],
|
||||
dependents: [],
|
||||
}));
|
||||
handleRegisterTasks(phase.id, entries);
|
||||
handleTaskCounts(phase.id, {
|
||||
complete: phaseTasks.filter((t) => t.status === "completed").length,
|
||||
total: phaseTasks.length,
|
||||
});
|
||||
}
|
||||
}, [phases, tasksByPhase, handleRegisterTasks, handleTaskCounts]);
|
||||
|
||||
// Empty state
|
||||
if (!phasesLoading && phases.length === 0) {
|
||||
return (
|
||||
<BreakdownSection
|
||||
initiativeId={initiativeId}
|
||||
phasesLoaded={!phasesLoading}
|
||||
phases={phases}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Loading
|
||||
if (phasesLoading || tasksQuery.isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12 text-muted-foreground">
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Loading pipeline...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <PipelineGraph columns={columns} tasksByPhase={tasksByPhase} />;
|
||||
}
|
||||
49
packages/web/src/components/pipeline/PipelineTaskCard.tsx
Normal file
49
packages/web/src/components/pipeline/PipelineTaskCard.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import { CheckCircle2, Loader2, Clock, Ban, Play, AlertTriangle } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { trpc } from "@/lib/trpc";
|
||||
import { useExecutionContext } from "@/components/execution";
|
||||
import type { SerializedTask } from "@/components/TaskRow";
|
||||
|
||||
const statusConfig: Record<string, { icon: typeof Clock; color: string; spin?: boolean }> = {
|
||||
pending: { icon: Clock, color: "text-muted-foreground" },
|
||||
pending_approval: { icon: AlertTriangle, color: "text-yellow-500" },
|
||||
in_progress: { icon: Loader2, color: "text-blue-500", spin: true },
|
||||
completed: { icon: CheckCircle2, color: "text-green-500" },
|
||||
blocked: { icon: Ban, color: "text-red-500" },
|
||||
};
|
||||
|
||||
interface PipelineTaskCardProps {
|
||||
task: SerializedTask;
|
||||
}
|
||||
|
||||
export function PipelineTaskCard({ task }: PipelineTaskCardProps) {
|
||||
const { setSelectedTaskId } = useExecutionContext();
|
||||
const queueTask = trpc.queueTask.useMutation();
|
||||
|
||||
const config = statusConfig[task.status] ?? statusConfig.pending;
|
||||
const Icon = config.icon;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex items-center gap-2 rounded px-2 py-1 cursor-pointer hover:bg-accent/50 group"
|
||||
onClick={() => setSelectedTaskId(task.id)}
|
||||
>
|
||||
<Icon
|
||||
className={cn("h-3.5 w-3.5 shrink-0", config.color, config.spin && "animate-spin")}
|
||||
/>
|
||||
<span className="min-w-0 flex-1 truncate text-xs">{task.name}</span>
|
||||
{task.status === "pending" && (
|
||||
<button
|
||||
className="shrink-0 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
queueTask.mutate({ taskId: task.id });
|
||||
}}
|
||||
title="Queue task"
|
||||
>
|
||||
<Play className="h-3 w-3 text-muted-foreground hover:text-foreground" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
1
packages/web/src/components/pipeline/index.ts
Normal file
1
packages/web/src/components/pipeline/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { PipelineTab } from "./PipelineTab";
|
||||
71
packages/web/src/components/review/CommentForm.tsx
Normal file
71
packages/web/src/components/review/CommentForm.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import { forwardRef, useState, useCallback } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
|
||||
interface CommentFormProps {
|
||||
onSubmit: (body: string) => void;
|
||||
onCancel: () => void;
|
||||
placeholder?: string;
|
||||
submitLabel?: string;
|
||||
}
|
||||
|
||||
export const CommentForm = forwardRef<HTMLTextAreaElement, CommentFormProps>(
|
||||
function CommentForm(
|
||||
{ onSubmit, onCancel, placeholder = "Write a comment...", submitLabel = "Comment" },
|
||||
ref
|
||||
) {
|
||||
const [body, setBody] = useState("");
|
||||
|
||||
const handleSubmit = useCallback(() => {
|
||||
const trimmed = body.trim();
|
||||
if (!trimmed) return;
|
||||
onSubmit(trimmed);
|
||||
setBody("");
|
||||
}, [body, onSubmit]);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
}
|
||||
if (e.key === "Escape") {
|
||||
onCancel();
|
||||
}
|
||||
},
|
||||
[handleSubmit, onCancel]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Textarea
|
||||
ref={ref}
|
||||
value={body}
|
||||
onChange={(e) => setBody(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={placeholder}
|
||||
className="min-h-[60px] text-xs resize-none"
|
||||
rows={2}
|
||||
/>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
Cmd+Enter to submit, Esc to cancel
|
||||
</span>
|
||||
<div className="flex gap-1.5">
|
||||
<Button variant="ghost" size="sm" className="h-7 text-xs" onClick={onCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-7 text-xs"
|
||||
onClick={handleSubmit}
|
||||
disabled={!body.trim()}
|
||||
>
|
||||
{submitLabel}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
72
packages/web/src/components/review/CommentThread.tsx
Normal file
72
packages/web/src/components/review/CommentThread.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
import { Check, RotateCcw } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type { ReviewComment } from "./types";
|
||||
|
||||
interface CommentThreadProps {
|
||||
comments: ReviewComment[];
|
||||
onResolve: (commentId: string) => void;
|
||||
onUnresolve: (commentId: string) => void;
|
||||
}
|
||||
|
||||
export function CommentThread({ comments, onResolve, onUnresolve }: CommentThreadProps) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{comments.map((comment) => (
|
||||
<div
|
||||
key={comment.id}
|
||||
className={`rounded border p-2.5 text-xs space-y-1.5 ${
|
||||
comment.resolved
|
||||
? "border-green-200 dark:border-green-900 bg-green-50/50 dark:bg-green-950/10"
|
||||
: "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-green-600 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>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatTime(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
return d.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" });
|
||||
}
|
||||
38
packages/web/src/components/review/DiffViewer.tsx
Normal file
38
packages/web/src/components/review/DiffViewer.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import type { FileDiff, DiffLine, ReviewComment } from "./types";
|
||||
import { FileCard } from "./FileCard";
|
||||
|
||||
interface DiffViewerProps {
|
||||
files: FileDiff[];
|
||||
comments: ReviewComment[];
|
||||
onAddComment: (
|
||||
filePath: string,
|
||||
lineNumber: number,
|
||||
lineType: DiffLine["type"],
|
||||
body: string,
|
||||
) => void;
|
||||
onResolveComment: (commentId: string) => void;
|
||||
onUnresolveComment: (commentId: string) => void;
|
||||
}
|
||||
|
||||
export function DiffViewer({
|
||||
files,
|
||||
comments,
|
||||
onAddComment,
|
||||
onResolveComment,
|
||||
onUnresolveComment,
|
||||
}: DiffViewerProps) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{files.map((file) => (
|
||||
<FileCard
|
||||
key={file.newPath}
|
||||
file={file}
|
||||
comments={comments.filter((c) => c.filePath === file.newPath)}
|
||||
onAddComment={onAddComment}
|
||||
onResolveComment={onResolveComment}
|
||||
onUnresolveComment={onUnresolveComment}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
86
packages/web/src/components/review/FileCard.tsx
Normal file
86
packages/web/src/components/review/FileCard.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
import { useState } from "react";
|
||||
import { ChevronDown, ChevronRight, Plus, Minus } from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import type { FileDiff, DiffLine, ReviewComment } from "./types";
|
||||
import { HunkRows } from "./HunkRows";
|
||||
|
||||
interface FileCardProps {
|
||||
file: FileDiff;
|
||||
comments: ReviewComment[];
|
||||
onAddComment: (
|
||||
filePath: string,
|
||||
lineNumber: number,
|
||||
lineType: DiffLine["type"],
|
||||
body: string,
|
||||
) => void;
|
||||
onResolveComment: (commentId: string) => void;
|
||||
onUnresolveComment: (commentId: string) => void;
|
||||
}
|
||||
|
||||
export function FileCard({
|
||||
file,
|
||||
comments,
|
||||
onAddComment,
|
||||
onResolveComment,
|
||||
onUnresolveComment,
|
||||
}: FileCardProps) {
|
||||
const [expanded, setExpanded] = useState(true);
|
||||
const commentCount = comments.length;
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-border overflow-hidden">
|
||||
{/* File header */}
|
||||
<button
|
||||
className="flex w-full items-center gap-2 px-3 py-2 bg-muted/50 hover:bg-muted text-left text-sm font-mono transition-colors"
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
>
|
||||
{expanded ? (
|
||||
<ChevronDown className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronRight className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
<span className="truncate flex-1">{file.newPath}</span>
|
||||
<span className="flex items-center gap-2 shrink-0 text-xs">
|
||||
{file.additions > 0 && (
|
||||
<span className="flex items-center gap-0.5 text-green-600">
|
||||
<Plus className="h-3 w-3" />
|
||||
{file.additions}
|
||||
</span>
|
||||
)}
|
||||
{file.deletions > 0 && (
|
||||
<span className="flex items-center gap-0.5 text-red-600">
|
||||
<Minus className="h-3 w-3" />
|
||||
{file.deletions}
|
||||
</span>
|
||||
)}
|
||||
{commentCount > 0 && (
|
||||
<Badge variant="secondary" className="text-[10px] px-1.5 py-0">
|
||||
{commentCount}
|
||||
</Badge>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* Diff content */}
|
||||
{expanded && (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-xs font-mono border-collapse">
|
||||
<tbody>
|
||||
{file.hunks.map((hunk, hi) => (
|
||||
<HunkRows
|
||||
key={hi}
|
||||
hunk={hunk}
|
||||
filePath={file.newPath}
|
||||
comments={comments}
|
||||
onAddComment={onAddComment}
|
||||
onResolveComment={onResolveComment}
|
||||
onUnresolveComment={onUnresolveComment}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
86
packages/web/src/components/review/HunkRows.tsx
Normal file
86
packages/web/src/components/review/HunkRows.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
import { useState, useCallback } from "react";
|
||||
import type { DiffLine, ReviewComment } from "./types";
|
||||
import { LineWithComments } from "./LineWithComments";
|
||||
|
||||
interface HunkRowsProps {
|
||||
hunk: { header: string; lines: DiffLine[] };
|
||||
filePath: string;
|
||||
comments: ReviewComment[];
|
||||
onAddComment: (
|
||||
filePath: string,
|
||||
lineNumber: number,
|
||||
lineType: DiffLine["type"],
|
||||
body: string,
|
||||
) => void;
|
||||
onResolveComment: (commentId: string) => void;
|
||||
onUnresolveComment: (commentId: string) => void;
|
||||
}
|
||||
|
||||
export function HunkRows({
|
||||
hunk,
|
||||
filePath,
|
||||
comments,
|
||||
onAddComment,
|
||||
onResolveComment,
|
||||
onUnresolveComment,
|
||||
}: HunkRowsProps) {
|
||||
const [commentingLine, setCommentingLine] = useState<{
|
||||
lineNumber: number;
|
||||
lineType: DiffLine["type"];
|
||||
} | null>(null);
|
||||
|
||||
const handleSubmitComment = useCallback(
|
||||
(body: string) => {
|
||||
if (!commentingLine) return;
|
||||
onAddComment(
|
||||
filePath,
|
||||
commentingLine.lineNumber,
|
||||
commentingLine.lineType,
|
||||
body,
|
||||
);
|
||||
setCommentingLine(null);
|
||||
},
|
||||
[commentingLine, filePath, onAddComment],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Hunk header */}
|
||||
<tr>
|
||||
<td
|
||||
colSpan={3}
|
||||
className="px-3 py-1 text-muted-foreground bg-blue-50 dark:bg-blue-950/30 text-[11px] select-none"
|
||||
>
|
||||
{hunk.header}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
{hunk.lines.map((line, li) => {
|
||||
const lineKey = line.newLineNumber ?? line.oldLineNumber ?? li;
|
||||
const lineComments = comments.filter(
|
||||
(c) => c.lineNumber === lineKey && c.lineType === line.type,
|
||||
);
|
||||
const isCommenting =
|
||||
commentingLine?.lineNumber === lineKey &&
|
||||
commentingLine?.lineType === line.type;
|
||||
|
||||
return (
|
||||
<LineWithComments
|
||||
key={`${line.type}-${lineKey}-${li}`}
|
||||
line={line}
|
||||
lineKey={lineKey}
|
||||
lineComments={lineComments}
|
||||
isCommenting={isCommenting}
|
||||
onStartComment={() =>
|
||||
setCommentingLine({ lineNumber: lineKey, lineType: line.type })
|
||||
}
|
||||
onCancelComment={() => setCommentingLine(null)}
|
||||
onSubmitComment={handleSubmitComment}
|
||||
onResolveComment={onResolveComment}
|
||||
onUnresolveComment={onUnresolveComment}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
138
packages/web/src/components/review/LineWithComments.tsx
Normal file
138
packages/web/src/components/review/LineWithComments.tsx
Normal file
@@ -0,0 +1,138 @@
|
||||
import { useRef, useEffect } from "react";
|
||||
import { MessageSquarePlus } from "lucide-react";
|
||||
import type { DiffLine, ReviewComment } from "./types";
|
||||
import { CommentThread } from "./CommentThread";
|
||||
import { CommentForm } from "./CommentForm";
|
||||
|
||||
interface LineWithCommentsProps {
|
||||
line: DiffLine;
|
||||
lineKey: number;
|
||||
lineComments: ReviewComment[];
|
||||
isCommenting: boolean;
|
||||
onStartComment: () => void;
|
||||
onCancelComment: () => void;
|
||||
onSubmitComment: (body: string) => void;
|
||||
onResolveComment: (commentId: string) => void;
|
||||
onUnresolveComment: (commentId: string) => void;
|
||||
}
|
||||
|
||||
export function LineWithComments({
|
||||
line,
|
||||
lineKey,
|
||||
lineComments,
|
||||
isCommenting,
|
||||
onStartComment,
|
||||
onCancelComment,
|
||||
onSubmitComment,
|
||||
onResolveComment,
|
||||
onUnresolveComment,
|
||||
}: LineWithCommentsProps) {
|
||||
const formRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (isCommenting) {
|
||||
formRef.current?.focus();
|
||||
}
|
||||
}, [isCommenting]);
|
||||
|
||||
const bgClass =
|
||||
line.type === "added"
|
||||
? "bg-green-50 dark:bg-green-950/20"
|
||||
: line.type === "removed"
|
||||
? "bg-red-50 dark:bg-red-950/20"
|
||||
: "";
|
||||
|
||||
const gutterBgClass =
|
||||
line.type === "added"
|
||||
? "bg-green-100 dark:bg-green-950/40"
|
||||
: line.type === "removed"
|
||||
? "bg-red-100 dark:bg-red-950/40"
|
||||
: "bg-muted/30";
|
||||
|
||||
const prefix =
|
||||
line.type === "added" ? "+" : line.type === "removed" ? "-" : " ";
|
||||
|
||||
const textColorClass =
|
||||
line.type === "added"
|
||||
? "text-green-800 dark:text-green-300"
|
||||
: line.type === "removed"
|
||||
? "text-red-800 dark:text-red-300"
|
||||
: "";
|
||||
|
||||
return (
|
||||
<>
|
||||
<tr
|
||||
className={`group ${bgClass} hover:brightness-95 dark:hover:brightness-110`}
|
||||
>
|
||||
{/* Line numbers */}
|
||||
<td
|
||||
className={`w-[72px] min-w-[72px] select-none text-right text-muted-foreground pr-1 ${gutterBgClass} align-top`}
|
||||
>
|
||||
<div className="flex items-center justify-end gap-0">
|
||||
<span className="w-8 inline-block text-right text-[11px] leading-5">
|
||||
{line.oldLineNumber ?? ""}
|
||||
</span>
|
||||
<span className="w-8 inline-block text-right text-[11px] leading-5">
|
||||
{line.newLineNumber ?? ""}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
{/* Comment button gutter */}
|
||||
<td className={`w-6 min-w-6 ${gutterBgClass} align-top`}>
|
||||
<button
|
||||
className="opacity-0 group-hover:opacity-100 transition-opacity p-0.5 hover:text-blue-600"
|
||||
onClick={onStartComment}
|
||||
title="Add comment"
|
||||
>
|
||||
<MessageSquarePlus className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</td>
|
||||
|
||||
{/* Code content */}
|
||||
<td className="pl-1 pr-3 align-top">
|
||||
<pre
|
||||
className={`leading-5 whitespace-pre-wrap break-all ${textColorClass}`}
|
||||
>
|
||||
<span className="select-none text-muted-foreground/60">
|
||||
{prefix}
|
||||
</span>
|
||||
{line.content}
|
||||
</pre>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
{/* Existing comments on this line */}
|
||||
{lineComments.length > 0 && (
|
||||
<tr>
|
||||
<td
|
||||
colSpan={3}
|
||||
className="px-3 py-2 bg-muted/20 border-y border-border/50"
|
||||
>
|
||||
<CommentThread
|
||||
comments={lineComments}
|
||||
onResolve={onResolveComment}
|
||||
onUnresolve={onUnresolveComment}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
|
||||
{/* Inline comment form */}
|
||||
{isCommenting && (
|
||||
<tr>
|
||||
<td
|
||||
colSpan={3}
|
||||
className="px-3 py-2 bg-blue-50/50 dark:bg-blue-950/20 border-y border-blue-200 dark:border-blue-900"
|
||||
>
|
||||
<CommentForm
|
||||
ref={formRef}
|
||||
onSubmit={onSubmitComment}
|
||||
onCancel={onCancelComment}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
213
packages/web/src/components/review/ReviewSidebar.tsx
Normal file
213
packages/web/src/components/review/ReviewSidebar.tsx
Normal file
@@ -0,0 +1,213 @@
|
||||
import {
|
||||
Check,
|
||||
X,
|
||||
MessageSquare,
|
||||
GitBranch,
|
||||
FileCode,
|
||||
Plus,
|
||||
Minus,
|
||||
Circle,
|
||||
CheckCircle2,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import type { FileDiff, ReviewComment, ReviewStatus } from "./types";
|
||||
|
||||
interface ReviewSidebarProps {
|
||||
title: string;
|
||||
description: string;
|
||||
author: string;
|
||||
status: ReviewStatus;
|
||||
sourceBranch: string;
|
||||
targetBranch: string;
|
||||
files: FileDiff[];
|
||||
comments: ReviewComment[];
|
||||
onApprove: () => void;
|
||||
onRequestChanges: () => void;
|
||||
onFileClick: (filePath: string) => void;
|
||||
}
|
||||
|
||||
export function ReviewSidebar({
|
||||
title,
|
||||
description,
|
||||
author,
|
||||
status,
|
||||
sourceBranch,
|
||||
targetBranch,
|
||||
files,
|
||||
comments,
|
||||
onApprove,
|
||||
onRequestChanges,
|
||||
onFileClick,
|
||||
}: ReviewSidebarProps) {
|
||||
const unresolvedCount = comments.filter((c) => !c.resolved).length;
|
||||
const resolvedCount = comments.filter((c) => c.resolved).length;
|
||||
const totalAdditions = files.reduce((s, f) => s + f.additions, 0);
|
||||
const totalDeletions = files.reduce((s, f) => s + f.deletions, 0);
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
{/* Review info */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<h3 className="text-sm font-semibold leading-tight">{title}</h3>
|
||||
<StatusBadge status={status} />
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||
{description}
|
||||
</p>
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<span className="font-medium text-foreground">{author}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground font-mono">
|
||||
<GitBranch className="h-3 w-3" />
|
||||
<span>{sourceBranch}</span>
|
||||
<span className="text-muted-foreground/50">→</span>
|
||||
<span>{targetBranch}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="space-y-2">
|
||||
{status === "pending" && (
|
||||
<>
|
||||
<Button
|
||||
className="w-full"
|
||||
size="sm"
|
||||
onClick={onApprove}
|
||||
disabled={unresolvedCount > 0}
|
||||
>
|
||||
<Check className="h-3.5 w-3.5 mr-1" />
|
||||
{unresolvedCount > 0
|
||||
? `Resolve ${unresolvedCount} thread${unresolvedCount > 1 ? "s" : ""} first`
|
||||
: "Approve"}
|
||||
</Button>
|
||||
<Button
|
||||
className="w-full"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onRequestChanges}
|
||||
>
|
||||
<X className="h-3.5 w-3.5 mr-1" />
|
||||
Request Changes
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{status === "approved" && (
|
||||
<div className="flex items-center gap-2 rounded-md bg-green-50 dark:bg-green-950/20 border border-green-200 dark:border-green-900 px-3 py-2 text-xs text-green-700 dark:text-green-400">
|
||||
<Check className="h-4 w-4" />
|
||||
<span className="font-medium">Approved</span>
|
||||
</div>
|
||||
)}
|
||||
{status === "changes_requested" && (
|
||||
<div className="flex items-center gap-2 rounded-md bg-orange-50 dark:bg-orange-950/20 border border-orange-200 dark:border-orange-900 px-3 py-2 text-xs text-orange-700 dark:text-orange-400">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="font-medium">Changes Requested</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Comment summary */}
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-xs 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} comment{comments.length !== 1 ? "s" : ""}
|
||||
</span>
|
||||
{resolvedCount > 0 && (
|
||||
<span className="flex items-center gap-1 text-green-600">
|
||||
<CheckCircle2 className="h-3 w-3" />
|
||||
{resolvedCount} resolved
|
||||
</span>
|
||||
)}
|
||||
{unresolvedCount > 0 && (
|
||||
<span className="flex items-center gap-1 text-orange-600">
|
||||
<Circle className="h-3 w-3" />
|
||||
{unresolvedCount} open
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">
|
||||
Changes
|
||||
</h4>
|
||||
<div className="flex items-center gap-3 text-xs">
|
||||
<span className="flex items-center gap-1">
|
||||
<FileCode className="h-3 w-3 text-muted-foreground" />
|
||||
{files.length} file{files.length !== 1 ? "s" : ""}
|
||||
</span>
|
||||
<span className="flex items-center gap-0.5 text-green-600">
|
||||
<Plus className="h-3 w-3" />
|
||||
{totalAdditions}
|
||||
</span>
|
||||
<span className="flex items-center gap-0.5 text-red-600">
|
||||
<Minus className="h-3 w-3" />
|
||||
{totalDeletions}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* File list */}
|
||||
<div className="space-y-1">
|
||||
<h4 className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">
|
||||
Files
|
||||
</h4>
|
||||
{files.map((file) => {
|
||||
const fileCommentCount = comments.filter(
|
||||
(c) => c.filePath === file.newPath
|
||||
).length;
|
||||
return (
|
||||
<button
|
||||
key={file.newPath}
|
||||
className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-xs hover:bg-accent/50 transition-colors group"
|
||||
onClick={() => onFileClick(file.newPath)}
|
||||
>
|
||||
<FileCode className="h-3 w-3 text-muted-foreground shrink-0" />
|
||||
<span className="truncate flex-1 font-mono text-[11px]">
|
||||
{file.newPath.split("/").pop()}
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5 shrink-0">
|
||||
{fileCommentCount > 0 && (
|
||||
<span className="flex items-center gap-0.5 text-muted-foreground">
|
||||
<MessageSquare className="h-2.5 w-2.5" />
|
||||
{fileCommentCount}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-green-600 text-[10px]">+{file.additions}</span>
|
||||
<span className="text-red-600 text-[10px]">-{file.deletions}</span>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status: ReviewStatus }) {
|
||||
if (status === "approved") {
|
||||
return (
|
||||
<Badge className="bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200 border-green-200 dark:border-green-800 text-[10px]">
|
||||
Approved
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
if (status === "changes_requested") {
|
||||
return (
|
||||
<Badge className="bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-200 border-orange-200 dark:border-orange-800 text-[10px]">
|
||||
Changes Requested
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Badge variant="secondary" className="text-[10px]">
|
||||
Pending Review
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
104
packages/web/src/components/review/ReviewTab.tsx
Normal file
104
packages/web/src/components/review/ReviewTab.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
import { useState, useCallback, useRef } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { DUMMY_REVIEW } from "./dummy-data";
|
||||
import { DiffViewer } from "./DiffViewer";
|
||||
import { ReviewSidebar } from "./ReviewSidebar";
|
||||
import type { ReviewComment, ReviewStatus, DiffLine } from "./types";
|
||||
|
||||
interface ReviewTabProps {
|
||||
initiativeId: string;
|
||||
}
|
||||
|
||||
export function ReviewTab({ initiativeId: _initiativeId }: ReviewTabProps) {
|
||||
const [comments, setComments] = useState<ReviewComment[]>(DUMMY_REVIEW.comments);
|
||||
const [status, setStatus] = useState<ReviewStatus>(DUMMY_REVIEW.status);
|
||||
const fileRefs = useRef<Map<string, HTMLDivElement>>(new Map());
|
||||
|
||||
const handleAddComment = useCallback(
|
||||
(filePath: string, lineNumber: number, lineType: DiffLine["type"], body: string) => {
|
||||
const newComment: ReviewComment = {
|
||||
id: `c${Date.now()}`,
|
||||
filePath,
|
||||
lineNumber,
|
||||
lineType,
|
||||
body,
|
||||
author: "you",
|
||||
createdAt: new Date().toISOString(),
|
||||
resolved: false,
|
||||
};
|
||||
setComments((prev) => [...prev, newComment]);
|
||||
toast.success("Comment added");
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handleResolveComment = useCallback((commentId: string) => {
|
||||
setComments((prev) =>
|
||||
prev.map((c) => (c.id === commentId ? { ...c, resolved: true } : c))
|
||||
);
|
||||
}, []);
|
||||
|
||||
const handleUnresolveComment = useCallback((commentId: string) => {
|
||||
setComments((prev) =>
|
||||
prev.map((c) => (c.id === commentId ? { ...c, resolved: false } : c))
|
||||
);
|
||||
}, []);
|
||||
|
||||
const handleApprove = useCallback(() => {
|
||||
setStatus("approved");
|
||||
toast.success("Review approved");
|
||||
}, []);
|
||||
|
||||
const handleRequestChanges = useCallback(() => {
|
||||
setStatus("changes_requested");
|
||||
toast("Changes requested", {
|
||||
description: "The agent will be notified about the requested changes.",
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleFileClick = useCallback((filePath: string) => {
|
||||
const el = fileRefs.current.get(filePath);
|
||||
if (el) {
|
||||
el.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[1fr_300px]">
|
||||
{/* Left: Diff */}
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center justify-between border-b border-border pb-3 mb-4">
|
||||
<h2 className="text-lg font-semibold">Review</h2>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{comments.filter((c) => !c.resolved).length} unresolved thread
|
||||
{comments.filter((c) => !c.resolved).length !== 1 ? "s" : ""}
|
||||
</span>
|
||||
</div>
|
||||
<DiffViewer
|
||||
files={DUMMY_REVIEW.files}
|
||||
comments={comments}
|
||||
onAddComment={handleAddComment}
|
||||
onResolveComment={handleResolveComment}
|
||||
onUnresolveComment={handleUnresolveComment}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Right: Sidebar */}
|
||||
<div className="w-full lg:w-[300px]">
|
||||
<ReviewSidebar
|
||||
title={DUMMY_REVIEW.title}
|
||||
description={DUMMY_REVIEW.description}
|
||||
author={DUMMY_REVIEW.author}
|
||||
status={status}
|
||||
sourceBranch={DUMMY_REVIEW.sourceBranch}
|
||||
targetBranch={DUMMY_REVIEW.targetBranch}
|
||||
files={DUMMY_REVIEW.files}
|
||||
comments={comments}
|
||||
onApprove={handleApprove}
|
||||
onRequestChanges={handleRequestChanges}
|
||||
onFileClick={handleFileClick}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
202
packages/web/src/components/review/dummy-data.ts
Normal file
202
packages/web/src/components/review/dummy-data.ts
Normal file
@@ -0,0 +1,202 @@
|
||||
import { parseUnifiedDiff } from "./parse-diff";
|
||||
import type { ReviewComment, ReviewSummary } from "./types";
|
||||
|
||||
const RAW_DIFF = `diff --git a/src/agent/output-handler.ts b/src/agent/output-handler.ts
|
||||
index 7aeabe5..7ca616a 100644
|
||||
--- a/src/agent/output-handler.ts
|
||||
+++ b/src/agent/output-handler.ts
|
||||
@@ -74,6 +74,8 @@ interface ClaudeCliResult {
|
||||
}
|
||||
|
||||
export class OutputHandler {
|
||||
+ private filePositions = new Map<string, number>();
|
||||
+
|
||||
constructor(
|
||||
private repository: AgentRepository,
|
||||
private eventBus?: EventBus,
|
||||
@@ -101,6 +103,43 @@ export class OutputHandler {
|
||||
}
|
||||
}
|
||||
|
||||
+ /**
|
||||
+ * Read complete lines from a file, avoiding partial lines that might still be writing.
|
||||
+ * This eliminates race conditions when agents are still writing output.
|
||||
+ */
|
||||
+ private async readCompleteLines(filePath: string, fromPosition: number = 0): Promise<{ content: string; lastPosition: number }> {
|
||||
+ try {
|
||||
+ const fs = await import('node:fs/promises');
|
||||
+ const content = await fs.readFile(filePath, 'utf-8');
|
||||
+
|
||||
+ if (fromPosition >= content.length) {
|
||||
+ return { content: '', lastPosition: fromPosition };
|
||||
+ }
|
||||
+
|
||||
+ // Get content from our last read position
|
||||
+ const newContent = content.slice(fromPosition);
|
||||
+
|
||||
+ // Split into lines
|
||||
+ const lines = newContent.split('\\n');
|
||||
+
|
||||
+ // If file doesn't end with newline, last element is potentially incomplete
|
||||
+ // Only process complete lines (all but the last, unless file ends with \\n)
|
||||
+ const hasTrailingNewline = newContent.endsWith('\\n');
|
||||
+ const completeLines = hasTrailingNewline ? lines : lines.slice(0, -1);
|
||||
+
|
||||
+ // Calculate new position (only count complete lines)
|
||||
+ const completeLinesContent = completeLines.join('\\n') + (completeLines.length > 0 && hasTrailingNewline ? '\\n' : '');
|
||||
+ const newPosition = fromPosition + Buffer.byteLength(completeLinesContent, 'utf-8');
|
||||
+
|
||||
+ return {
|
||||
+ content: completeLinesContent,
|
||||
+ lastPosition: newPosition
|
||||
+ };
|
||||
+ } catch {
|
||||
+ return { content: '', lastPosition: fromPosition };
|
||||
+ }
|
||||
+ }
|
||||
+
|
||||
/**
|
||||
* Handle a standardized stream event from a parser.
|
||||
*/
|
||||
@@ -213,12 +252,27 @@ export class OutputHandler {
|
||||
if (!signalText) {
|
||||
try {
|
||||
const outputFilePath = active?.outputFilePath ?? '';
|
||||
- if (outputFilePath && await this.validateSignalFile(outputFilePath)) {
|
||||
- const fileContent = await readFile(outputFilePath, 'utf-8');
|
||||
+ if (outputFilePath) {
|
||||
+ // Read only complete lines from the file, avoiding race conditions
|
||||
+ const lastPosition = this.filePositions.get(agentId) || 0;
|
||||
+ const { content: fileContent, lastPosition: newPosition } = await this.readCompleteLines(outputFilePath, lastPosition);
|
||||
+
|
||||
if (fileContent.trim()) {
|
||||
+ this.filePositions.set(agentId, newPosition);
|
||||
await this.processAgentOutput(agentId, fileContent, provider, getAgentWorkdir);
|
||||
return;
|
||||
}
|
||||
+
|
||||
+ // If no new complete lines, but file might still be writing, try again with validation
|
||||
+ if (await this.validateSignalFile(outputFilePath)) {
|
||||
+ const fullContent = await readFile(outputFilePath, 'utf-8');
|
||||
+ if (fullContent.trim() && fullContent.length > newPosition) {
|
||||
+ // File is complete and has content beyond what we've read
|
||||
+ this.filePositions.delete(agentId); // Clean up tracking
|
||||
+ await this.processAgentOutput(agentId, fullContent, provider, getAgentWorkdir);
|
||||
+ return;
|
||||
+ }
|
||||
+ }
|
||||
}
|
||||
} catch { /* file empty or missing */ }
|
||||
|
||||
diff --git a/src/agent/manager.ts b/src/agent/manager.ts
|
||||
index a1b2c3d..e4f5g6h 100644
|
||||
--- a/src/agent/manager.ts
|
||||
+++ b/src/agent/manager.ts
|
||||
@@ -145,6 +145,18 @@ export class MultiProviderAgentManager {
|
||||
return agent;
|
||||
}
|
||||
|
||||
+ /**
|
||||
+ * Check if an agent has a valid completion signal that indicates
|
||||
+ * it finished successfully, even if process monitoring missed it.
|
||||
+ */
|
||||
+ private async checkSignalCompletion(agent: AgentInfo): Promise<boolean> {
|
||||
+ const signalPath = this.getSignalPath(agent);
|
||||
+ if (!signalPath) return false;
|
||||
+ const signal = await this.readSignalFile(signalPath);
|
||||
+ if (!signal) return false;
|
||||
+ return ['done', 'questions', 'error'].includes(signal.status);
|
||||
+ }
|
||||
+
|
||||
/**
|
||||
* Reconcile agent states after a server restart.
|
||||
* Checks which agents are still alive and updates their status.
|
||||
@@ -160,8 +172,16 @@ export class MultiProviderAgentManager {
|
||||
if (isAlive) {
|
||||
this.monitorAgent(agent);
|
||||
} else {
|
||||
- // Agent process is gone — mark as crashed
|
||||
- await this.outputHandler.handleAgentError(agent.id, 'Agent process terminated unexpectedly');
|
||||
+ // Agent process is gone — check signal before marking as crashed
|
||||
+ const hasValidSignal = await this.checkSignalCompletion(agent);
|
||||
+ if (hasValidSignal) {
|
||||
+ // Agent completed normally but we missed the signal
|
||||
+ this.logger.info({ agentId: agent.id }, 'Agent has valid completion signal, processing...');
|
||||
+ await this.outputHandler.handleCompletion(agent.id, agent.provider);
|
||||
+ } else {
|
||||
+ // Truly crashed
|
||||
+ await this.outputHandler.handleAgentError(agent.id, 'Agent process terminated unexpectedly');
|
||||
+ }
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const DUMMY_COMMENTS: ReviewComment[] = [
|
||||
{
|
||||
id: "c1",
|
||||
filePath: "src/agent/output-handler.ts",
|
||||
lineNumber: 77,
|
||||
lineType: "added",
|
||||
body: "Consider using a WeakMap here to avoid memory leaks if agent references are garbage collected. Though since we clean up in handleAgentError and completion, this is probably fine.",
|
||||
author: "agent:review-bot",
|
||||
createdAt: "2026-02-09T10:30:00Z",
|
||||
resolved: false,
|
||||
},
|
||||
{
|
||||
id: "c2",
|
||||
filePath: "src/agent/output-handler.ts",
|
||||
lineNumber: 112,
|
||||
lineType: "added",
|
||||
body: "Dynamic import of fs/promises on every call is wasteful. This should be a top-level import since it's a Node built-in and always available.",
|
||||
author: "agent:review-bot",
|
||||
createdAt: "2026-02-09T10:30:00Z",
|
||||
resolved: false,
|
||||
},
|
||||
{
|
||||
id: "c3",
|
||||
filePath: "src/agent/output-handler.ts",
|
||||
lineNumber: 131,
|
||||
lineType: "added",
|
||||
body: "Bug: `Buffer.byteLength` gives byte length but `content.slice()` works on character offsets. If the file contains multi-byte characters, the position tracking will drift. Use character length consistently, or switch to byte-based reads with `fs.read()`.",
|
||||
author: "agent:review-bot",
|
||||
createdAt: "2026-02-09T10:31:00Z",
|
||||
resolved: false,
|
||||
},
|
||||
{
|
||||
id: "c4",
|
||||
filePath: "src/agent/manager.ts",
|
||||
lineNumber: 156,
|
||||
lineType: "added",
|
||||
body: "Good approach. Checking the signal file before marking as crashed eliminates the race condition where the process exits before we can read its output.",
|
||||
author: "agent:review-bot",
|
||||
createdAt: "2026-02-09T10:32:00Z",
|
||||
resolved: true,
|
||||
},
|
||||
{
|
||||
id: "c5",
|
||||
filePath: "src/agent/manager.ts",
|
||||
lineNumber: 180,
|
||||
lineType: "added",
|
||||
body: "The log message says 'processing...' but doesn't indicate what processing means. Should clarify that this triggers handleCompletion which will update the agent's status based on the signal content.",
|
||||
author: "agent:review-bot",
|
||||
createdAt: "2026-02-09T10:33:00Z",
|
||||
resolved: false,
|
||||
},
|
||||
];
|
||||
|
||||
const files = parseUnifiedDiff(RAW_DIFF);
|
||||
|
||||
export const DUMMY_REVIEW: ReviewSummary = {
|
||||
id: "review-1",
|
||||
title: "fix(agent): Eliminate race condition in completion handling",
|
||||
description:
|
||||
"Introduces incremental file position tracking to avoid reading partial lines from agent output files. Also adds signal.json checking during reconciliation to prevent false crash marking when agents complete between process checks.",
|
||||
author: "agent:slim-wildebeest",
|
||||
status: "pending",
|
||||
comments: DUMMY_COMMENTS,
|
||||
files,
|
||||
createdAt: "2026-02-09T10:00:00Z",
|
||||
sourceBranch: "fix/completion-race-condition",
|
||||
targetBranch: "main",
|
||||
};
|
||||
1
packages/web/src/components/review/index.ts
Normal file
1
packages/web/src/components/review/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { ReviewTab } from "./ReviewTab";
|
||||
93
packages/web/src/components/review/parse-diff.ts
Normal file
93
packages/web/src/components/review/parse-diff.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import type { FileDiff, DiffHunk, DiffLine } from "./types";
|
||||
|
||||
/**
|
||||
* Parse a unified diff string into structured FileDiff objects.
|
||||
*/
|
||||
export function parseUnifiedDiff(raw: string): FileDiff[] {
|
||||
const files: FileDiff[] = [];
|
||||
const fileChunks = raw.split(/^diff --git /m).filter(Boolean);
|
||||
|
||||
for (const chunk of fileChunks) {
|
||||
const lines = chunk.split("\n");
|
||||
|
||||
// Extract paths from first line: "a/path b/path"
|
||||
const headerMatch = lines[0]?.match(/^a\/(.+?) b\/(.+)$/);
|
||||
if (!headerMatch) continue;
|
||||
|
||||
const oldPath = headerMatch[1];
|
||||
const newPath = headerMatch[2];
|
||||
const hunks: DiffHunk[] = [];
|
||||
let additions = 0;
|
||||
let deletions = 0;
|
||||
|
||||
let i = 1;
|
||||
// Skip to first hunk header
|
||||
while (i < lines.length && !lines[i].startsWith("@@")) {
|
||||
i++;
|
||||
}
|
||||
|
||||
while (i < lines.length) {
|
||||
const hunkMatch = lines[i].match(
|
||||
/^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@(.*)$/
|
||||
);
|
||||
if (!hunkMatch) {
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const oldStart = parseInt(hunkMatch[1], 10);
|
||||
const oldCount = parseInt(hunkMatch[2] ?? "1", 10);
|
||||
const newStart = parseInt(hunkMatch[3], 10);
|
||||
const newCount = parseInt(hunkMatch[4] ?? "1", 10);
|
||||
const header = lines[i];
|
||||
|
||||
const hunkLines: DiffLine[] = [];
|
||||
let oldLine = oldStart;
|
||||
let newLine = newStart;
|
||||
|
||||
i++;
|
||||
while (i < lines.length && !lines[i].startsWith("@@") && !lines[i].startsWith("diff --git ")) {
|
||||
const line = lines[i];
|
||||
if (line.startsWith("+")) {
|
||||
hunkLines.push({
|
||||
type: "added",
|
||||
content: line.slice(1),
|
||||
oldLineNumber: null,
|
||||
newLineNumber: newLine,
|
||||
});
|
||||
newLine++;
|
||||
additions++;
|
||||
} else if (line.startsWith("-")) {
|
||||
hunkLines.push({
|
||||
type: "removed",
|
||||
content: line.slice(1),
|
||||
oldLineNumber: oldLine,
|
||||
newLineNumber: null,
|
||||
});
|
||||
oldLine++;
|
||||
deletions++;
|
||||
} else if (line.startsWith(" ") || line === "") {
|
||||
hunkLines.push({
|
||||
type: "context",
|
||||
content: line.startsWith(" ") ? line.slice(1) : line,
|
||||
oldLineNumber: oldLine,
|
||||
newLineNumber: newLine,
|
||||
});
|
||||
oldLine++;
|
||||
newLine++;
|
||||
} else {
|
||||
// Likely "\ No newline at end of file" or similar
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
i++;
|
||||
}
|
||||
|
||||
hunks.push({ header, oldStart, oldCount, newStart, newCount, lines: hunkLines });
|
||||
}
|
||||
|
||||
files.push({ oldPath, newPath, hunks, additions, deletions });
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
49
packages/web/src/components/review/types.ts
Normal file
49
packages/web/src/components/review/types.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
export interface DiffHunk {
|
||||
header: string;
|
||||
oldStart: number;
|
||||
oldCount: number;
|
||||
newStart: number;
|
||||
newCount: number;
|
||||
lines: DiffLine[];
|
||||
}
|
||||
|
||||
export interface DiffLine {
|
||||
type: "added" | "removed" | "context";
|
||||
content: string;
|
||||
oldLineNumber: number | null;
|
||||
newLineNumber: number | null;
|
||||
}
|
||||
|
||||
export interface FileDiff {
|
||||
oldPath: string;
|
||||
newPath: string;
|
||||
hunks: DiffHunk[];
|
||||
additions: number;
|
||||
deletions: number;
|
||||
}
|
||||
|
||||
export interface ReviewComment {
|
||||
id: string;
|
||||
filePath: string;
|
||||
lineNumber: number; // new-side line number (or old-side for deletions)
|
||||
lineType: "added" | "removed" | "context";
|
||||
body: string;
|
||||
author: string;
|
||||
createdAt: string;
|
||||
resolved: boolean;
|
||||
}
|
||||
|
||||
export type ReviewStatus = "pending" | "approved" | "changes_requested";
|
||||
|
||||
export interface ReviewSummary {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
author: string;
|
||||
status: ReviewStatus;
|
||||
comments: ReviewComment[];
|
||||
files: FileDiff[];
|
||||
createdAt: string;
|
||||
sourceBranch: string;
|
||||
targetBranch: string;
|
||||
}
|
||||
@@ -7,6 +7,7 @@
|
||||
|
||||
export { useAutoSave } from './useAutoSave.js';
|
||||
export { useDebounce, useDebounceWithImmediate } from './useDebounce.js';
|
||||
export { useLiveUpdates } from './useLiveUpdates.js';
|
||||
export { useRefineAgent } from './useRefineAgent.js';
|
||||
export { useSubscriptionWithErrorHandling } from './useSubscriptionWithErrorHandling.js';
|
||||
|
||||
|
||||
@@ -1,13 +1,54 @@
|
||||
import { useRef, useCallback, useEffect } from "react";
|
||||
import { useRef, useCallback, useEffect, useState } from "react";
|
||||
import { trpc } from "@/lib/trpc";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface UseAutoSaveOptions {
|
||||
debounceMs?: number;
|
||||
onSaved?: () => void;
|
||||
onError?: (error: Error) => void;
|
||||
}
|
||||
|
||||
export function useAutoSave({ debounceMs = 1000, onSaved }: UseAutoSaveOptions = {}) {
|
||||
const updateMutation = trpc.updatePage.useMutation({ onSuccess: onSaved });
|
||||
export function useAutoSave({ debounceMs = 1000, onSaved, onError }: UseAutoSaveOptions = {}) {
|
||||
const [lastError, setLastError] = useState<Error | null>(null);
|
||||
const [retryCount, setRetryCount] = useState(0);
|
||||
const utils = trpc.useUtils();
|
||||
|
||||
const updateMutation = trpc.updatePage.useMutation({
|
||||
onMutate: async (variables) => {
|
||||
// Cancel any outgoing refetches
|
||||
await utils.getPage.cancel({ id: variables.id });
|
||||
|
||||
// Snapshot the previous value
|
||||
const previousPage = utils.getPage.getData({ id: variables.id });
|
||||
|
||||
// Optimistically update the page in cache
|
||||
if (previousPage) {
|
||||
const optimisticUpdate = {
|
||||
...previousPage,
|
||||
...(variables.title !== undefined && { title: variables.title }),
|
||||
...(variables.content !== undefined && { content: variables.content }),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
utils.getPage.setData({ id: variables.id }, optimisticUpdate);
|
||||
}
|
||||
|
||||
return { previousPage };
|
||||
},
|
||||
onSuccess: () => {
|
||||
setLastError(null);
|
||||
setRetryCount(0);
|
||||
onSaved?.();
|
||||
},
|
||||
onError: (error, variables, context) => {
|
||||
// Revert optimistic update
|
||||
if (context?.previousPage) {
|
||||
utils.getPage.setData({ id: variables.id }, context.previousPage);
|
||||
}
|
||||
setLastError(error);
|
||||
onError?.(error);
|
||||
},
|
||||
// Invalidation handled globally by MutationCache
|
||||
});
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const pendingRef = useRef<{
|
||||
id: string;
|
||||
@@ -15,7 +56,7 @@ export function useAutoSave({ debounceMs = 1000, onSaved }: UseAutoSaveOptions =
|
||||
content?: string | null;
|
||||
} | null>(null);
|
||||
|
||||
const flush = useCallback(() => {
|
||||
const flush = useCallback(async () => {
|
||||
if (timerRef.current) {
|
||||
clearTimeout(timerRef.current);
|
||||
timerRef.current = null;
|
||||
@@ -23,13 +64,38 @@ export function useAutoSave({ debounceMs = 1000, onSaved }: UseAutoSaveOptions =
|
||||
if (pendingRef.current) {
|
||||
const data = pendingRef.current;
|
||||
pendingRef.current = null;
|
||||
const promise = updateMutation.mutateAsync(data);
|
||||
// Prevent unhandled rejection when called from debounce timer
|
||||
promise.catch(() => {});
|
||||
return promise;
|
||||
|
||||
try {
|
||||
await updateMutation.mutateAsync(data);
|
||||
return;
|
||||
} catch (error) {
|
||||
// Retry logic for transient failures
|
||||
if (retryCount < 2 && error instanceof Error) {
|
||||
setRetryCount(prev => prev + 1);
|
||||
pendingRef.current = data; // Restore data for retry
|
||||
|
||||
// Exponential backoff: 1s, 2s
|
||||
const delay = 1000 * Math.pow(2, retryCount);
|
||||
setTimeout(() => void flush(), delay);
|
||||
return;
|
||||
}
|
||||
|
||||
// Final failure - show user feedback
|
||||
toast.error(`Failed to save: ${error instanceof Error ? error.message : 'Unknown error'}`, {
|
||||
action: {
|
||||
label: 'Retry',
|
||||
onClick: () => {
|
||||
setRetryCount(0);
|
||||
pendingRef.current = data;
|
||||
void flush();
|
||||
},
|
||||
},
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
return Promise.resolve();
|
||||
}, [updateMutation]);
|
||||
}, [updateMutation, retryCount]);
|
||||
|
||||
const save = useCallback(
|
||||
(id: string, data: { title?: string; content?: string | null }) => {
|
||||
@@ -64,5 +130,8 @@ export function useAutoSave({ debounceMs = 1000, onSaved }: UseAutoSaveOptions =
|
||||
save,
|
||||
flush,
|
||||
isSaving: updateMutation.isPending,
|
||||
lastError,
|
||||
hasError: lastError !== null,
|
||||
retryCount,
|
||||
};
|
||||
}
|
||||
|
||||
49
packages/web/src/hooks/useLiveUpdates.ts
Normal file
49
packages/web/src/hooks/useLiveUpdates.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { toast } from 'sonner';
|
||||
import { trpc } from '@/lib/trpc';
|
||||
import { useSubscriptionWithErrorHandling } from './useSubscriptionWithErrorHandling';
|
||||
|
||||
export interface LiveUpdateRule {
|
||||
/** Event type prefix to match, e.g. 'task:' or 'agent:' */
|
||||
prefix: string;
|
||||
/** tRPC query keys to invalidate when a matching event arrives */
|
||||
invalidate: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens a single `onEvent` SSE subscription and routes events to query invalidations
|
||||
* based on prefix-matching rules. Drops heartbeat events silently.
|
||||
*
|
||||
* Encapsulates error toast + reconnect config so pages don't duplicate boilerplate.
|
||||
*/
|
||||
export function useLiveUpdates(rules: LiveUpdateRule[]) {
|
||||
const utils = trpc.useUtils();
|
||||
|
||||
return useSubscriptionWithErrorHandling(
|
||||
() => trpc.onEvent.useSubscription(undefined),
|
||||
{
|
||||
onData: (event) => {
|
||||
// Drop heartbeats and malformed events (missing type)
|
||||
if (!event?.type || event.type === '__heartbeat__') return;
|
||||
|
||||
for (const rule of rules) {
|
||||
if (event.type.startsWith(rule.prefix)) {
|
||||
for (const key of rule.invalidate) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
void (utils as any)[key]?.invalidate();
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error('Live updates disconnected. Refresh to reconnect.', {
|
||||
id: 'sub-error',
|
||||
duration: Infinity,
|
||||
});
|
||||
console.error('Live updates subscription error:', error);
|
||||
},
|
||||
onStarted: () => toast.dismiss('sub-error'),
|
||||
autoReconnect: true,
|
||||
maxReconnectAttempts: 5,
|
||||
},
|
||||
);
|
||||
}
|
||||
124
packages/web/src/hooks/useOptimisticMutation.ts
Normal file
124
packages/web/src/hooks/useOptimisticMutation.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import { useCallback } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import type { TRPCClientError } from '@trpc/client';
|
||||
|
||||
/**
|
||||
* Options for configuring optimistic mutations
|
||||
*/
|
||||
export interface OptimisticMutationOptions<TData, TVariables, TContext = unknown> {
|
||||
/** Function to apply optimistic update */
|
||||
onOptimisticUpdate?: (variables: TVariables) => TContext;
|
||||
/** Function to revert optimistic update on error */
|
||||
onRevert?: (context: TContext | undefined) => void;
|
||||
/** Function to clean up after mutation settles */
|
||||
onCleanup?: () => void;
|
||||
/** Success toast message */
|
||||
successMessage?: string;
|
||||
/** Error toast message (will be appended with actual error) */
|
||||
errorMessage?: string;
|
||||
/** Whether to show toast notifications */
|
||||
showToasts?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Higher-order function that wraps a tRPC mutation with optimistic updates
|
||||
*/
|
||||
export function useOptimisticMutation<TData, TVariables, TContext = unknown>(
|
||||
mutation: any, // tRPC mutation
|
||||
options: OptimisticMutationOptions<TData, TVariables, TContext> = {}
|
||||
) {
|
||||
const {
|
||||
onOptimisticUpdate,
|
||||
onRevert,
|
||||
onCleanup,
|
||||
successMessage,
|
||||
errorMessage,
|
||||
showToasts = true,
|
||||
} = options;
|
||||
|
||||
const mutate = useCallback(
|
||||
(variables: TVariables) => {
|
||||
let context: TContext | undefined;
|
||||
|
||||
return mutation.mutate(variables, {
|
||||
onMutate: async (vars: TVariables) => {
|
||||
if (onOptimisticUpdate) {
|
||||
context = onOptimisticUpdate(vars);
|
||||
}
|
||||
return { context };
|
||||
},
|
||||
onSuccess: () => {
|
||||
if (successMessage && showToasts) {
|
||||
toast.success(successMessage);
|
||||
}
|
||||
},
|
||||
onError: (error: TRPCClientError<any>) => {
|
||||
if (onRevert && context !== undefined) {
|
||||
onRevert(context);
|
||||
}
|
||||
|
||||
if (showToasts) {
|
||||
const message = errorMessage
|
||||
? `${errorMessage}: ${error.message}`
|
||||
: `Error: ${error.message}`;
|
||||
toast.error(message);
|
||||
}
|
||||
},
|
||||
onSettled: () => {
|
||||
if (onCleanup) {
|
||||
onCleanup();
|
||||
}
|
||||
},
|
||||
});
|
||||
},
|
||||
[mutation, onOptimisticUpdate, onRevert, onCleanup, successMessage, errorMessage, showToasts]
|
||||
);
|
||||
|
||||
const mutateAsync = useCallback(
|
||||
async (variables: TVariables): Promise<TData> => {
|
||||
let context: TContext | undefined;
|
||||
|
||||
try {
|
||||
if (onOptimisticUpdate) {
|
||||
context = onOptimisticUpdate(variables);
|
||||
}
|
||||
|
||||
const result = await mutation.mutateAsync(variables);
|
||||
|
||||
if (successMessage && showToasts) {
|
||||
toast.success(successMessage);
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
if (onRevert && context !== undefined) {
|
||||
onRevert(context);
|
||||
}
|
||||
|
||||
if (showToasts) {
|
||||
const message = errorMessage && error instanceof Error
|
||||
? `${errorMessage}: ${error.message}`
|
||||
: `Error: ${error instanceof Error ? error.message : 'Unknown error'}`;
|
||||
toast.error(message);
|
||||
}
|
||||
|
||||
throw error;
|
||||
} finally {
|
||||
if (onCleanup) {
|
||||
onCleanup();
|
||||
}
|
||||
}
|
||||
},
|
||||
[mutation, onOptimisticUpdate, onRevert, onCleanup, successMessage, errorMessage, showToasts]
|
||||
);
|
||||
|
||||
return {
|
||||
mutate,
|
||||
mutateAsync,
|
||||
isPending: mutation.isPending,
|
||||
error: mutation.error,
|
||||
isError: mutation.isError,
|
||||
isSuccess: mutation.isSuccess,
|
||||
reset: mutation.reset,
|
||||
};
|
||||
}
|
||||
149
packages/web/src/hooks/usePhaseAutoSave.ts
Normal file
149
packages/web/src/hooks/usePhaseAutoSave.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
import { useRef, useCallback, useEffect, useState } from "react";
|
||||
import { trpc } from "@/lib/trpc";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface UsePhaseAutoSaveOptions {
|
||||
debounceMs?: number;
|
||||
onSaved?: () => void;
|
||||
onError?: (error: Error) => void;
|
||||
}
|
||||
|
||||
export function usePhaseAutoSave({ debounceMs = 1000, onSaved, onError }: UsePhaseAutoSaveOptions = {}) {
|
||||
const [lastError, setLastError] = useState<Error | null>(null);
|
||||
const [retryCount, setRetryCount] = useState(0);
|
||||
const utils = trpc.useUtils();
|
||||
|
||||
const updateMutation = trpc.updatePhase.useMutation({
|
||||
onMutate: async (variables) => {
|
||||
// Cancel any outgoing refetches
|
||||
await utils.getPhase.cancel({ id: variables.id });
|
||||
await utils.listPhases.cancel();
|
||||
|
||||
// Snapshot previous values
|
||||
const previousPhase = utils.getPhase.getData({ id: variables.id });
|
||||
const previousPhases = utils.listPhases.getData();
|
||||
|
||||
// Optimistically update phase in cache
|
||||
if (previousPhase) {
|
||||
const optimisticUpdate = {
|
||||
...previousPhase,
|
||||
...(variables.content !== undefined && { content: variables.content }),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
utils.getPhase.setData({ id: variables.id }, optimisticUpdate);
|
||||
|
||||
// Also update in the phases list if present
|
||||
if (previousPhases) {
|
||||
utils.listPhases.setData(undefined,
|
||||
previousPhases.map(phase =>
|
||||
phase.id === variables.id ? optimisticUpdate : phase
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return { previousPhase, previousPhases };
|
||||
},
|
||||
onSuccess: () => {
|
||||
setLastError(null);
|
||||
setRetryCount(0);
|
||||
onSaved?.();
|
||||
},
|
||||
onError: (error, variables, context) => {
|
||||
// Revert optimistic updates
|
||||
if (context?.previousPhase) {
|
||||
utils.getPhase.setData({ id: variables.id }, context.previousPhase);
|
||||
}
|
||||
if (context?.previousPhases) {
|
||||
utils.listPhases.setData(undefined, context.previousPhases);
|
||||
}
|
||||
setLastError(error);
|
||||
onError?.(error);
|
||||
},
|
||||
// Invalidation handled globally by MutationCache
|
||||
});
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const pendingRef = useRef<{
|
||||
id: string;
|
||||
content?: string | null;
|
||||
} | null>(null);
|
||||
|
||||
const flush = useCallback(async () => {
|
||||
if (timerRef.current) {
|
||||
clearTimeout(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
if (pendingRef.current) {
|
||||
const data = pendingRef.current;
|
||||
pendingRef.current = null;
|
||||
|
||||
try {
|
||||
await updateMutation.mutateAsync(data);
|
||||
return;
|
||||
} catch (error) {
|
||||
// Retry logic for transient failures
|
||||
if (retryCount < 2 && error instanceof Error) {
|
||||
setRetryCount(prev => prev + 1);
|
||||
pendingRef.current = data; // Restore data for retry
|
||||
|
||||
// Exponential backoff: 1s, 2s
|
||||
const delay = 1000 * Math.pow(2, retryCount);
|
||||
setTimeout(() => void flush(), delay);
|
||||
return;
|
||||
}
|
||||
|
||||
// Final failure - show user feedback
|
||||
toast.error(`Failed to save phase: ${error instanceof Error ? error.message : 'Unknown error'}`, {
|
||||
action: {
|
||||
label: 'Retry',
|
||||
onClick: () => {
|
||||
setRetryCount(0);
|
||||
pendingRef.current = data;
|
||||
void flush();
|
||||
},
|
||||
},
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
return Promise.resolve();
|
||||
}, [updateMutation, retryCount]);
|
||||
|
||||
const save = useCallback(
|
||||
(id: string, data: { content?: string | null }) => {
|
||||
pendingRef.current = { id, ...data };
|
||||
|
||||
if (timerRef.current) {
|
||||
clearTimeout(timerRef.current);
|
||||
}
|
||||
|
||||
timerRef.current = setTimeout(() => void flush(), debounceMs);
|
||||
},
|
||||
[debounceMs, flush],
|
||||
);
|
||||
|
||||
// Flush on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (timerRef.current) {
|
||||
clearTimeout(timerRef.current);
|
||||
}
|
||||
if (pendingRef.current) {
|
||||
const data = pendingRef.current;
|
||||
pendingRef.current = null;
|
||||
updateMutation.mutate(data);
|
||||
}
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
return {
|
||||
save,
|
||||
flush,
|
||||
isSaving: updateMutation.isPending,
|
||||
lastError,
|
||||
hasError: lastError !== null,
|
||||
retryCount,
|
||||
};
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
import { useMemo, useCallback, useRef } from 'react';
|
||||
import { useCallback, useMemo, useRef } from 'react';
|
||||
import { trpc } from '@/lib/trpc';
|
||||
import type { Agent, PendingQuestions, Proposal } from '@codewalk-district/shared';
|
||||
import type { PendingQuestions, Proposal } from '@codewalk-district/shared';
|
||||
|
||||
export type RefineAgentState = 'none' | 'running' | 'waiting' | 'completed' | 'crashed';
|
||||
|
||||
type RefineAgent = NonNullable<ReturnType<typeof trpc.getActiveRefineAgent.useQuery>['data']>;
|
||||
|
||||
export interface SpawnRefineAgentOptions {
|
||||
initiativeId: string;
|
||||
instruction?: string;
|
||||
@@ -11,7 +13,7 @@ export interface SpawnRefineAgentOptions {
|
||||
|
||||
export interface UseRefineAgentResult {
|
||||
/** Current refine agent for the initiative */
|
||||
agent: Agent | null;
|
||||
agent: RefineAgent | null;
|
||||
/** Current state of the refine agent */
|
||||
state: RefineAgentState;
|
||||
/** Questions from the agent (when state is 'waiting') */
|
||||
@@ -32,6 +34,11 @@ export interface UseRefineAgentResult {
|
||||
isPending: boolean;
|
||||
error: Error | null;
|
||||
};
|
||||
/** Stop the current agent (kills process, clears questions) */
|
||||
stop: {
|
||||
mutate: () => void;
|
||||
isPending: boolean;
|
||||
};
|
||||
/** Dismiss the current agent (sets userDismissedAt so it disappears) */
|
||||
dismiss: () => void;
|
||||
/** Whether any queries are loading */
|
||||
@@ -49,26 +56,9 @@ export interface UseRefineAgentResult {
|
||||
export function useRefineAgent(initiativeId: string): UseRefineAgentResult {
|
||||
const utils = trpc.useUtils();
|
||||
|
||||
// Query all agents and find the active refine agent
|
||||
const agentsQuery = trpc.listAgents.useQuery();
|
||||
const agents = agentsQuery.data ?? [];
|
||||
|
||||
const agent = useMemo(() => {
|
||||
// Find the most recent refine agent for this initiative
|
||||
const candidates = agents
|
||||
.filter(
|
||||
(a) =>
|
||||
a.mode === 'refine' &&
|
||||
a.initiativeId === initiativeId &&
|
||||
['running', 'waiting_for_input', 'idle', 'crashed'].includes(a.status) &&
|
||||
!a.userDismissedAt, // Exclude dismissed agents
|
||||
)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
|
||||
);
|
||||
return candidates[0] ?? null;
|
||||
}, [agents, initiativeId]);
|
||||
// Query only the active refine agent for this initiative (server-side filtered)
|
||||
const agentQuery = trpc.getActiveRefineAgent.useQuery({ initiativeId });
|
||||
const agent = agentQuery.data ?? null;
|
||||
|
||||
const state: RefineAgentState = useMemo(() => {
|
||||
if (!agent) return 'none';
|
||||
@@ -118,7 +108,45 @@ export function useRefineAgent(initiativeId: string): UseRefineAgentResult {
|
||||
|
||||
// Spawn mutation
|
||||
const spawnMutation = trpc.spawnArchitectRefine.useMutation({
|
||||
onMutate: async ({ initiativeId, instruction }) => {
|
||||
// Cancel outgoing refetches
|
||||
await utils.listAgents.cancel();
|
||||
|
||||
// Snapshot previous value
|
||||
const previousAgents = utils.listAgents.getData();
|
||||
|
||||
// Optimistically add a temporary agent
|
||||
const tempAgent = {
|
||||
id: `temp-${Date.now()}`,
|
||||
name: 'refine',
|
||||
mode: 'refine' as const,
|
||||
status: 'running' as const,
|
||||
initiativeId,
|
||||
taskId: null,
|
||||
phaseId: null,
|
||||
provider: 'claude',
|
||||
accountId: null,
|
||||
instruction: instruction || null,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
userDismissedAt: null,
|
||||
completedAt: null,
|
||||
};
|
||||
|
||||
utils.listAgents.setData(undefined, (old = []) => [tempAgent, ...old]);
|
||||
|
||||
return { previousAgents };
|
||||
},
|
||||
onSuccess: () => {
|
||||
// Agent will appear in the list after invalidation
|
||||
},
|
||||
onError: (err, variables, context) => {
|
||||
// Revert optimistic update
|
||||
if (context?.previousAgents) {
|
||||
utils.listAgents.setData(undefined, context.previousAgents);
|
||||
}
|
||||
},
|
||||
onSettled: () => {
|
||||
void utils.listAgents.invalidate();
|
||||
},
|
||||
});
|
||||
@@ -130,14 +158,42 @@ export function useRefineAgent(initiativeId: string): UseRefineAgentResult {
|
||||
},
|
||||
});
|
||||
|
||||
// Stop mutation — kills process and clears pending questions
|
||||
const stopMutation = trpc.stopAgent.useMutation({
|
||||
onSuccess: () => {
|
||||
void utils.listAgents.invalidate();
|
||||
void utils.listWaitingAgents.invalidate();
|
||||
},
|
||||
});
|
||||
|
||||
// Dismiss mutation — sets userDismissedAt so agent disappears from the list
|
||||
const dismissMutation = trpc.dismissAgent.useMutation({
|
||||
onMutate: async ({ id }) => {
|
||||
// Cancel outgoing refetches
|
||||
await utils.listAgents.cancel();
|
||||
|
||||
// Snapshot previous value
|
||||
const previousAgents = utils.listAgents.getData();
|
||||
|
||||
// Optimistically remove the agent from the list
|
||||
utils.listAgents.setData(undefined, (old = []) =>
|
||||
old.filter(a => a.id !== id)
|
||||
);
|
||||
|
||||
return { previousAgents };
|
||||
},
|
||||
onSuccess: () => {
|
||||
// Force immediate refetch of agents to update UI
|
||||
void utils.listAgents.invalidate();
|
||||
void utils.listAgents.refetch();
|
||||
void utils.listProposals.invalidate();
|
||||
},
|
||||
onError: (err, variables, context) => {
|
||||
// Revert optimistic update
|
||||
if (context?.previousAgents) {
|
||||
utils.listAgents.setData(undefined, context.previousAgents);
|
||||
}
|
||||
},
|
||||
onSettled: () => {
|
||||
void utils.listAgents.invalidate();
|
||||
},
|
||||
});
|
||||
|
||||
// Keep mutation functions in refs so the returned spawn/resume objects are
|
||||
@@ -148,6 +204,8 @@ export function useRefineAgent(initiativeId: string): UseRefineAgentResult {
|
||||
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;
|
||||
|
||||
@@ -177,6 +235,18 @@ export function useRefineAgent(initiativeId: string): UseRefineAgentResult {
|
||||
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) {
|
||||
@@ -185,11 +255,11 @@ export function useRefineAgent(initiativeId: string): UseRefineAgentResult {
|
||||
}, []);
|
||||
|
||||
const refresh = useCallback(() => {
|
||||
void utils.listAgents.invalidate();
|
||||
void utils.getActiveRefineAgent.invalidate({ initiativeId });
|
||||
void utils.listProposals.invalidate();
|
||||
}, [utils]);
|
||||
}, [utils, initiativeId]);
|
||||
|
||||
const isLoading = agentsQuery.isLoading ||
|
||||
const isLoading = agentQuery.isLoading ||
|
||||
(state === 'waiting' && questionsQuery.isLoading) ||
|
||||
(state === 'completed' && (resultQuery.isLoading || proposalsQuery.isLoading));
|
||||
|
||||
@@ -201,6 +271,7 @@ export function useRefineAgent(initiativeId: string): UseRefineAgentResult {
|
||||
result,
|
||||
spawn,
|
||||
resume,
|
||||
stop,
|
||||
dismiss,
|
||||
isLoading,
|
||||
refresh,
|
||||
|
||||
@@ -36,7 +36,7 @@ interface SubscriptionState {
|
||||
* and ensures proper cleanup on unmount.
|
||||
*/
|
||||
export function useSubscriptionWithErrorHandling(
|
||||
subscription: () => ReturnType<typeof trpc.subscribeToEvents.useSubscription>,
|
||||
subscription: () => ReturnType<typeof trpc.onEvent.useSubscription>,
|
||||
options: UseSubscriptionWithErrorHandlingOptions = {}
|
||||
) {
|
||||
const {
|
||||
|
||||
@@ -128,3 +128,39 @@
|
||||
/* Selected cell highlight */
|
||||
.ProseMirror td.selectedCell, .ProseMirror th.selectedCell { background-color: hsl(var(--primary) / 0.08); }
|
||||
.dark .ProseMirror td.selectedCell, .dark .ProseMirror th.selectedCell { background-color: hsl(var(--primary) / 0.15); }
|
||||
|
||||
/* Inline code styling — remove prose backtick pseudo-elements */
|
||||
.ProseMirror :not(pre) > code {
|
||||
background-color: hsl(var(--muted));
|
||||
padding: 0.15em 0.35em;
|
||||
border-radius: 0.25rem;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
font-size: 0.875em;
|
||||
}
|
||||
.ProseMirror :not(pre) > code::before,
|
||||
.ProseMirror :not(pre) > code::after {
|
||||
content: none;
|
||||
}
|
||||
.dark .ProseMirror :not(pre) > code {
|
||||
background-color: hsl(var(--muted));
|
||||
}
|
||||
|
||||
/* Code block styling */
|
||||
.ProseMirror pre {
|
||||
background-color: hsl(var(--muted));
|
||||
padding: 1rem;
|
||||
border-radius: 0.375rem;
|
||||
overflow-x: auto;
|
||||
}
|
||||
.ProseMirror pre code {
|
||||
background: none;
|
||||
padding: 0;
|
||||
border-radius: 0;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
font-size: 0.875em;
|
||||
color: inherit;
|
||||
}
|
||||
.ProseMirror pre code::before,
|
||||
.ProseMirror pre code::after {
|
||||
content: none;
|
||||
}
|
||||
|
||||
124
packages/web/src/lib/invalidation.ts
Normal file
124
packages/web/src/lib/invalidation.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import type { QueryClient } from "@tanstack/react-query";
|
||||
import { MutationCache } from "@tanstack/react-query";
|
||||
import type { AnyQueryProcedure, AnyMutationProcedure } from "@trpc/server";
|
||||
import type { AppRouter } from "@codewalk-district/shared";
|
||||
|
||||
// Strip the [key: string] index signature from RouterRecord so keyof yields
|
||||
// only the literal procedure names, not `string`.
|
||||
type RemoveIndexSignature<T> = {
|
||||
[K in keyof T as string extends K ? never : K]: T[K];
|
||||
};
|
||||
|
||||
type Procedures = RemoveIndexSignature<AppRouter>;
|
||||
|
||||
type MutationName = {
|
||||
[K in keyof Procedures]: Procedures[K] extends AnyMutationProcedure ? K : never;
|
||||
}[keyof Procedures] & string;
|
||||
|
||||
type QueryName = {
|
||||
[K in keyof Procedures]: Procedures[K] extends AnyQueryProcedure ? K : never;
|
||||
}[keyof Procedures] & string;
|
||||
|
||||
/**
|
||||
* Centralized invalidation map.
|
||||
*
|
||||
* Maps each tRPC mutation name to the query keys that should be invalidated
|
||||
* when that mutation succeeds. This eliminates scattered `utils.listX.invalidate()`
|
||||
* calls across every component.
|
||||
*
|
||||
* tRPC React Query encodes keys as arrays: the first element is a tuple like
|
||||
* ["listAgents"], and the mutation key follows the same pattern. We match on
|
||||
* the procedure name (the last segment of the tRPC path).
|
||||
*/
|
||||
|
||||
const INVALIDATION_MAP: Partial<Record<MutationName, QueryName[]>> = {
|
||||
// --- Agents ---
|
||||
stopAgent: ["listAgents", "listWaitingAgents", "listMessages"],
|
||||
deleteAgent: ["listAgents"],
|
||||
dismissAgent: ["listAgents", "listProposals"],
|
||||
resumeAgent: ["listAgents", "listWaitingAgents", "listMessages"],
|
||||
respondToMessage: ["listWaitingAgents", "listMessages"],
|
||||
|
||||
// --- Architect spawns ---
|
||||
spawnArchitectRefine: ["listAgents"],
|
||||
spawnArchitectDiscuss: ["listAgents"],
|
||||
spawnArchitectBreakdown: ["listAgents"],
|
||||
spawnArchitectDecompose: ["listAgents", "listInitiativeTasks"],
|
||||
|
||||
// --- Initiatives ---
|
||||
createInitiative: ["listInitiatives"],
|
||||
updateInitiative: ["listInitiatives", "getInitiative"],
|
||||
updateInitiativeProjects: ["getInitiative"],
|
||||
|
||||
// --- Phases ---
|
||||
createPhase: ["listPhases", "listInitiativePhaseDependencies"],
|
||||
deletePhase: ["listPhases", "listInitiativeTasks", "listInitiativePhaseDependencies"],
|
||||
updatePhase: ["listPhases", "getPhase"],
|
||||
approvePhase: ["listPhases", "listInitiativeTasks"],
|
||||
queuePhase: ["listPhases"],
|
||||
createPhaseDependency: ["getPhaseDependencies", "listInitiativePhaseDependencies"],
|
||||
removePhaseDependency: ["getPhaseDependencies", "listInitiativePhaseDependencies"],
|
||||
|
||||
// --- Tasks ---
|
||||
createPhaseTask: ["listPhaseTasks", "listInitiativeTasks", "listTasks"],
|
||||
createInitiativeTask: ["listTasks", "listInitiativeTasks"],
|
||||
createChildTasks: ["listTasks", "listInitiativeTasks", "listPhaseTasks"],
|
||||
queueTask: ["listTasks", "listInitiativeTasks", "listPhaseTasks"],
|
||||
approveTask: ["listTasks", "listInitiativeTasks", "listPhaseTasks", "listPendingApprovals"],
|
||||
|
||||
// --- Proposals ---
|
||||
acceptProposal: ["listProposals", "listPages", "getPage", "listAgents", "listPhases", "listTasks"],
|
||||
acceptAllProposals: ["listProposals", "listPages", "getPage", "listAgents", "listPhases", "listTasks"],
|
||||
dismissAllProposals: ["listProposals", "listAgents"],
|
||||
|
||||
// --- Pages ---
|
||||
updatePage: ["listPages", "getPage", "getRootPage"],
|
||||
createPage: ["listPages", "getRootPage"],
|
||||
deletePage: ["listPages", "getRootPage"],
|
||||
|
||||
// --- Projects ---
|
||||
registerProject: ["listProjects"],
|
||||
|
||||
// --- Accounts ---
|
||||
addAccount: ["listAccounts"],
|
||||
removeAccount: ["listAccounts"],
|
||||
refreshAccounts: ["listAccounts"],
|
||||
markAccountExhausted: ["listAccounts"],
|
||||
};
|
||||
|
||||
/**
|
||||
* Extract the tRPC procedure name from a mutation key.
|
||||
*
|
||||
* tRPC v11 React Query keys look like: [["procedureName"], { type: "mutation" }]
|
||||
* We want just "procedureName".
|
||||
*/
|
||||
function extractProcedureName(mutationKey: unknown): MutationName | null {
|
||||
if (!Array.isArray(mutationKey)) return null;
|
||||
const first = mutationKey[0];
|
||||
if (Array.isArray(first) && typeof first[0] === "string") {
|
||||
return first[0] as MutationName;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a MutationCache with a global onSuccess handler that automatically
|
||||
* invalidates the relevant queries for each tRPC mutation.
|
||||
*/
|
||||
export function createMutationCache(queryClient: QueryClient): MutationCache {
|
||||
return new MutationCache({
|
||||
onSuccess: (_data, _variables, _context, mutation) => {
|
||||
const name = extractProcedureName(mutation.options.mutationKey);
|
||||
if (!name) return;
|
||||
|
||||
const queriesToInvalidate = INVALIDATION_MAP[name];
|
||||
if (!queriesToInvalidate) return;
|
||||
|
||||
for (const queryName of queriesToInvalidate) {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: [[queryName]],
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
168
packages/web/src/lib/parse-agent-output.ts
Normal file
168
packages/web/src/lib/parse-agent-output.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
export interface ParsedMessage {
|
||||
type:
|
||||
| "text"
|
||||
| "system"
|
||||
| "tool_call"
|
||||
| "tool_result"
|
||||
| "session_end"
|
||||
| "error";
|
||||
content: string;
|
||||
meta?: {
|
||||
toolName?: string;
|
||||
isError?: boolean;
|
||||
cost?: number;
|
||||
duration?: number;
|
||||
};
|
||||
}
|
||||
|
||||
export function formatToolCall(toolUse: any): string {
|
||||
const { name, input } = toolUse;
|
||||
|
||||
if (name === "Bash") {
|
||||
return `$ ${input.command}${input.description ? "\n# " + input.description : ""}`;
|
||||
}
|
||||
|
||||
if (name === "Read") {
|
||||
return `Read: ${input.file_path}${input.offset ? ` (lines ${input.offset}-${input.offset + (input.limit || 10)})` : ""}`;
|
||||
}
|
||||
|
||||
if (name === "Edit") {
|
||||
return `Edit: ${input.file_path}\n${input.old_string.substring(0, 100)}${input.old_string.length > 100 ? "..." : ""}\n-> ${input.new_string.substring(0, 100)}${input.new_string.length > 100 ? "..." : ""}`;
|
||||
}
|
||||
|
||||
if (name === "Write") {
|
||||
return `Write: ${input.file_path} (${input.content.length} chars)`;
|
||||
}
|
||||
|
||||
if (name === "Task") {
|
||||
return `${input.subagent_type}: ${input.description}\n${input.prompt?.substring(0, 200)}${input.prompt && input.prompt.length > 200 ? "..." : ""}`;
|
||||
}
|
||||
|
||||
return `${name}: ${JSON.stringify(input, null, 2)}`;
|
||||
}
|
||||
|
||||
export function getMessageStyling(type: ParsedMessage["type"]): string {
|
||||
switch (type) {
|
||||
case "system":
|
||||
return "mb-1";
|
||||
case "text":
|
||||
return "mb-1";
|
||||
case "tool_call":
|
||||
return "mb-2";
|
||||
case "tool_result":
|
||||
return "mb-2";
|
||||
case "error":
|
||||
return "mb-2";
|
||||
case "session_end":
|
||||
return "mb-2";
|
||||
default:
|
||||
return "mb-1";
|
||||
}
|
||||
}
|
||||
|
||||
export function parseAgentOutput(raw: string): ParsedMessage[] {
|
||||
const lines = raw.split("\n").filter(Boolean);
|
||||
const parsedMessages: ParsedMessage[] = [];
|
||||
|
||||
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 },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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,
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Legacy streaming format
|
||||
else if (event.type === "stream_event" && event.event?.delta?.text) {
|
||||
parsedMessages.push({
|
||||
type: "text",
|
||||
content: event.event.delta.text,
|
||||
});
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
@@ -2,18 +2,27 @@ import React, { useState } from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { trpc, createTRPCClient } from './lib/trpc';
|
||||
import { createMutationCache } from './lib/invalidation';
|
||||
import App from './App';
|
||||
import './index.css';
|
||||
|
||||
function Root() {
|
||||
const [queryClient] = useState(() => new QueryClient({
|
||||
const [queryClient] = useState(() => {
|
||||
// We need a reference to the QueryClient inside createMutationCache,
|
||||
// but it doesn't exist yet. Create a lazy holder.
|
||||
let qc: QueryClient;
|
||||
const mutationCache = createMutationCache(() => qc);
|
||||
qc = new QueryClient({
|
||||
mutationCache,
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
refetchOnWindowFocus: false,
|
||||
retry: 1,
|
||||
},
|
||||
},
|
||||
}));
|
||||
});
|
||||
return qc;
|
||||
});
|
||||
const [trpcClient] = useState(createTRPCClient);
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState } from "react";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||
import { AlertCircle, RefreshCw } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card } from "@/components/ui/card";
|
||||
@@ -8,62 +8,99 @@ import { Skeleton } from "@/components/Skeleton";
|
||||
import { toast } from "sonner";
|
||||
import { trpc } from "@/lib/trpc";
|
||||
import { AgentOutputViewer } from "@/components/AgentOutputViewer";
|
||||
import { AgentActions } from "@/components/AgentActions";
|
||||
import { formatRelativeTime } from "@/lib/utils";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useSubscriptionWithErrorHandling } from "@/hooks";
|
||||
import { StatusDot } from "@/components/StatusDot";
|
||||
import { useLiveUpdates } from "@/hooks";
|
||||
|
||||
export const Route = createFileRoute("/agents")({
|
||||
component: AgentsPage,
|
||||
});
|
||||
|
||||
type StatusFilter = "all" | "running" | "questions" | "exited" | "dismissed";
|
||||
|
||||
function AgentsPage() {
|
||||
const [selectedAgentId, setSelectedAgentId] = useState<string | null>(null);
|
||||
const [filter, setFilter] = useState<StatusFilter>("all");
|
||||
const navigate = useNavigate();
|
||||
|
||||
// Live updates: invalidate agents on agent events with robust error handling
|
||||
// Live updates
|
||||
const utils = trpc.useUtils();
|
||||
const subscription = useSubscriptionWithErrorHandling(
|
||||
() => trpc.onAgentUpdate.useSubscription(undefined),
|
||||
{
|
||||
onData: () => {
|
||||
void utils.listAgents.invalidate();
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error("Live updates disconnected. Refresh to reconnect.", {
|
||||
id: "sub-error",
|
||||
duration: Infinity,
|
||||
});
|
||||
console.error('Agent updates subscription error:', error);
|
||||
},
|
||||
onStarted: () => {
|
||||
// Clear any existing error toasts when reconnecting
|
||||
toast.dismiss("sub-error");
|
||||
},
|
||||
autoReconnect: true,
|
||||
maxReconnectAttempts: 5,
|
||||
}
|
||||
);
|
||||
useLiveUpdates([
|
||||
{ prefix: 'agent:', invalidate: ['listAgents'] },
|
||||
]);
|
||||
|
||||
// Data fetching
|
||||
// Data
|
||||
const agentsQuery = trpc.listAgents.useQuery();
|
||||
|
||||
// Mutations
|
||||
const stopMutation = trpc.stopAgent.useMutation({
|
||||
onSuccess: (data) => {
|
||||
toast.success(`Stopped ${data.name}`);
|
||||
},
|
||||
onError: (err) => toast.error(`Failed to stop: ${err.message}`),
|
||||
});
|
||||
|
||||
const deleteMutation = trpc.deleteAgent.useMutation({
|
||||
onSuccess: (data) => {
|
||||
if (selectedAgentId) {
|
||||
const deleted = agents.find(
|
||||
(a) => a.name === data.name || a.id === selectedAgentId
|
||||
);
|
||||
if (deleted) setSelectedAgentId(null);
|
||||
}
|
||||
toast.success(`Deleted ${data.name}`);
|
||||
},
|
||||
onError: (err) => toast.error(`Failed to delete: ${err.message}`),
|
||||
});
|
||||
|
||||
const dismissMutation = trpc.dismissAgent.useMutation({
|
||||
onSuccess: (data) => {
|
||||
toast.success(`Dismissed ${data.name}`);
|
||||
},
|
||||
onError: (err) => toast.error(`Failed to dismiss: ${err.message}`),
|
||||
});
|
||||
|
||||
// Handlers
|
||||
function handleRefresh() {
|
||||
void utils.listAgents.invalidate();
|
||||
}
|
||||
|
||||
function handleStop(id: string) {
|
||||
stopMutation.mutate({ id });
|
||||
}
|
||||
|
||||
function handleDelete(id: string) {
|
||||
if (!window.confirm("Delete this agent? This cannot be undone.")) return;
|
||||
setSelectedAgentId((prev) => (prev === id ? null : prev));
|
||||
deleteMutation.mutate({ id });
|
||||
}
|
||||
|
||||
function handleDismiss(id: string) {
|
||||
dismissMutation.mutate({ id });
|
||||
}
|
||||
|
||||
function handleGoToInbox() {
|
||||
void navigate({ to: "/inbox" });
|
||||
}
|
||||
|
||||
// Loading state
|
||||
if (agentsQuery.isLoading) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div
|
||||
style={{ height: "calc(100vh - 7rem)" }}
|
||||
className="flex flex-col gap-4"
|
||||
>
|
||||
<div className="flex items-center justify-between shrink-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<Skeleton className="h-6 w-20" />
|
||||
<Skeleton className="h-5 w-8 rounded-full" />
|
||||
</div>
|
||||
<Skeleton className="h-8 w-20" />
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[300px_1fr]">
|
||||
<div className="space-y-2">
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-[320px_1fr] min-h-0 flex-1">
|
||||
<div className="space-y-2 overflow-hidden">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<Card key={i} className="p-3">
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -73,7 +110,7 @@ function AgentsPage() {
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
<Skeleton className="h-96 rounded-lg" />
|
||||
<Skeleton className="h-full rounded-lg" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -85,7 +122,8 @@ function AgentsPage() {
|
||||
<div className="flex flex-col items-center justify-center gap-4 py-12">
|
||||
<AlertCircle className="h-8 w-8 text-destructive" />
|
||||
<p className="text-sm text-destructive">
|
||||
Failed to load agents: {agentsQuery.error?.message ?? "Unknown error"}
|
||||
Failed to load agents:{" "}
|
||||
{agentsQuery.error?.message ?? "Unknown error"}
|
||||
</p>
|
||||
<Button variant="outline" size="sm" onClick={handleRefresh}>
|
||||
Retry
|
||||
@@ -99,9 +137,53 @@ function AgentsPage() {
|
||||
? agents.find((a) => a.id === selectedAgentId)
|
||||
: null;
|
||||
|
||||
// Filter counts
|
||||
const counts = {
|
||||
all: agents.filter((a) => !a.userDismissedAt).length,
|
||||
running: agents.filter((a) => a.status === "running").length,
|
||||
questions: agents.filter((a) => a.status === "waiting_for_input").length,
|
||||
exited: agents.filter((a) =>
|
||||
["stopped", "crashed", "idle"].includes(a.status)
|
||||
).length,
|
||||
dismissed: agents.filter((a) => a.userDismissedAt).length,
|
||||
};
|
||||
|
||||
// Filter + sort
|
||||
const filtered = agents
|
||||
.filter((agent) => {
|
||||
switch (filter) {
|
||||
case "all":
|
||||
return !agent.userDismissedAt;
|
||||
case "running":
|
||||
return agent.status === "running";
|
||||
case "questions":
|
||||
return agent.status === "waiting_for_input";
|
||||
case "exited":
|
||||
return ["stopped", "crashed", "idle"].includes(agent.status);
|
||||
case "dismissed":
|
||||
return !!agent.userDismissedAt;
|
||||
}
|
||||
})
|
||||
.sort(
|
||||
(a, b) =>
|
||||
new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()
|
||||
);
|
||||
|
||||
const filterOptions: { value: StatusFilter; label: string }[] = [
|
||||
{ value: "all", label: "All" },
|
||||
{ value: "running", label: "Running" },
|
||||
{ value: "questions", label: "Questions" },
|
||||
{ value: "exited", label: "Exited" },
|
||||
{ value: "dismissed", label: "Dismissed" },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Header */}
|
||||
<div
|
||||
style={{ height: "calc(100vh - 7rem)" }}
|
||||
className="flex flex-col gap-4"
|
||||
>
|
||||
{/* Header + Filters */}
|
||||
<div className="shrink-0 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<h1 className="text-lg font-semibold">Agents</h1>
|
||||
@@ -112,17 +194,39 @@ function AgentsPage() {
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{filterOptions.map((opt) => (
|
||||
<Button
|
||||
key={opt.value}
|
||||
variant={filter === opt.value ? "default" : "outline"}
|
||||
size="sm"
|
||||
className="h-7 px-2 text-xs"
|
||||
onClick={() => setFilter(opt.value)}
|
||||
>
|
||||
{opt.label}
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="ml-1.5 h-4 min-w-4 px-1 text-[10px]"
|
||||
>
|
||||
{counts[opt.value]}
|
||||
</Badge>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Two-column layout */}
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[300px_1fr]">
|
||||
{/* Two-panel layout */}
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-[320px_1fr] min-h-0 flex-1">
|
||||
{/* Left: Agent List */}
|
||||
<div className="space-y-2">
|
||||
{agents.length === 0 ? (
|
||||
<div className="overflow-y-auto space-y-2">
|
||||
{filtered.length === 0 ? (
|
||||
<div className="rounded-lg border border-dashed p-8 text-center">
|
||||
<p className="text-sm text-muted-foreground">No agents found</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No agents match this filter
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
agents.map((agent) => (
|
||||
filtered.map((agent) => (
|
||||
<Card
|
||||
key={agent.id}
|
||||
className={cn(
|
||||
@@ -133,7 +237,7 @@ function AgentsPage() {
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<StatusDot status={agent.status} />
|
||||
<StatusDot status={agent.status} size="sm" />
|
||||
<span className="truncate text-sm font-medium">
|
||||
{agent.name}
|
||||
</span>
|
||||
@@ -145,10 +249,35 @@ function AgentsPage() {
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{agent.mode}
|
||||
</Badge>
|
||||
{/* Action dropdown */}
|
||||
<div onClick={(e) => e.stopPropagation()}>
|
||||
<AgentActions
|
||||
agentId={agent.id}
|
||||
status={agent.status}
|
||||
isDismissed={!!agent.userDismissedAt}
|
||||
onStop={handleStop}
|
||||
onDelete={handleDelete}
|
||||
onDismiss={handleDismiss}
|
||||
onGoToInbox={handleGoToInbox}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">
|
||||
{formatRelativeTime(String(agent.createdAt))}
|
||||
</div>
|
||||
<div className="mt-1 flex items-center justify-between">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatRelativeTime(String(agent.updatedAt))}
|
||||
</span>
|
||||
{agent.status === "waiting_for_input" && (
|
||||
<span
|
||||
className="text-xs text-yellow-600 hover:underline cursor-pointer"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleGoToInbox();
|
||||
}}
|
||||
>
|
||||
Answer questions →
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
))
|
||||
@@ -156,10 +285,14 @@ function AgentsPage() {
|
||||
</div>
|
||||
|
||||
{/* Right: Output Viewer */}
|
||||
<div className="min-h-0">
|
||||
{selectedAgent ? (
|
||||
<AgentOutputViewer agentId={selectedAgent.id} agentName={selectedAgent.name} />
|
||||
<AgentOutputViewer
|
||||
agentId={selectedAgent.id}
|
||||
agentName={selectedAgent.name}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex items-center justify-center rounded-lg border border-dashed p-8">
|
||||
<div className="flex h-full items-center justify-center rounded-lg border border-dashed">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Select an agent to view output
|
||||
</p>
|
||||
@@ -167,30 +300,6 @@ function AgentsPage() {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Components
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function StatusDot({ status }: { status: string }) {
|
||||
const colors: Record<string, string> = {
|
||||
running: "bg-green-500",
|
||||
waiting_for_input: "bg-yellow-500",
|
||||
idle: "bg-zinc-400",
|
||||
stopped: "bg-zinc-400",
|
||||
crashed: "bg-red-500",
|
||||
};
|
||||
return (
|
||||
<span
|
||||
className={cn("h-2 w-2 rounded-full shrink-0", colors[status] ?? "bg-zinc-400")}
|
||||
title={status}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import { useState } from "react";
|
||||
import { createFileRoute, Link } from "@tanstack/react-router";
|
||||
import { AlertCircle, ChevronLeft } from "lucide-react";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { AlertCircle } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Skeleton } from "@/components/Skeleton";
|
||||
import { toast } from "sonner";
|
||||
import { trpc } from "@/lib/trpc";
|
||||
import { InboxList } from "@/components/InboxList";
|
||||
import { QuestionForm } from "@/components/QuestionForm";
|
||||
import { formatRelativeTime } from "@/lib/utils";
|
||||
import { InboxDetailPanel } from "@/components/InboxDetailPanel";
|
||||
import { useLiveUpdates } from "@/hooks";
|
||||
|
||||
export const Route = createFileRoute("/inbox")({
|
||||
component: InboxPage,
|
||||
@@ -17,20 +17,12 @@ export const Route = createFileRoute("/inbox")({
|
||||
function InboxPage() {
|
||||
const [selectedAgentId, setSelectedAgentId] = useState<string | null>(null);
|
||||
|
||||
// Live updates: invalidate inbox queries on agent events
|
||||
// Single SSE stream for live updates
|
||||
useLiveUpdates([
|
||||
{ prefix: 'agent:', invalidate: ['listWaitingAgents', 'listMessages'] },
|
||||
]);
|
||||
|
||||
const utils = trpc.useUtils();
|
||||
trpc.onAgentUpdate.useSubscription(undefined, {
|
||||
onData: () => {
|
||||
void utils.listWaitingAgents.invalidate();
|
||||
void utils.listMessages.invalidate();
|
||||
},
|
||||
onError: () => {
|
||||
toast.error("Live updates disconnected. Refresh to reconnect.", {
|
||||
id: "sub-error",
|
||||
duration: Infinity,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// Data fetching
|
||||
const agentsQuery = trpc.listWaitingAgents.useQuery();
|
||||
@@ -43,8 +35,6 @@ function InboxPage() {
|
||||
// Mutations
|
||||
const resumeAgentMutation = trpc.resumeAgent.useMutation({
|
||||
onSuccess: () => {
|
||||
void utils.listWaitingAgents.invalidate();
|
||||
void utils.listMessages.invalidate();
|
||||
setSelectedAgentId(null);
|
||||
toast.success("Answer submitted");
|
||||
},
|
||||
@@ -53,10 +43,18 @@ function InboxPage() {
|
||||
},
|
||||
});
|
||||
|
||||
const dismissQuestionsMutation = trpc.stopAgent.useMutation({
|
||||
onSuccess: () => {
|
||||
setSelectedAgentId(null);
|
||||
toast.success("Questions dismissed");
|
||||
},
|
||||
onError: () => {
|
||||
toast.error("Failed to dismiss questions");
|
||||
},
|
||||
});
|
||||
|
||||
const respondToMessageMutation = trpc.respondToMessage.useMutation({
|
||||
onSuccess: () => {
|
||||
void utils.listWaitingAgents.invalidate();
|
||||
void utils.listMessages.invalidate();
|
||||
setSelectedAgentId(null);
|
||||
toast.success("Response sent");
|
||||
},
|
||||
@@ -95,6 +93,11 @@ function InboxPage() {
|
||||
resumeAgentMutation.mutate({ id: selectedAgentId, answers });
|
||||
}
|
||||
|
||||
function handleDismissQuestions() {
|
||||
if (!selectedAgentId) return;
|
||||
dismissQuestionsMutation.mutate({ id: selectedAgentId });
|
||||
}
|
||||
|
||||
function handleDismiss() {
|
||||
if (!selectedMessage) return;
|
||||
respondToMessageMutation.mutate({
|
||||
@@ -175,7 +178,7 @@ function InboxPage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[1fr_400px]">
|
||||
{/* Left: Inbox List — hidden on mobile when agent selected */}
|
||||
{/* Left: Inbox List -- hidden on mobile when agent selected */}
|
||||
<div className={selectedAgent ? "hidden lg:block" : undefined}>
|
||||
<InboxList
|
||||
agents={serializedAgents}
|
||||
@@ -188,130 +191,57 @@ function InboxPage() {
|
||||
|
||||
{/* Right: Detail Panel */}
|
||||
{selectedAgent && (
|
||||
<div className="space-y-4 rounded-lg border border-border p-4">
|
||||
{/* Mobile back button */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="lg:hidden"
|
||||
onClick={() => setSelectedAgentId(null)}
|
||||
>
|
||||
<ChevronLeft className="mr-1 h-4 w-4" />
|
||||
Back to list
|
||||
</Button>
|
||||
|
||||
{/* Detail Header */}
|
||||
<div className="border-b border-border pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-bold">
|
||||
{selectedAgent.name}{" "}
|
||||
<span className="font-normal text-muted-foreground">
|
||||
→ You
|
||||
</span>
|
||||
</h3>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatRelativeTime(String(selectedAgent.updatedAt))}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Task:{" "}
|
||||
{selectedAgent.taskId ? (
|
||||
<Link
|
||||
to="/initiatives"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
{selectedAgent.taskId}
|
||||
</Link>
|
||||
) : (
|
||||
"—"
|
||||
)}
|
||||
</p>
|
||||
{selectedAgent.taskId && (
|
||||
<Link
|
||||
to="/initiatives"
|
||||
className="mt-1 inline-block text-xs text-primary hover:underline"
|
||||
>
|
||||
View in context →
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Question Form or Notification Content */}
|
||||
{questionsQuery.isLoading && (
|
||||
<div className="py-4 text-center text-sm text-muted-foreground">
|
||||
Loading questions...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{questionsQuery.isError && (
|
||||
<div className="py-4 text-center text-sm text-destructive">
|
||||
Failed to load questions: {questionsQuery.error.message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{pendingQuestions &&
|
||||
pendingQuestions.questions.length > 0 && (
|
||||
<QuestionForm
|
||||
questions={pendingQuestions.questions.map((q) => ({
|
||||
<InboxDetailPanel
|
||||
agent={{
|
||||
id: selectedAgent.id,
|
||||
name: selectedAgent.name,
|
||||
status: selectedAgent.status,
|
||||
taskId: selectedAgent.taskId,
|
||||
updatedAt: String(selectedAgent.updatedAt),
|
||||
}}
|
||||
message={
|
||||
selectedMessage
|
||||
? {
|
||||
id: selectedMessage.id,
|
||||
content: selectedMessage.content,
|
||||
requiresResponse: selectedMessage.requiresResponse,
|
||||
}
|
||||
: null
|
||||
}
|
||||
questions={
|
||||
pendingQuestions
|
||||
? pendingQuestions.questions.map((q) => ({
|
||||
id: q.id,
|
||||
question: q.question,
|
||||
options: q.options,
|
||||
multiSelect: q.multiSelect,
|
||||
}))}
|
||||
onSubmit={handleSubmitAnswers}
|
||||
onCancel={() => setSelectedAgentId(null)}
|
||||
}))
|
||||
: null
|
||||
}
|
||||
isLoadingQuestions={questionsQuery.isLoading}
|
||||
questionsError={
|
||||
questionsQuery.isError ? questionsQuery.error.message : null
|
||||
}
|
||||
onBack={() => setSelectedAgentId(null)}
|
||||
onSubmitAnswers={handleSubmitAnswers}
|
||||
onDismissQuestions={handleDismissQuestions}
|
||||
onDismissMessage={handleDismiss}
|
||||
isSubmitting={resumeAgentMutation.isPending}
|
||||
isDismissingQuestions={dismissQuestionsMutation.isPending}
|
||||
isDismissingMessage={respondToMessageMutation.isPending}
|
||||
submitError={
|
||||
resumeAgentMutation.isError
|
||||
? resumeAgentMutation.error.message
|
||||
: null
|
||||
}
|
||||
dismissMessageError={
|
||||
respondToMessageMutation.isError
|
||||
? respondToMessageMutation.error.message
|
||||
: null
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{resumeAgentMutation.isError && (
|
||||
<p className="text-sm text-destructive">
|
||||
Error: {resumeAgentMutation.error.message}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Notification message (no questions / requiresResponse=false) */}
|
||||
{selectedMessage &&
|
||||
!selectedMessage.requiresResponse &&
|
||||
!questionsQuery.isLoading && (
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm">{selectedMessage.content}</p>
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleDismiss}
|
||||
disabled={respondToMessageMutation.isPending}
|
||||
>
|
||||
{respondToMessageMutation.isPending
|
||||
? "Dismissing..."
|
||||
: "Dismiss"}
|
||||
</Button>
|
||||
</div>
|
||||
{respondToMessageMutation.isError && (
|
||||
<p className="text-sm text-destructive">
|
||||
Error: {respondToMessageMutation.error.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* No questions and requires response — message content only */}
|
||||
{selectedMessage &&
|
||||
selectedMessage.requiresResponse &&
|
||||
pendingQuestions &&
|
||||
pendingQuestions.questions.length === 0 &&
|
||||
!questionsQuery.isLoading && (
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm">{selectedMessage.content}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Waiting for structured questions...
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty detail panel placeholder */}
|
||||
{!selectedAgent && (
|
||||
<div className="hidden items-center justify-center rounded-lg border border-dashed border-border p-8 lg:flex">
|
||||
@@ -324,8 +254,3 @@ function InboxPage() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@@ -1,98 +1,42 @@
|
||||
import { useState } from "react";
|
||||
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||
import { AlertCircle } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/Skeleton";
|
||||
import { toast } from "sonner";
|
||||
import { trpc } from "@/lib/trpc";
|
||||
import { InitiativeHeader } from "@/components/InitiativeHeader";
|
||||
import { ContentTab } from "@/components/editor/ContentTab";
|
||||
import { ExecutionTab } from "@/components/ExecutionTab";
|
||||
import { useSubscriptionWithErrorHandling } from "@/hooks";
|
||||
import { ReviewTab } from "@/components/review";
|
||||
import { PipelineTab } from "@/components/pipeline";
|
||||
import { useLiveUpdates } from "@/hooks";
|
||||
|
||||
type Tab = "content" | "breakdown" | "execution" | "review";
|
||||
const TABS: Tab[] = ["content", "breakdown", "execution", "review"];
|
||||
|
||||
export const Route = createFileRoute("/initiatives/$id")({
|
||||
component: InitiativeDetailPage,
|
||||
validateSearch: (search: Record<string, unknown>): { tab: Tab } => ({
|
||||
tab: TABS.includes(search.tab as Tab) ? (search.tab as Tab) : "content",
|
||||
}),
|
||||
});
|
||||
|
||||
type Tab = "content" | "execution";
|
||||
|
||||
function InitiativeDetailPage() {
|
||||
const { id } = Route.useParams();
|
||||
const { tab: activeTab } = Route.useSearch();
|
||||
const navigate = useNavigate();
|
||||
const [activeTab, setActiveTab] = useState<Tab>("content");
|
||||
|
||||
// Live updates: keep subscriptions at page level so they work across both tabs
|
||||
const utils = trpc.useUtils();
|
||||
|
||||
// Task updates subscription with robust error handling
|
||||
useSubscriptionWithErrorHandling(
|
||||
() => trpc.onTaskUpdate.useSubscription(undefined),
|
||||
{
|
||||
onData: () => {
|
||||
void utils.listPhases.invalidate();
|
||||
void utils.listTasks.invalidate();
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error("Live updates disconnected. Refresh to reconnect.", {
|
||||
id: "sub-error",
|
||||
duration: Infinity,
|
||||
});
|
||||
console.error('Task updates subscription error:', error);
|
||||
},
|
||||
onStarted: () => toast.dismiss("sub-error"),
|
||||
autoReconnect: true,
|
||||
maxReconnectAttempts: 5,
|
||||
}
|
||||
);
|
||||
|
||||
// Agent updates subscription with robust error handling
|
||||
useSubscriptionWithErrorHandling(
|
||||
() => trpc.onAgentUpdate.useSubscription(undefined),
|
||||
{
|
||||
onData: () => {
|
||||
void utils.listAgents.invalidate();
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error("Live updates disconnected. Refresh to reconnect.", {
|
||||
id: "sub-error",
|
||||
duration: Infinity,
|
||||
});
|
||||
console.error('Agent updates subscription error:', error);
|
||||
},
|
||||
onStarted: () => toast.dismiss("sub-error"),
|
||||
autoReconnect: true,
|
||||
maxReconnectAttempts: 5,
|
||||
}
|
||||
);
|
||||
|
||||
// Page updates subscription with robust error handling
|
||||
useSubscriptionWithErrorHandling(
|
||||
() => trpc.onPageUpdate.useSubscription(undefined),
|
||||
{
|
||||
onData: () => {
|
||||
void utils.listPages.invalidate();
|
||||
void utils.getPage.invalidate();
|
||||
void utils.getRootPage.invalidate();
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error("Live updates disconnected. Refresh to reconnect.", {
|
||||
id: "sub-error",
|
||||
duration: Infinity,
|
||||
});
|
||||
console.error('Page updates subscription error:', error);
|
||||
},
|
||||
onStarted: () => toast.dismiss("sub-error"),
|
||||
autoReconnect: true,
|
||||
maxReconnectAttempts: 5,
|
||||
}
|
||||
);
|
||||
// Single SSE stream for all live updates
|
||||
useLiveUpdates([
|
||||
{ prefix: 'task:', invalidate: ['listPhases', 'listTasks', 'listInitiativeTasks'] },
|
||||
{ prefix: 'phase:', invalidate: ['listPhases', 'listTasks', 'listInitiativePhaseDependencies'] },
|
||||
{ prefix: 'agent:', invalidate: ['listAgents'] },
|
||||
{ prefix: 'page:', invalidate: ['listPages', 'getPage', 'getRootPage'] },
|
||||
]);
|
||||
|
||||
// tRPC queries
|
||||
const initiativeQuery = trpc.getInitiative.useQuery({ id });
|
||||
const phasesQuery = trpc.listPhases.useQuery(
|
||||
{ initiativeId: id },
|
||||
{ enabled: !!initiativeQuery.data },
|
||||
);
|
||||
const phasesQuery = trpc.listPhases.useQuery({ initiativeId: id });
|
||||
const depsQuery = trpc.listInitiativePhaseDependencies.useQuery({ initiativeId: id });
|
||||
|
||||
// Loading state
|
||||
if (initiativeQuery.isLoading) {
|
||||
@@ -162,38 +106,45 @@ function InitiativeDetailPage() {
|
||||
|
||||
{/* Tab bar */}
|
||||
<div className="flex gap-1 border-b border-border">
|
||||
{TABS.map((tab) => (
|
||||
<button
|
||||
onClick={() => setActiveTab("content")}
|
||||
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
|
||||
activeTab === "content"
|
||||
key={tab}
|
||||
onClick={() =>
|
||||
navigate({
|
||||
search: { tab },
|
||||
replace: true,
|
||||
})
|
||||
}
|
||||
className={`px-4 py-2 text-sm font-medium capitalize border-b-2 transition-colors ${
|
||||
activeTab === tab
|
||||
? "border-primary text-foreground"
|
||||
: "border-transparent text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
Content
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab("execution")}
|
||||
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
|
||||
activeTab === "execution"
|
||||
? "border-primary text-foreground"
|
||||
: "border-transparent text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
Execution
|
||||
{tab}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Tab content */}
|
||||
{activeTab === "content" && <ContentTab initiativeId={id} initiativeName={initiative.name} />}
|
||||
{activeTab === "execution" && (
|
||||
{activeTab === "breakdown" && (
|
||||
<ExecutionTab
|
||||
initiativeId={id}
|
||||
phases={phases}
|
||||
phasesLoading={phasesQuery.isLoading}
|
||||
phasesLoaded={phasesQuery.isSuccess}
|
||||
dependencyEdges={depsQuery.data ?? []}
|
||||
/>
|
||||
)}
|
||||
{activeTab === "execution" && (
|
||||
<PipelineTab
|
||||
initiativeId={id}
|
||||
phases={phases}
|
||||
phasesLoading={phasesQuery.isLoading}
|
||||
/>
|
||||
)}
|
||||
{activeTab === "review" && <ReviewTab initiativeId={id} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,10 +2,9 @@ import { useState } from "react";
|
||||
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||
import { Plus } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { toast } from "sonner";
|
||||
import { trpc } from "@/lib/trpc";
|
||||
import { InitiativeList } from "@/components/InitiativeList";
|
||||
import { CreateInitiativeDialog } from "@/components/CreateInitiativeDialog";
|
||||
import { useLiveUpdates } from "@/hooks";
|
||||
|
||||
export const Route = createFileRoute("/initiatives/")({
|
||||
component: DashboardPage,
|
||||
@@ -25,20 +24,11 @@ function DashboardPage() {
|
||||
const [statusFilter, setStatusFilter] = useState<StatusFilter>("all");
|
||||
const [createDialogOpen, setCreateDialogOpen] = useState(false);
|
||||
|
||||
// Live updates: invalidate dashboard queries on task/phase events
|
||||
const utils = trpc.useUtils();
|
||||
trpc.onTaskUpdate.useSubscription(undefined, {
|
||||
onData: () => {
|
||||
void utils.listInitiatives.invalidate();
|
||||
void utils.listPhases.invalidate();
|
||||
},
|
||||
onError: () => {
|
||||
toast.error("Live updates disconnected. Refresh to reconnect.", {
|
||||
id: "sub-error",
|
||||
duration: Infinity,
|
||||
});
|
||||
},
|
||||
});
|
||||
// Single SSE stream for live updates
|
||||
useLiveUpdates([
|
||||
{ prefix: 'task:', invalidate: ['listInitiatives', 'listPhases'] },
|
||||
{ prefix: 'phase:', invalidate: ['listInitiatives', 'listPhases'] },
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
|
||||
@@ -2,24 +2,19 @@ import { createFileRoute } from '@tanstack/react-router'
|
||||
import {
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
AlertTriangle,
|
||||
RefreshCw,
|
||||
Server,
|
||||
} from 'lucide-react'
|
||||
import { trpc } from '@/lib/trpc'
|
||||
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Skeleton } from '@/components/Skeleton'
|
||||
import { AccountCard } from '@/components/AccountCard'
|
||||
|
||||
export const Route = createFileRoute('/settings/health')({
|
||||
component: HealthCheckPage,
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function formatUptime(seconds: number): string {
|
||||
const d = Math.floor(seconds / 86400)
|
||||
const h = Math.floor((seconds % 86400) / 3600)
|
||||
@@ -34,73 +29,6 @@ function formatUptime(seconds: number): string {
|
||||
return parts.join(' ')
|
||||
}
|
||||
|
||||
function formatResetTime(isoDate: string): string {
|
||||
const now = Date.now()
|
||||
const target = new Date(isoDate).getTime()
|
||||
const diffMs = target - now
|
||||
if (diffMs <= 0) return 'now'
|
||||
|
||||
const totalMinutes = Math.floor(diffMs / 60_000)
|
||||
const totalHours = Math.floor(totalMinutes / 60)
|
||||
const totalDays = Math.floor(totalHours / 24)
|
||||
|
||||
if (totalDays > 0) {
|
||||
const remainingHours = totalHours - totalDays * 24
|
||||
return `in ${totalDays}d ${remainingHours}h`
|
||||
}
|
||||
const remainingMinutes = totalMinutes - totalHours * 60
|
||||
return `in ${totalHours}h ${remainingMinutes}m`
|
||||
}
|
||||
|
||||
function capitalize(s: string): string {
|
||||
return s.charAt(0).toUpperCase() + s.slice(1)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Usage bar
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function UsageBar({
|
||||
label,
|
||||
utilization,
|
||||
resetsAt,
|
||||
}: {
|
||||
label: string
|
||||
utilization: number
|
||||
resetsAt: string | null
|
||||
}) {
|
||||
const color =
|
||||
utilization >= 90
|
||||
? 'bg-destructive'
|
||||
: utilization >= 70
|
||||
? 'bg-yellow-500'
|
||||
: 'bg-green-500'
|
||||
const resetText = resetsAt ? formatResetTime(resetsAt) : null
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<span className="w-20 shrink-0 text-muted-foreground">{label}</span>
|
||||
<div className="h-2 flex-1 rounded-full bg-muted">
|
||||
<div
|
||||
className={`h-2 rounded-full ${color}`}
|
||||
style={{ width: `${Math.min(utilization, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="w-12 shrink-0 text-right">
|
||||
{utilization.toFixed(0)}%
|
||||
</span>
|
||||
{resetText && (
|
||||
<span className="shrink-0 text-muted-foreground">
|
||||
resets {resetText}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Page component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function HealthCheckPage() {
|
||||
const healthQuery = trpc.systemHealthCheck.useQuery(undefined, {
|
||||
refetchInterval: 30_000,
|
||||
@@ -242,142 +170,3 @@ function HealthCheckPage() {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Account card
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type AccountData = {
|
||||
id: string
|
||||
email: string
|
||||
provider: string
|
||||
credentialsValid: boolean
|
||||
tokenValid: boolean
|
||||
tokenExpiresAt: string | null
|
||||
subscriptionType: string | null
|
||||
error: string | null
|
||||
usage: {
|
||||
five_hour: { utilization: number; resets_at: string | null } | null
|
||||
seven_day: { utilization: number; resets_at: string | null } | null
|
||||
seven_day_sonnet: { utilization: number; resets_at: string | null } | null
|
||||
seven_day_opus: { utilization: number; resets_at: string | null } | null
|
||||
extra_usage: {
|
||||
is_enabled: boolean
|
||||
monthly_limit: number | null
|
||||
used_credits: number | null
|
||||
utilization: number | null
|
||||
} | null
|
||||
} | null
|
||||
isExhausted: boolean
|
||||
exhaustedUntil: string | null
|
||||
lastUsedAt: string | null
|
||||
agentCount: number
|
||||
activeAgentCount: number
|
||||
}
|
||||
|
||||
function AccountCard({ account }: { account: AccountData }) {
|
||||
const statusIcon = !account.credentialsValid ? (
|
||||
<XCircle className="h-5 w-5 shrink-0 text-destructive" />
|
||||
) : account.isExhausted ? (
|
||||
<AlertTriangle className="h-5 w-5 shrink-0 text-yellow-500" />
|
||||
) : (
|
||||
<CheckCircle2 className="h-5 w-5 shrink-0 text-green-500" />
|
||||
)
|
||||
|
||||
const statusText = !account.credentialsValid
|
||||
? 'Invalid credentials'
|
||||
: account.isExhausted
|
||||
? `Exhausted until ${account.exhaustedUntil ? new Date(account.exhaustedUntil).toLocaleTimeString() : 'unknown'}`
|
||||
: 'Available'
|
||||
|
||||
const usage = account.usage
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="space-y-3 py-4">
|
||||
{/* Header row */}
|
||||
<div className="flex items-start gap-3">
|
||||
{statusIcon}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-sm font-medium">{account.email}</span>
|
||||
<Badge variant="outline">{account.provider}</Badge>
|
||||
{account.subscriptionType && (
|
||||
<Badge variant="secondary">
|
||||
{capitalize(account.subscriptionType)}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-1 flex items-center gap-3 text-xs text-muted-foreground">
|
||||
<span>
|
||||
{account.agentCount} agent{account.agentCount !== 1 ? 's' : ''}{' '}
|
||||
({account.activeAgentCount} active)
|
||||
</span>
|
||||
<span>{statusText}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Usage bars */}
|
||||
{usage && (
|
||||
<div className="space-y-1.5 pl-8">
|
||||
{usage.five_hour && (
|
||||
<UsageBar
|
||||
label="Session (5h)"
|
||||
utilization={usage.five_hour.utilization}
|
||||
resetsAt={usage.five_hour.resets_at}
|
||||
/>
|
||||
)}
|
||||
{usage.seven_day && (
|
||||
<UsageBar
|
||||
label="Weekly (7d)"
|
||||
utilization={usage.seven_day.utilization}
|
||||
resetsAt={usage.seven_day.resets_at}
|
||||
/>
|
||||
)}
|
||||
{usage.seven_day_sonnet &&
|
||||
usage.seven_day_sonnet.utilization > 0 && (
|
||||
<UsageBar
|
||||
label="Sonnet (7d)"
|
||||
utilization={usage.seven_day_sonnet.utilization}
|
||||
resetsAt={usage.seven_day_sonnet.resets_at}
|
||||
/>
|
||||
)}
|
||||
{usage.seven_day_opus && usage.seven_day_opus.utilization > 0 && (
|
||||
<UsageBar
|
||||
label="Opus (7d)"
|
||||
utilization={usage.seven_day_opus.utilization}
|
||||
resetsAt={usage.seven_day_opus.resets_at}
|
||||
/>
|
||||
)}
|
||||
{usage.extra_usage && usage.extra_usage.is_enabled && (
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<span className="w-20 shrink-0 text-muted-foreground">
|
||||
Extra usage
|
||||
</span>
|
||||
<span>
|
||||
${((usage.extra_usage.used_credits ?? 0) / 100).toFixed(2)}{' '}
|
||||
used
|
||||
{usage.extra_usage.monthly_limit != null && (
|
||||
<>
|
||||
{' '}
|
||||
/ ${(usage.extra_usage.monthly_limit / 100).toFixed(
|
||||
2
|
||||
)}{' '}
|
||||
limit
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error message */}
|
||||
{account.error && (
|
||||
<p className="pl-8 text-xs text-destructive">{account.error}</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"root":["./src/app.tsx","./src/main.tsx","./src/routetree.gen.ts","./src/router.tsx","./src/vite-env.d.ts","./src/components/actionmenu.tsx","./src/components/agentoutputviewer.tsx","./src/components/createinitiativedialog.tsx","./src/components/decisionlist.tsx","./src/components/dependencyindicator.tsx","./src/components/errorboundary.tsx","./src/components/executiontab.tsx","./src/components/freetextinput.tsx","./src/components/inboxlist.tsx","./src/components/initiativecard.tsx","./src/components/initiativeheader.tsx","./src/components/initiativelist.tsx","./src/components/messagecard.tsx","./src/components/optiongroup.tsx","./src/components/phaseaccordion.tsx","./src/components/progressbar.tsx","./src/components/progresspanel.tsx","./src/components/projectpicker.tsx","./src/components/questionform.tsx","./src/components/refinespawndialog.tsx","./src/components/registerprojectdialog.tsx","./src/components/skeleton.tsx","./src/components/spawnarchitectdropdown.tsx","./src/components/statusbadge.tsx","./src/components/statusdot.tsx","./src/components/taskdetailmodal.tsx","./src/components/taskrow.tsx","./src/components/editor/blockselectionextension.ts","./src/components/editor/contentproposalreview.tsx","./src/components/editor/contenttab.tsx","./src/components/editor/pagebreadcrumb.tsx","./src/components/editor/pagelinkextension.tsx","./src/components/editor/pagetitlecontext.tsx","./src/components/editor/pagetree.tsx","./src/components/editor/refineagentpanel.tsx","./src/components/editor/slashcommandlist.tsx","./src/components/editor/slashcommands.ts","./src/components/editor/tiptapeditor.tsx","./src/components/editor/slash-command-items.ts","./src/components/execution/breakdownsection.tsx","./src/components/execution/executioncontext.tsx","./src/components/execution/phaseactions.tsx","./src/components/execution/phasewithtasks.tsx","./src/components/execution/phaseslist.tsx","./src/components/execution/plantasksfetcher.tsx","./src/components/execution/progresssidebar.tsx","./src/components/execution/taskmodal.tsx","./src/components/execution/index.ts","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/card.tsx","./src/components/ui/dialog.tsx","./src/components/ui/dropdown-menu.tsx","./src/components/ui/input.tsx","./src/components/ui/label.tsx","./src/components/ui/sonner.tsx","./src/components/ui/textarea.tsx","./src/hooks/index.ts","./src/hooks/useautosave.ts","./src/hooks/usedebounce.ts","./src/hooks/userefineagent.ts","./src/hooks/usespawnmutation.ts","./src/hooks/usesubscriptionwitherrorhandling.ts","./src/layouts/applayout.tsx","./src/lib/markdown-to-tiptap.ts","./src/lib/trpc.ts","./src/lib/utils.ts","./src/routes/__root.tsx","./src/routes/agents.tsx","./src/routes/inbox.tsx","./src/routes/index.tsx","./src/routes/settings.tsx","./src/routes/initiatives/$id.tsx","./src/routes/initiatives/index.tsx","./src/routes/settings/health.tsx","./src/routes/settings/index.tsx"],"errors":true,"version":"5.9.3"}
|
||||
{"root":["./src/app.tsx","./src/main.tsx","./src/routetree.gen.ts","./src/router.tsx","./src/vite-env.d.ts","./src/components/accountcard.tsx","./src/components/actionmenu.tsx","./src/components/agentactions.tsx","./src/components/agentoutputviewer.tsx","./src/components/createinitiativedialog.tsx","./src/components/decisionlist.tsx","./src/components/dependencyindicator.tsx","./src/components/errorboundary.tsx","./src/components/executiontab.tsx","./src/components/freetextinput.tsx","./src/components/inboxdetailpanel.tsx","./src/components/inboxlist.tsx","./src/components/initiativecard.tsx","./src/components/initiativeheader.tsx","./src/components/initiativelist.tsx","./src/components/messagecard.tsx","./src/components/optiongroup.tsx","./src/components/phaseaccordion.tsx","./src/components/progressbar.tsx","./src/components/progresspanel.tsx","./src/components/projectpicker.tsx","./src/components/questionform.tsx","./src/components/refinespawndialog.tsx","./src/components/registerprojectdialog.tsx","./src/components/skeleton.tsx","./src/components/spawnarchitectdropdown.tsx","./src/components/statusbadge.tsx","./src/components/statusdot.tsx","./src/components/taskdetailmodal.tsx","./src/components/taskrow.tsx","./src/components/editor/blockdraghandle.tsx","./src/components/editor/blockselectionextension.ts","./src/components/editor/contentproposalreview.tsx","./src/components/editor/contenttab.tsx","./src/components/editor/deletesubpagedialog.tsx","./src/components/editor/pagebreadcrumb.tsx","./src/components/editor/pagelinkdeletiondetector.ts","./src/components/editor/pagelinkextension.tsx","./src/components/editor/pagetitlecontext.tsx","./src/components/editor/pagetree.tsx","./src/components/editor/phasecontenteditor.tsx","./src/components/editor/refineagentpanel.tsx","./src/components/editor/slashcommandlist.tsx","./src/components/editor/slashcommands.ts","./src/components/editor/tiptapeditor.tsx","./src/components/editor/slash-command-items.ts","./src/components/execution/breakdownsection.tsx","./src/components/execution/executioncontext.tsx","./src/components/execution/phaseactions.tsx","./src/components/execution/phasedetailpanel.tsx","./src/components/execution/phasesidebaritem.tsx","./src/components/execution/phasewithtasks.tsx","./src/components/execution/phaseslist.tsx","./src/components/execution/progresssidebar.tsx","./src/components/execution/taskmodal.tsx","./src/components/execution/index.ts","./src/components/pipeline/pipelinegraph.tsx","./src/components/pipeline/pipelinephasegroup.tsx","./src/components/pipeline/pipelinestagecolumn.tsx","./src/components/pipeline/pipelinetab.tsx","./src/components/pipeline/pipelinetaskcard.tsx","./src/components/pipeline/index.ts","./src/components/review/commentform.tsx","./src/components/review/commentthread.tsx","./src/components/review/diffviewer.tsx","./src/components/review/filecard.tsx","./src/components/review/hunkrows.tsx","./src/components/review/linewithcomments.tsx","./src/components/review/reviewsidebar.tsx","./src/components/review/reviewtab.tsx","./src/components/review/dummy-data.ts","./src/components/review/index.ts","./src/components/review/parse-diff.ts","./src/components/review/types.ts","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/card.tsx","./src/components/ui/dialog.tsx","./src/components/ui/dropdown-menu.tsx","./src/components/ui/input.tsx","./src/components/ui/label.tsx","./src/components/ui/sonner.tsx","./src/components/ui/textarea.tsx","./src/hooks/index.ts","./src/hooks/useautosave.ts","./src/hooks/usedebounce.ts","./src/hooks/useliveupdates.ts","./src/hooks/useoptimisticmutation.ts","./src/hooks/usephaseautosave.ts","./src/hooks/userefineagent.ts","./src/hooks/usespawnmutation.ts","./src/hooks/usesubscriptionwitherrorhandling.ts","./src/layouts/applayout.tsx","./src/lib/_type-check-temp.ts","./src/lib/invalidation.ts","./src/lib/markdown-to-tiptap.ts","./src/lib/parse-agent-output.ts","./src/lib/trpc.ts","./src/lib/utils.ts","./src/routes/__root.tsx","./src/routes/agents.tsx","./src/routes/inbox.tsx","./src/routes/index.tsx","./src/routes/settings.tsx","./src/routes/initiatives/$id.tsx","./src/routes/initiatives/index.tsx","./src/routes/settings/health.tsx","./src/routes/settings/index.tsx"],"errors":true,"version":"5.9.3"}
|
||||
Reference in New Issue
Block a user