diff --git a/apps/web/src/components/app/feedback-detail-panel.tsx b/apps/web/src/components/app/feedback-detail-panel.tsx new file mode 100644 index 00000000..6403ae53 --- /dev/null +++ b/apps/web/src/components/app/feedback-detail-panel.tsx @@ -0,0 +1,253 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { ChevronLeft, ChevronRight, X } from "lucide-react"; + +import { + bySeverity, + formatFeedbackText, + SEVERITY_LABELS, + STATUS_LABELS, +} from "@/components/app/feedback-utils"; +import { useFeedbackData } from "@/components/app/use-feedback-data"; +import { + FeedbackActions, + FeedbackItemNotFoundState, + IgnoreReasonInput, + ResolutionInfoBlock, + RoundChip, +} from "@/components/app/feedback-shared"; +import { type FeedbackItem } from "@/components/app/types"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { useCopyText } from "@/hooks/use-copy"; +import { Markdown } from "@/components/ui/markdown"; + +export function FeedbackDetailPanel({ + parentAgentId, + itemId, + isConnected, + sendTerminalInput, + onClose, + onNavigate, +}: { + parentAgentId: string; + itemId: number; + isConnected: boolean; + sendTerminalInput?: (data: string) => void; + onClose: () => void; + onNavigate: (itemId: number) => void; +}): JSX.Element | null { + const { feedback, personaAttribution, updateStatus } = + useFeedbackData(parentAgentId); + const [copied, copyText] = useCopyText(); + const [copiedItemId, setCopiedItemId] = useState(null); + + const panelRef = useRef(null); + const activeItems = useMemo( + () => + feedback + .filter((f) => f.status === "open" || f.status === "forwarded") + .sort(bySeverity), + [feedback] + ); + const resolvedItems = useMemo( + () => + feedback + .filter((f) => f.status !== "open" && f.status !== "forwarded") + .sort(bySeverity), + [feedback] + ); + const item = feedback.find((f) => f.id === itemId) ?? null; + + const isActiveItem = + item && (item.status === "open" || item.status === "forwarded"); + const navItems = isActiveItem ? activeItems : resolvedItems; + const itemIndex = item ? navItems.findIndex((f) => f.id === item.id) : -1; + const prevItem = itemIndex > 0 ? navItems[itemIndex - 1]! : null; + const nextItem = + itemIndex >= 0 && itemIndex < navItems.length - 1 + ? navItems[itemIndex + 1]! + : null; + + useEffect(() => { + panelRef.current?.focus(); + }, [itemId]); + + const forward = useCallback( + (feedbackItem: FeedbackItem, mode: "wdyt" | "fix") => { + if (sendTerminalInput && isConnected) { + const prefix = + mode === "fix" + ? "Fix the following issue found by the persona reviewer:" + : "A persona reviewer flagged the following. What do you think — is this a real concern?"; + const text = prefix + "\n" + formatFeedbackText(feedbackItem) + "\r"; + sendTerminalInput(text); + void updateStatus(feedbackItem, "forwarded"); + } + }, + [sendTerminalInput, isConnected, updateStatus] + ); + + const handleCopy = useCallback( + (feedbackItem: FeedbackItem) => { + copyText(formatFeedbackText(feedbackItem)); + setCopiedItemId(feedbackItem.id); + }, + [copyText] + ); + + const [ignoreTarget, setIgnoreTarget] = useState(null); + + const handleResolve = useCallback( + (feedbackItem: FeedbackItem, status: string, reason?: string) => { + void updateStatus(feedbackItem, status, reason); + setIgnoreTarget(null); + const samePersona = activeItems.filter( + (f) => f.agentId === feedbackItem.agentId + ); + const idx = samePersona.findIndex((f) => f.id === feedbackItem.id); + const remaining = samePersona.filter((f) => f.id !== feedbackItem.id); + if (remaining.length > 0) { + onNavigate( + remaining[Math.min(Math.max(idx, 0), remaining.length - 1)]!.id + ); + } else if (resolvedItems.length > 0) { + onNavigate(resolvedItems[0]!.id); + } else { + onClose(); + } + }, + [updateStatus, activeItems, resolvedItems, onNavigate, onClose] + ); + + if (!item) { + return ( +
+ +
+ ); + } + + const isActionable = item.status === "open" || item.status === "forwarded"; + const severityInfo = SEVERITY_LABELS[item.severity] ?? SEVERITY_LABELS.info; + const attr = personaAttribution.get(item.agentId); + const isIgnoring = ignoreTarget === item.id; + + return ( +
{ + if (isIgnoring) return; + if (e.key === "Escape") { + e.stopPropagation(); + onClose(); + } + }} + className="flex h-full min-h-0 flex-col overflow-hidden border-t border-white/[0.12] bg-[hsl(var(--card))] px-6 py-4 outline-none" + > +
+
+ {severityInfo!.label} + + + {item.filePath + ? `${item.filePath}${item.lineNumber ? `:${item.lineNumber}` : ""}` + : "Feedback"} + + {attr ? ( + + + {attr.name} + + ) : null} +
+
+ + {itemIndex + 1}/{navItems.length} + {!isActiveItem ? " resolved" : ""} + + + + +
+
+ +
+
+
+ Description +
+ + {item.description} + +
+ + {item.suggestion ? ( +
+
+ Suggestion +
+ + {item.suggestion} + +
+ ) : null} + + {!isActionable ? : null} +
+ +
+ {ignoreTarget === item.id ? ( + setIgnoreTarget(null)} + onSubmit={(reason) => handleResolve(item, "ignored", reason)} + /> + ) : ( + forward(item, mode)} + onCopy={() => handleCopy(item)} + copied={copied && copiedItemId === item.id} + onUpdateStatus={(s) => { + if (s === "ignored") { + setIgnoreTarget(item.id); + } else { + handleResolve(item, s); + } + }} + isActionable={isActionable} + statusLabel={STATUS_LABELS[item.status]} + size="default" + /> + )} +
+
+ ); +} diff --git a/apps/web/src/components/app/feedback-finding-row.tsx b/apps/web/src/components/app/feedback-finding-row.tsx new file mode 100644 index 00000000..77918227 --- /dev/null +++ b/apps/web/src/components/app/feedback-finding-row.tsx @@ -0,0 +1,87 @@ +import { FrontTruncatedValue } from "@/components/app/agent-meta"; +import { SEVERITY_DOT, STATUS_LABELS } from "@/components/app/feedback-utils"; +import { RoundChip, StatusIcon } from "@/components/app/feedback-shared"; +import { type FeedbackItem } from "@/components/app/types"; +import { cn } from "@/lib/utils"; + +export function FeedbackFindingRow({ + item, + isSelected, + showRoundDivider, + onClick, +}: { + item: FeedbackItem; + isSelected: boolean; + showRoundDivider: boolean; + onClick: () => void; +}): JSX.Element { + const isActionable = item.status === "open" || item.status === "forwarded"; + const dotColor = SEVERITY_DOT[item.severity] ?? SEVERITY_DOT.info; + const statusLabel = STATUS_LABELS[item.status]; + const isRecheckItem = + item.roundNumber >= 2 && item.respondsToFeedbackId != null; + + return ( +
+ {showRoundDivider ? ( +
+ + Round 2 findings + +
+ ) : null} + +
+ ); +} diff --git a/apps/web/src/components/app/feedback-panel.tsx b/apps/web/src/components/app/feedback-panel.tsx index c521ea97..90fcb7c7 100644 --- a/apps/web/src/components/app/feedback-panel.tsx +++ b/apps/web/src/components/app/feedback-panel.tsx @@ -1,8 +1,6 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useMemo, useState } from "react"; import { useQuery } from "@tanstack/react-query"; -import { ChevronLeft, ChevronRight, X } from "lucide-react"; -import { FrontTruncatedValue } from "@/components/app/agent-meta"; import { reviewVerdictLabel } from "@/components/app/agent-event-utils"; import { getVerdict, @@ -13,34 +11,19 @@ import { type FeedbackDetailState, bySeverity, compareFeedbackForPanel, - formatFeedbackText, - SEVERITY_DOT, - SEVERITY_LABELS, - STATUS_LABELS, } from "@/components/app/feedback-utils"; -import { useFeedbackData } from "@/components/app/use-feedback-data"; -import { - FeedbackActions, - FeedbackItemNotFoundState, - IgnoreReasonInput, - ResolutionInfoBlock, - RoundChip, - StatusIcon, -} from "@/components/app/feedback-shared"; +import { FeedbackFindingRow } from "@/components/app/feedback-finding-row"; import { type Agent, type AgentVisualState, type FeedbackItem, } from "@/components/app/types"; -import { Badge } from "@/components/ui/badge"; -import { Button } from "@/components/ui/button"; -import { useCopyText } from "@/hooks/use-copy"; -import { Markdown } from "@/components/ui/markdown"; import { AnimatePresence, motion } from "framer-motion"; import { api } from "@/lib/api"; import { cn } from "@/lib/utils"; export { type FeedbackDetailState } from "@/components/app/feedback-utils"; +export { FeedbackDetailPanel } from "@/components/app/feedback-detail-panel"; export { ReviewSummaryPanel } from "@/components/app/review-summary-panel"; export { MobileFeedbackSheet, @@ -195,6 +178,10 @@ export function ParentFeedbackPanel({ } : undefined; + const firstRound2Index = items.findIndex( + (candidate) => candidate.roundNumber >= 2 + ); + return (
Findings
- {items.map((item) => { - const isActionable = - item.status === "open" || - item.status === "forwarded"; - const dotColor = - SEVERITY_DOT[item.severity] ?? - SEVERITY_DOT.info; - const statusLabel = STATUS_LABELS[item.status]; - const isSelected = - item.id === activeDetailItemId; - const isRecheckItem = - item.roundNumber >= 2 && - item.respondsToFeedbackId != null; - const showRoundDivider = - item.roundNumber >= 2 && - items.findIndex( - (candidate) => candidate.roundNumber >= 2 - ) === items.indexOf(item); - - return ( -
- {showRoundDivider ? ( -
- - Round 2 findings - -
- ) : null} - -
- ); - })} + {items.map((item, itemIndex) => ( + = 2 && + itemIndex === firstRound2Index + } + onClick={() => { + if (item.id === activeDetailItemId) { + onOpenDetail?.(null); + return; + } + if ( + closeOnSessionAction && + parentAgent && + attachToAgent + ) { + if (selectedAgentId !== parentAgentId) { + void attachToAgent(parentAgent); + } + onRequestClose?.(); + } + onOpenDetail?.({ + parentAgentId, + itemId: item.id, + }); + }} + /> + ))} {resolvedCount > 0 ? ( - - - - - -
-
-
- Description -
- - {item.description} - -
- - {item.suggestion ? ( -
-
- Suggestion -
- - {item.suggestion} - -
- ) : null} - - {!isActionable ? : null} -
- -
- {ignoreTarget === item.id ? ( - setIgnoreTarget(null)} - onSubmit={(reason) => handleResolve(item, "ignored", reason)} - /> - ) : ( - forward(item, mode)} - onCopy={() => handleCopy(item)} - copied={copied && copiedItemId === item.id} - onUpdateStatus={(s) => { - if (s === "ignored") { - setIgnoreTarget(item.id); - } else { - handleResolve(item, s); - } - }} - isActionable={isActionable} - statusLabel={STATUS_LABELS[item.status]} - size="default" - /> - )} -
- - ); -}