Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 69 additions & 2 deletions src/components/admin/review-row.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,11 @@
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"
import { createPortal } from "react-dom"
import { useRouter } from "next/navigation"
import { ArrowRight, ArrowRightLeft, ChevronRight, GitMerge, Layers, Network, Pencil, PlusCircle, PlusSquare, Share2, Trash2, type LucideIcon } from "lucide-react"
import { ArrowRight, ArrowRightLeft, CheckCircle2, ChevronRight, GitMerge, Layers, Loader2, Network, Pencil, PlusCircle, PlusSquare, Share2, Trash2, Users, type LucideIcon } from "lucide-react"
import { formatDateRelative } from "@/lib/date-format"
import type { Review, ReviewStatus } from "@/lib/graph-api"
import { approveReview, dismissReview } from "@/lib/graph-api"
import { approveReview, dismissReview, triggerMergeWorkflow } from "@/lib/graph-api"
import { useStakworkRunStatus } from "@/lib/hooks/use-stakwork-run-status"
import { cn, displayNodeType } from "@/lib/utils"
import {
Tooltip,
Expand Down Expand Up @@ -558,6 +559,18 @@ export function ReviewRow({
const [dismissing, setDismissing] = useState(false)
const [inlineError, setInlineError] = useState<string | null>(null)

// ── Human Review dispatch (merge_nodes + pending only) ───────────────────────
const isMergeReviewEligible = review.action_name === "merge_nodes" && review.status === "pending"
const {
status: humanReviewStatus,
isInFlight: humanReviewInFlight,
trigger: triggerHumanReview,
triggering: humanReviewTriggering,
} = useStakworkRunStatus(
isMergeReviewEligible ? review.ref_id : "__noop__",
"node_merge_review"
)

// ── Merge-specific interactive state ────────────────────────────────────────
const [checkedSources, setCheckedSources] = useState<Set<string>>(new Set())
const [canonicalId, setCanonicalId] = useState<string>("")
Expand Down Expand Up @@ -748,6 +761,60 @@ export function ReviewRow({
onConfirm={(reason) => handleDismiss(reason)}
minWidthClass="min-w-[68px]"
/>
{/* Send for Human Review — merge_nodes + pending only */}
{isMergeReviewEligible && (
<button
type="button"
data-testid="send-for-human-review-btn"
disabled={humanReviewInFlight || humanReviewTriggering}
onClick={(e) => {
e.stopPropagation()
if (humanReviewStatus === "COMPLETED") return
triggerHumanReview(() => triggerMergeWorkflow(review.ref_id))
}}
title={
humanReviewInFlight
? "Human review in progress…"
: humanReviewStatus === "COMPLETED"
? "Human review completed"
: humanReviewStatus === "FAILED"
? "Review failed — click to retry"
: "Send for human review"
}
className={cn(
"inline-flex items-center gap-1 rounded border px-2 py-0.5 text-[11px] font-medium transition-all",
humanReviewInFlight || humanReviewTriggering
? "cursor-not-allowed border-sky-500/40 bg-sky-500/10 text-sky-300"
: humanReviewStatus === "COMPLETED"
? "cursor-default border-emerald-500/40 bg-emerald-500/10 text-emerald-300"
: humanReviewStatus === "FAILED"
? "border-red-500/40 bg-red-500/5 text-red-400 hover:border-red-500/70 hover:bg-red-500/15"
: "border-sky-500/30 bg-sky-500/5 text-sky-400 hover:border-sky-500/60 hover:bg-sky-500/10"
)}
>
{humanReviewInFlight || humanReviewTriggering ? (
<>
<Loader2 className="h-3 w-3 animate-spin" />
Sending…
</>
) : humanReviewStatus === "COMPLETED" ? (
<>
<CheckCircle2 className="h-3 w-3" />
Sent
</>
) : humanReviewStatus === "FAILED" ? (
<>
<Users className="h-3 w-3" />
Retry Review
</>
) : (
<>
<Users className="h-3 w-3" />
Human Review
</>
)}
</button>
)}
</div>
) : (
<StatusBadge status={review.status} />
Expand Down
156 changes: 35 additions & 121 deletions src/components/layout/node-preview-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@ import { cn, displayNodeType, formatCompactNumber } from "@/lib/utils"
import { pickString, unescapeText, DISPLAY_KEY_FALLBACKS } from "@/lib/node-display"
import { getStatusBadge, isBlockedStatus, isInProgress } from "@/lib/node-status"
import type { GraphNode, GraphData, StakworkRun } from "@/lib/graph-api"
import { triggerDeepResearch, triggerEnrich, getLatestStakworkRun, getNode, isGraphData } from "@/lib/graph-api"
import { triggerDeepResearch, triggerEnrich, getNode, isGraphData } from "@/lib/graph-api"
import { useStakworkRunStatus, isInFlightStatus } from "@/lib/hooks/use-stakwork-run-status"
import { getWatches, watchNode, unwatchNode } from "@/lib/watch-api"
import { cookieStorage } from "@/lib/cookie-storage"
import type { SchemaNode } from "@/lib/schema-types"
Expand Down Expand Up @@ -788,127 +789,40 @@ export function NodePreviewPanel({ node, onBack, schemas }: NodePreviewPanelProp
const [watched, setWatched] = useState(false)
const [watchLoading, setWatchLoading] = useState(false)

// Deep Research state
type DeepResearchStatus = "PENDING" | "RUNNING" | "COMPLETED" | "FAILED" | "ERROR" | "HALTED" | null
const [deepResearchStatus, setDeepResearchStatus] = useState<DeepResearchStatus>(null)
const [deepResearchLoading, setDeepResearchLoading] = useState(false)
const deepResearchPollRef = useRef<ReturnType<typeof setInterval> | null>(null)
// Deep Research — shared hook
const {
status: deepResearchStatus,
isInFlight: deepResearchInFlight,
trigger: triggerDeepResearchRun,
triggering: deepResearchLoading,
} = useStakworkRunStatus(
DEEP_RESEARCH_NODE_TYPES.includes(currentNode.node_type) ? currentNode.ref_id : "__noop__",
"deep_research",
{ onCompleted: () => setProbeNonce((n) => n + 1) }
)

// Enrich state
const [enrichLoading, setEnrichLoading] = useState(false)
const [enrichStatus, setEnrichStatus] = useState<DeepResearchStatus>(null)
// Enrich — shared hook
const [enrichRunProjectId, setEnrichRunProjectId] = useState<number | null>(null)
const enrichPollRef = useRef<ReturnType<typeof setInterval> | null>(null)

function isDeepResearchInFlight(status: DeepResearchStatus): boolean {
return status === "PENDING" || status === "RUNNING"
}

function mapRunStatus(status: string): DeepResearchStatus {
const s = status.toUpperCase()
if (s === "PENDING" || s === "IN_PROGRESS" || s === "RUNNING") return "RUNNING"
if (s === "COMPLETED") return "COMPLETED"
if (s === "FAILED" || s === "ERROR" || s === "HALTED") return "FAILED"
return null
}

function startDeepResearchPoll(refId: string) {
if (deepResearchPollRef.current) clearInterval(deepResearchPollRef.current)
deepResearchPollRef.current = setInterval(async () => {
try {
const run = await getLatestStakworkRun(refId, "deep_research")
if (!run) return
const mapped = mapRunStatus(run.status)
setDeepResearchStatus(mapped)
if (mapped !== "PENDING" && mapped !== "RUNNING") {
if (deepResearchPollRef.current) clearInterval(deepResearchPollRef.current)
deepResearchPollRef.current = null
if (mapped === "COMPLETED") {
// Trigger node refetch by bumping probeNonce
setProbeNonce((n) => n + 1)
}
}
} catch {
// silent — keep polling
}
}, 5000)
}

// Hydrate deep research status on mount / node change
useEffect(() => {
setDeepResearchStatus(null)
if (!DEEP_RESEARCH_NODE_TYPES.includes(currentNode.node_type)) return
let cancelled = false
getLatestStakworkRun(currentNode.ref_id, "deep_research")
.then((run) => {
if (cancelled || !run) return
const mapped = mapRunStatus(run.status)
setDeepResearchStatus(mapped)
if (mapped === "PENDING" || mapped === "RUNNING") {
startDeepResearchPoll(currentNode.ref_id)
}
})
.catch(() => {})
return () => {
cancelled = true
if (deepResearchPollRef.current) {
clearInterval(deepResearchPollRef.current)
deepResearchPollRef.current = null
}
if (enrichPollRef.current) {
clearInterval(enrichPollRef.current)
enrichPollRef.current = null
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentNode.ref_id])
const {
status: enrichStatus,
isInFlight: enrichInFlight,
trigger: triggerEnrichRun,
triggering: enrichLoading,
} = useStakworkRunStatus(currentNode.ref_id, "web_search_enrich", {
onCompleted: () => setProbeNonce((n) => n + 1),
onPoll: (run) => { if (run.project_id) setEnrichRunProjectId(run.project_id) },
})

async function handleDeepResearch() {
if (deepResearchLoading || isDeepResearchInFlight(deepResearchStatus)) return
setDeepResearchLoading(true)
setDeepResearchStatus("PENDING")
try {
await triggerDeepResearch(currentNode.ref_id)
startDeepResearchPoll(currentNode.ref_id)
} catch {
setDeepResearchStatus("FAILED")
} finally {
setDeepResearchLoading(false)
}
}

function startEnrichPoll(refId: string) {
if (enrichPollRef.current) clearInterval(enrichPollRef.current)
enrichPollRef.current = setInterval(async () => {
try {
const run = await getLatestStakworkRun(refId, "web_search_enrich")
if (!run) return
const mapped = mapRunStatus(run.status)
setEnrichStatus(mapped)
if (run.project_id) setEnrichRunProjectId(run.project_id)
if (mapped !== "PENDING" && mapped !== "RUNNING") {
if (enrichPollRef.current) clearInterval(enrichPollRef.current)
enrichPollRef.current = null
if (mapped === "COMPLETED") setProbeNonce((n) => n + 1)
}
} catch {
// silent — keep polling
}
}, 5000)
await triggerDeepResearchRun(() => triggerDeepResearch(currentNode.ref_id))
}

async function handleEnrich() {
if (enrichLoading || isDeepResearchInFlight(enrichStatus)) return
setEnrichLoading(true)
setEnrichStatus("PENDING")
try {
await triggerEnrich(currentNode.ref_id)
startEnrichPoll(currentNode.ref_id)
} catch {
setEnrichStatus("FAILED")
} finally {
setEnrichLoading(false)
}
await triggerEnrichRun(async () => {
const result = await triggerEnrich(currentNode.ref_id)
// Capture project_id for the "View Enrich run" link if available
return result
})
}

// Full reset (currentNode + history + scroll) only when a genuinely different
Expand Down Expand Up @@ -1478,12 +1392,12 @@ export function NodePreviewPanel({ node, onBack, schemas }: NodePreviewPanelProp
size="sm"
variant="outline"
className="w-full"
disabled={deepResearchLoading || isDeepResearchInFlight(deepResearchStatus)}
disabled={deepResearchLoading || deepResearchInFlight}
onClick={handleDeepResearch}
data-testid="deep-research-button"
title="Deep Research this topic"
>
{isDeepResearchInFlight(deepResearchStatus) ? (
{deepResearchInFlight ? (
<>
<Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />
Researching…
Expand All @@ -1493,7 +1407,7 @@ export function NodePreviewPanel({ node, onBack, schemas }: NodePreviewPanelProp
<FlaskConical className="h-3.5 w-3.5 mr-1.5" />
Re-run Research
</>
) : (deepResearchStatus === "FAILED" || deepResearchStatus === "ERROR" || deepResearchStatus === "HALTED") ? (
) : (deepResearchStatus === "FAILED") ? (
<>
<FlaskConical className="h-3.5 w-3.5 mr-1.5" />
Retry Research
Expand All @@ -1515,12 +1429,12 @@ export function NodePreviewPanel({ node, onBack, schemas }: NodePreviewPanelProp
size="sm"
variant="outline"
className="w-full"
disabled={enrichLoading || isDeepResearchInFlight(enrichStatus)}
disabled={enrichLoading || enrichInFlight}
onClick={handleEnrich}
data-testid="enrich-button"
title="Enrich this node via web search"
>
{isDeepResearchInFlight(enrichStatus) ? (
{enrichInFlight ? (
<>
<Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />
Enriching…
Expand All @@ -1530,7 +1444,7 @@ export function NodePreviewPanel({ node, onBack, schemas }: NodePreviewPanelProp
<Search className="h-3.5 w-3.5 mr-1.5" />
Enriched
</>
) : (enrichStatus === "FAILED" || enrichStatus === "ERROR" || enrichStatus === "HALTED") ? (
) : (enrichStatus === "FAILED") ? (
<>
<Search className="h-3.5 w-3.5 mr-1.5" />
Enrich failed
Expand Down
21 changes: 13 additions & 8 deletions src/lib/__tests__/node-preview-panel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2267,14 +2267,19 @@ describe("NodePreviewPanel – Enrich button", () => {
it("shows 'View Enrich run' link once enrichRunProjectId is set via polling", async () => {
vi.useFakeTimers({ shouldAdvanceTime: true })
try {
mockGetLatestStakworkRun
.mockResolvedValueOnce({
ref_id: "enrich-run-1",
job_type: "web_search_enrich",
status: "RUNNING",
created_at: 0,
project_id: 42000,
})
const runPayload = {
ref_id: "enrich-run-1",
job_type: "web_search_enrich",
status: "RUNNING",
created_at: 0,
project_id: 42000,
}
// beforeEach already sets mockResolvedValue(null); override for polling tick.
// mockImplementation lets us return runPayload only for web_search_enrich polls.
mockGetLatestStakworkRun.mockImplementation((_refId: string, jobType: string) => {
if (jobType === "web_search_enrich") return Promise.resolve(runPayload)
return Promise.resolve(null)
})

const node = makeEnrichNode("Location")
mockApiGet.mockResolvedValue(makeGraphData(node))
Expand Down
Loading
Loading