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
1 change: 1 addition & 0 deletions apps/web/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,7 @@ export interface ApiSnapshot {
answerText: string | null
citedDomains: string[]
competitorOverlap: string[]
recommendedCompetitors?: string[]
groundingSources: GroundingSource[]
searchQueries: string[]
model: string | null
Expand Down
2 changes: 2 additions & 0 deletions apps/web/src/build-dashboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -421,6 +421,7 @@ function buildEvidenceFromTimeline(
citedDomains: snap?.citedDomains ?? [],
evidenceUrls: [],
competitorDomains: snap?.competitorOverlap ?? [],
recommendedCompetitors: snap?.recommendedCompetitors ?? [],
relatedTechnicalSignals: [],
groundingSources: snap?.groundingSources ?? [],
summary: evidenceSummary(snapState, entry.keyword),
Expand Down Expand Up @@ -448,6 +449,7 @@ function buildEvidenceFromTimeline(
citedDomains: [],
evidenceUrls: [],
competitorDomains: [],
recommendedCompetitors: [],
relatedTechnicalSignals: [],
groundingSources: [],
summary: `"${kw.keyword}" has been added but no visibility run has been triggered yet.`,
Expand Down
184 changes: 126 additions & 58 deletions apps/web/src/components/layout/EvidenceDetailModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { X } from 'lucide-react'
import { effectiveDomains, normalizeProjectDomain } from '@ainyc/canonry-contracts'

import { CitationBadge } from '../shared/CitationBadge.js'
import { InfoTooltip } from '../shared/InfoTooltip.js'
import { highlightTermsInText } from '../../lib/highlight.js'
import { fetchRunDetail, type GroundingSource } from '../../api.js'
import type { CitationInsightVm, ProjectCommandCenterVm } from '../../view-models.js'
Expand All @@ -17,6 +18,7 @@ export interface EvidenceDisplayData {
answerSnippet: string
citedDomains: string[]
competitorDomains: string[]
recommendedCompetitors: string[]
groundingSources: GroundingSource[]
evidenceUrls: string[]
changeLabel: string
Expand All @@ -33,6 +35,7 @@ export function EvidenceDetailModal({
onClose: () => void
}) {
const [showFullAnswer, setShowFullAnswer] = useState(false)
const [sidebarTab, setSidebarTab] = useState<'citations' | 'sources'>('citations')
const [selectedRunIdx, setSelectedRunIdx] = useState(-1) // -1 = latest (current)
const [historicalSnapshot, setHistoricalSnapshot] = useState<EvidenceDisplayData | null>(null)
const [loadingHistory, setLoadingHistory] = useState(false)
Expand Down Expand Up @@ -67,6 +70,7 @@ export function EvidenceDetailModal({
answerSnippet: snap.answerText,
citedDomains: snap.citedDomains ?? evidence.citedDomains,
competitorDomains: snap.competitorOverlap ?? evidence.competitorDomains,
recommendedCompetitors: snap.recommendedCompetitors ?? evidence.recommendedCompetitors ?? [],
groundingSources: snap.groundingSources ?? evidence.groundingSources,
evidenceUrls: [],
changeLabel: evidence.changeLabel,
Expand All @@ -87,6 +91,7 @@ export function EvidenceDetailModal({
answerSnippet: evidence.answerSnippet,
citedDomains: evidence.citedDomains,
competitorDomains: evidence.competitorDomains,
recommendedCompetitors: evidence.recommendedCompetitors ?? [],
groundingSources: evidence.groundingSources,
evidenceUrls: evidence.evidenceUrls,
changeLabel: evidence.changeLabel,
Expand Down Expand Up @@ -158,6 +163,7 @@ export function EvidenceDetailModal({
answerSnippet: snap.answerText ?? '',
citedDomains: snap.citedDomains,
competitorDomains: snap.competitorOverlap,
recommendedCompetitors: snap.recommendedCompetitors ?? [],
groundingSources: snap.groundingSources,
evidenceUrls: [],
changeLabel: run.citationState,
Expand All @@ -169,6 +175,7 @@ export function EvidenceDetailModal({
answerSnippet: '',
citedDomains: [],
competitorDomains: [],
recommendedCompetitors: [],
groundingSources: [],
evidenceUrls: [],
changeLabel: run.citationState,
Expand All @@ -186,6 +193,7 @@ export function EvidenceDetailModal({
answerSnippet: '',
citedDomains: [],
competitorDomains: [],
recommendedCompetitors: [],
groundingSources: [],
evidenceUrls: [],
changeLabel: run.citationState,
Expand Down Expand Up @@ -470,72 +478,132 @@ export function EvidenceDetailModal({

{/* Right: leaderboard + sources */}
<div className="evidence-modal-sidebar">
{/* Citation leaderboard */}
{display.citedDomains.length > 0 && (
<div>
<p className="drawer-section-label">Who was cited {'\u2014'} in order</p>
<div className="citation-leaderboard">
{display.citedDomains.map((domain, i) => {
const norm = domain.toLowerCase().replace(/^www\./, '')
const isYou = myDomains.has(norm)
const isCompetitor = !isYou && display.competitorDomains.some(
c => c.toLowerCase().replace(/^www\./, '') === norm,
)
const variant = isYou ? 'you' : isCompetitor ? 'competitor' : 'other'
return (
<div key={domain} className={`citation-leaderboard-item citation-leaderboard-item--${variant}`}>
<span className="citation-leaderboard-rank">#{i + 1}</span>
<span className="citation-leaderboard-domain">{domain}</span>
{isYou && <span className="citation-leaderboard-tag">You</span>}
{isCompetitor && <span className="citation-leaderboard-tag">Competitor</span>}
</div>
)
})}
{!isCited && (
<div className="citation-leaderboard-item citation-leaderboard-item--not-cited border-dashed">
<span className="citation-leaderboard-rank text-zinc-600">{'\u2014'}</span>
<span className="citation-leaderboard-domain text-zinc-600">{project.project.canonicalDomain}</span>
<span className="citation-leaderboard-tag text-zinc-600">Not cited</span>
</div>
)}
</div>
{/* Tabbed sidebar navigation */}
{(display.citedDomains.length > 0 || display.recommendedCompetitors.length > 0 || display.groundingSources.length > 0 || display.evidenceUrls.length > 0) && (
<div className="sidebar-tabs">
<button
className={`sidebar-tab ${sidebarTab === 'citations' ? 'sidebar-tab--active' : ''}`}
onClick={() => setSidebarTab('citations')}
type="button"
title="Domains Canonry identified as cited in the model response. This is the primary visibility signal."
>
Citations
</button>
<button
className={`sidebar-tab ${sidebarTab === 'sources' ? 'sidebar-tab--active' : ''}`}
onClick={() => setSidebarTab('sources')}
type="button"
title="Grounding links the model used to build the answer. These are supporting sources, not the cited-domain ranking."
>
Sources
</button>
</div>
)}

{/* Grounding sources */}
{display.groundingSources.length > 0 && (
<div>
<p className="drawer-section-label">Grounding sources ({display.groundingSources.length})</p>
<ul className="grid gap-0.5">
{display.groundingSources.map((src, i) => (
<li key={i} className="truncate text-sm">
<a href={src.uri} target="_blank" rel="noreferrer" className="text-zinc-400 hover:text-zinc-200 transition-colors">
{src.title || src.uri}
</a>
</li>
))}
</ul>
</div>
{sidebarTab === 'citations' && (
<>
{display.recommendedCompetitors.length > 0 && (
<div>
<div className="drawer-section-label flex items-center">
<span>Company names mentioned</span>
<InfoTooltip text="Best-effort company names extracted from the answer text. This is supplementary context and does not replace the cited-domain list below." />
</div>
<div className="citation-leaderboard">
{display.recommendedCompetitors.map((name, i) => (
<div key={name} className="citation-leaderboard-item citation-leaderboard-item--competitor">
<span className="citation-leaderboard-rank">#{i + 1}</span>
<span className="citation-leaderboard-domain">{name}</span>
</div>
))}
</div>
</div>
)}

{display.citedDomains.length > 0 ? (
<div>
<div className="drawer-section-label flex items-center">
<span>Domains cited in answer</span>
<InfoTooltip text="Domains Canonry identified as actually cited in the model response. This is the canonical evidence used for citation state and ranking." />
</div>
<div className="citation-leaderboard">
{display.citedDomains.map((domain, i) => {
const norm = domain.toLowerCase().replace(/^www\./, '')
const isYou = myDomains.has(norm)
const isCompetitor = !isYou && display.competitorDomains.some(
c => c.toLowerCase().replace(/^www\./, '') === norm,
)
const variant = isYou ? 'you' : isCompetitor ? 'competitor' : 'other'
return (
<div key={domain} className={`citation-leaderboard-item citation-leaderboard-item--${variant}`}>
<span className="citation-leaderboard-rank">#{i + 1}</span>
<span className="citation-leaderboard-domain">{domain}</span>
{isYou && <span className="citation-leaderboard-tag">You</span>}
{isCompetitor && <span className="citation-leaderboard-tag">Competitor</span>}
</div>
)
})}
{!isCited && (
<div className="citation-leaderboard-item citation-leaderboard-item--not-cited border-dashed">
<span className="citation-leaderboard-rank text-zinc-600">{'\u2014'}</span>
<span className="citation-leaderboard-domain text-zinc-600">{project.project.canonicalDomain}</span>
<span className="citation-leaderboard-tag text-zinc-600">Not cited</span>
</div>
)}
</div>
</div>
) : display.recommendedCompetitors.length === 0 ? (
<div className="flex items-center justify-center h-24 text-zinc-600 text-sm">
No citation data {isViewingHistory ? 'for this run' : 'yet'}
</div>
) : null}
</>
)}

{/* Evidence URLs */}
{display.evidenceUrls.length > 0 && (
<div>
<p className="drawer-section-label">Evidence URLs</p>
<ul className="grid gap-1">
{display.evidenceUrls.map((url) => (
<li key={url} className="truncate text-sm">
<a href={url} target="_blank" rel="noreferrer" className="text-zinc-400 hover:text-zinc-200 transition-colors">
{url}
</a>
</li>
))}
</ul>
</div>
{sidebarTab === 'sources' && (
<>
{display.groundingSources.length > 0 && (
<div>
<div className="drawer-section-label flex items-center">
<span>Grounding source links ({display.groundingSources.length})</span>
<InfoTooltip text="Links the model used as grounding or supporting context while producing the answer. These are not the same thing as the cited-domain ranking." />
</div>
<ul className="grid gap-0.5">
{display.groundingSources.map((src, i) => (
<li key={i} className="truncate text-sm">
<a href={src.uri} target="_blank" rel="noreferrer" className="text-zinc-400 hover:text-zinc-200 transition-colors">
{src.title || src.uri}
</a>
</li>
))}
</ul>
</div>
)}

{display.evidenceUrls.length > 0 && (
<div>
<p className="drawer-section-label">Evidence URLs</p>
<ul className="grid gap-1">
{display.evidenceUrls.map((url) => (
<li key={url} className="truncate text-sm">
<a href={url} target="_blank" rel="noreferrer" className="text-zinc-400 hover:text-zinc-200 transition-colors">
{url}
</a>
</li>
))}
</ul>
</div>
)}

{display.groundingSources.length === 0 && display.evidenceUrls.length === 0 && (
<div className="flex items-center justify-center h-24 text-zinc-600 text-sm">
No source data {isViewingHistory ? 'for this run' : 'yet'}
</div>
)}
</>
)}

{/* No data state */}
{display.citedDomains.length === 0 && display.groundingSources.length === 0 && display.evidenceUrls.length === 0 && (
{display.citedDomains.length === 0 && display.recommendedCompetitors.length === 0 && display.groundingSources.length === 0 && display.evidenceUrls.length === 0 && (
<div className="flex items-center justify-center h-24 text-zinc-600 text-sm">
No citation data {isViewingHistory ? 'for this run' : 'yet'}
</div>
Expand Down
13 changes: 13 additions & 0 deletions apps/web/src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -1397,6 +1397,19 @@
@apply md:border-l md:border-zinc-800/40 md:pl-6;
}

.sidebar-tabs {
@apply flex gap-1 p-0.5 rounded-lg bg-zinc-900/60 border border-zinc-800/40;
}

.sidebar-tab {
@apply flex-1 px-3 py-1.5 text-xs font-medium rounded-md transition-colors;
@apply text-zinc-500 hover:text-zinc-300;
}

.sidebar-tab--active {
@apply bg-zinc-800 text-zinc-200 shadow-sm;
}

.evidence-answer-collapsed {
max-height: 200px;
overflow: hidden;
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/view-models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,7 @@ export interface CitationInsightVm {
citedDomains: string[]
evidenceUrls: string[]
competitorDomains: string[]
recommendedCompetitors?: string[]
relatedTechnicalSignals: string[]
groundingSources: GroundingSource[]
summary: string
Expand Down
2 changes: 2 additions & 0 deletions packages/api-routes/src/history.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ export async function historyRoutes(app: FastifyInstance) {
answerText: querySnapshots.answerText,
citedDomains: querySnapshots.citedDomains,
competitorOverlap: querySnapshots.competitorOverlap,
recommendedCompetitors: querySnapshots.recommendedCompetitors,
location: querySnapshots.location,
createdAt: querySnapshots.createdAt,
})
Expand Down Expand Up @@ -96,6 +97,7 @@ export async function historyRoutes(app: FastifyInstance) {
answerText: s.answerText,
citedDomains: tryParseJson(s.citedDomains, [] as string[]),
competitorOverlap: tryParseJson(s.competitorOverlap, [] as string[]),
recommendedCompetitors: tryParseJson(s.recommendedCompetitors, [] as string[]),
location: s.location,
createdAt: s.createdAt,
})),
Expand Down
2 changes: 2 additions & 0 deletions packages/api-routes/src/runs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -298,6 +298,7 @@ export async function runRoutes(app: FastifyInstance, opts: RunRoutesOptions) {
answerText: querySnapshots.answerText,
citedDomains: querySnapshots.citedDomains,
competitorOverlap: querySnapshots.competitorOverlap,
recommendedCompetitors: querySnapshots.recommendedCompetitors,
location: querySnapshots.location,
rawResponse: querySnapshots.rawResponse,
createdAt: querySnapshots.createdAt,
Expand All @@ -321,6 +322,7 @@ export async function runRoutes(app: FastifyInstance, opts: RunRoutesOptions) {
answerText: s.answerText,
citedDomains: tryParseJson(s.citedDomains, []),
competitorOverlap: tryParseJson(s.competitorOverlap, []),
recommendedCompetitors: tryParseJson(s.recommendedCompetitors, []),
model: s.model ?? rawParsed.model,
location: s.location,
groundingSources: rawParsed.groundingSources,
Expand Down
Loading
Loading