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
28 changes: 26 additions & 2 deletions src/components/admin/review-row.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"
import { createPortal } from "react-dom"
import { useRouter } from "next/navigation"
import { ArrowRight, ArrowRightLeft, ChevronRight, GitMerge, Trash2, type LucideIcon } from "lucide-react"
import { ArrowRight, ArrowRightLeft, ChevronRight, GitMerge, PlusCircle, Trash2, 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"
Expand Down Expand Up @@ -229,6 +229,11 @@ const ACTION_LABELS: Record<string, ActionLabels> = {
rowLabel: (s) => `Replace ${s.displayName ?? s.typeLabel}`,
approvePrompt: () => "Replace the old node with the new one?",
},
add_source: {
approve: "Add",
rowLabel: () => "Add new source",
approvePrompt: () => "Add this source to the radar?",
},
}

// ── Confirm action popover (used for both Approve and Dismiss) ────────────────
Expand Down Expand Up @@ -408,6 +413,7 @@ const ICON_MAP: Record<string, LucideIcon> = {
"git-merge": GitMerge,
"trash-2": Trash2,
"arrow-right-left": ArrowRightLeft,
"plus-circle": PlusCircle,
}

// ── Main ReviewRow ────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -553,7 +559,9 @@ export function ReviewRow({
</div>
) : (
<span className="text-[12px] text-muted-foreground">
{labels.rowLabel(subjectSummary)}
{(!direction && review.subject_nodes.length === 0 && review.display_label)
? review.display_label
: labels.rowLabel(subjectSummary)}
</span>
)}

Expand Down Expand Up @@ -646,6 +654,22 @@ export function ReviewRow({
/>
</div>
</div>
) : review.action_name === "add_source" && review.action_payload && typeof review.action_payload === "object" ? (
<div>
<div className="mb-1.5 font-mono text-[9px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">
Suggested Source
</div>
<div className="flex flex-col gap-1 text-[12px]">
<div>
<span className="text-muted-foreground">Type: </span>
<span>{String((review.action_payload as Record<string, unknown>).source_type ?? "—")}</span>
</div>
<div>
<span className="text-muted-foreground">Source: </span>
<span className="break-all">{String((review.action_payload as Record<string, unknown>).source ?? "—")}</span>
</div>
</div>
</div>
) : (
<div>
<div className="mb-1.5 font-mono text-[9px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">
Expand Down
135 changes: 135 additions & 0 deletions src/lib/__tests__/reviews.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -386,6 +386,141 @@ describe("ReviewRow", () => {
expect(getByText(/Hide Mock Episode/)).toBeTruthy()
})

// ── add_source / new_source_candidate ─────────────────────────────────────

it("collapsed row shows display_label when subject_nodes is empty and display_label is set", () => {
const { getByText } = render(
<ReviewRow
schemas={[]}
review={makeReview({
ref_id: "mock-new-source-1",
type: "new_source_candidate",
action_name: "add_source",
action_payload: { source: "https://www.youtube.com/@lexfridman", source_type: "youtube_channel" },
subject_ids: [],
subject_nodes: [],
display_label: "Add Youtube Channel: https://www.youtube.com/@lexfridman",
icon: "plus-circle",
accent: "green",
action_verb: "Add",
status: "pending",
})}
onRefresh={noop}
/>
)
expect(getByText("Add Youtube Channel: https://www.youtube.com/@lexfridman")).toBeTruthy()
})

it("collapsed row renders plus-circle icon for add_source action", () => {
const { container } = render(
<ReviewRow
schemas={[]}
review={makeReview({
type: "new_source_candidate",
action_name: "add_source",
action_payload: { source: "https://www.youtube.com/@lexfridman", source_type: "youtube_channel" },
subject_ids: [],
subject_nodes: [],
display_label: "Add Youtube Channel: https://www.youtube.com/@lexfridman",
icon: "plus-circle",
status: "pending",
})}
onRefresh={noop}
/>
)
expect(container.querySelector("svg")).toBeTruthy()
})

it("approve button label shows 'Add' for add_source action", () => {
const { getByText } = render(
<ReviewRow
schemas={[]}
review={makeReview({
type: "new_source_candidate",
action_name: "add_source",
action_payload: { source: "https://www.youtube.com/@lexfridman", source_type: "youtube_channel" },
subject_ids: [],
subject_nodes: [],
display_label: "Add Youtube Channel: https://www.youtube.com/@lexfridman",
icon: "plus-circle",
status: "pending",
})}
onRefresh={noop}
/>
)
expect(getByText("Add")).toBeTruthy()
})

it("expanded section shows 'Suggested Source' heading with source_type and source for add_source", async () => {
const user = userEvent.setup()
const { getByText } = render(
<ReviewRow
schemas={[]}
review={makeReview({
ref_id: "mock-new-source-1",
type: "new_source_candidate",
action_name: "add_source",
action_payload: { source: "https://www.youtube.com/@lexfridman", source_type: "youtube_channel" },
subject_ids: [],
subject_nodes: [],
display_label: "Add Youtube Channel: https://www.youtube.com/@lexfridman",
icon: "plus-circle",
status: "pending",
})}
onRefresh={noop}
/>
)
// Click the row to expand
await user.click(getByText("Add Youtube Channel: https://www.youtube.com/@lexfridman"))
expect(getByText("Suggested Source")).toBeTruthy()
expect(getByText("youtube_channel")).toBeTruthy()
expect(getByText("https://www.youtube.com/@lexfridman")).toBeTruthy()
})

it("expanded section does NOT show 'Subjects (0)' for add_source", async () => {
const user = userEvent.setup()
const { getByText, queryByText } = render(
<ReviewRow
schemas={[]}
review={makeReview({
type: "new_source_candidate",
action_name: "add_source",
action_payload: { source: "https://www.youtube.com/@lexfridman", source_type: "youtube_channel" },
subject_ids: [],
subject_nodes: [],
display_label: "Add Youtube Channel: https://www.youtube.com/@lexfridman",
icon: "plus-circle",
status: "pending",
})}
onRefresh={noop}
/>
)
await user.click(getByText("Add Youtube Channel: https://www.youtube.com/@lexfridman"))
expect(queryByText("Subjects (0)")).toBeNull()
})

it("shows error_message inline for failed new_source_candidate review", () => {
const { getByText } = render(
<ReviewRow
schemas={[]}
review={makeReview({
ref_id: "mock-new-source-2",
type: "new_source_candidate",
action_name: "add_source",
action_payload: { source: "https://feeds.transistor.fm/example", source_type: "rss" },
subject_ids: [],
subject_nodes: [],
display_label: "Add Rss: https://feeds.transistor.fm/example",
icon: "plus-circle",
status: "failed",
error_message: "add_source failed: Source already exists",
})}
onRefresh={noop}
/>
)
expect(getByText(/add_source failed: Source already exists/)).toBeTruthy()
})

it("renders topic_review_candidate row without errors", () => {
const { container, getByText } = render(
<ReviewRow
Expand Down
35 changes: 35 additions & 0 deletions src/lib/mock-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1121,4 +1121,39 @@ export const MOCK_REVIEWS: Review[] = [
icon: "trash-2",
created_at: new Date(Date.now() - 300_000).toISOString(),
},
{
ref_id: "mock-new-source-1",
type: "new_source_candidate",
rationale: "This YouTube channel is frequently referenced by existing graph nodes and has not yet been added as a source.",
subject_ids: [],
subject_nodes: [],
action_name: "add_source",
action_payload: { source: "https://www.youtube.com/@lexfridman", source_type: "youtube_channel" },
status: "pending",
fingerprint: "mock-fingerprint-new-source-1",
priority: 1,
created_at: new Date(Date.now() - 3_600_000).toISOString(),
display_label: "Add Youtube Channel: https://www.youtube.com/@lexfridman",
accent: "green",
action_verb: "Add",
icon: "plus-circle",
},
{
ref_id: "mock-new-source-2",
type: "new_source_candidate",
rationale: "Suggested RSS feed already exists in the radar.",
subject_ids: [],
subject_nodes: [],
action_name: "add_source",
action_payload: { source: "https://feeds.transistor.fm/example", source_type: "rss" },
status: "failed",
error_message: "add_source failed: Source already exists",
fingerprint: "mock-fingerprint-new-source-2",
priority: 0,
created_at: new Date(Date.now() - 7_200_000).toISOString(),
display_label: "Add Rss: https://feeds.transistor.fm/example",
accent: "green",
action_verb: "Add",
icon: "plus-circle",
},
]
Loading