diff --git a/app/client/AgentReachView.tsx b/app/client/AgentReachView.tsx new file mode 100644 index 0000000..d4970ef --- /dev/null +++ b/app/client/AgentReachView.tsx @@ -0,0 +1,1256 @@ +import React, { useEffect, useMemo, useRef, useState } from "react"; + +import type { + AgentReachCandidateCardViewModel, + AgentReachChipTone, + AgentReachCoverageViewModel, + AgentReachSummaryCardViewModel, + AgentReachViewModel, +} from "../../src/visualConsole/types.ts"; + +export type AgentReachSortMode = "attention" | "time" | "evidence"; +type AgentReachUiCopy = ReturnType; + +export type AgentReachViewProps = Pick< + AgentReachViewModel, + "status" | "summary_cards" | "filters" | "candidates" | "coverage" | "direction_snapshot" | "warnings" | "state" | "banner" +> & { + lang?: "zh" | "en"; + initialTheme: "light" | "dark"; + initialSelectedId?: string | null; +}; + +declare global { + interface Window { + __AGENTREACH_INITIAL_DATA__?: Partial; + } +} + +const PAGE_SIZE = 4; + +function uiText(lang: "zh" | "en" | undefined, zh: string, en: string): string { + return lang === "en" ? en : zh; +} + +function agentReachCopy(lang: "zh" | "en" | undefined) { + return { + lang: lang === "en" ? "en" : "zh", + unknown: uiText(lang, "未知", "Unknown"), + externalAttention: uiText(lang, "外部关注度", "External Attention"), + object: uiText(lang, "对象", "Object"), + evidence: uiText(lang, "证据", "Evidence"), + source: uiText(lang, "来源", "Sources"), + pending: uiText(lang, "待补充", "Pending"), + crossPlatform: uiText(lang, "跨平台", "Cross-platform"), + cannotPrimary: uiText(lang, "不能作为主结论", "Not a primary conclusion"), + evidenceAggregation: uiText(lang, "证据聚合", "Evidence Aggregate"), + mention: uiText(lang, "提及", "Mentions"), + distinctSources: uiText(lang, "独立来源", "Distinct Sources"), + activeDays: uiText(lang, "活跃天数", "Active Days"), + lastSeen: uiText(lang, "最近看到", "Last Seen"), + crossPlatformConfirm: uiText(lang, "跨平台外部确认", "Cross-platform confirmation"), + singlePlatformSignal: uiText(lang, "单平台信号", "Single-platform signal"), + noEvidenceAggregate: uiText(lang, "当前候选没有可绑定的证据聚合。", "No bindable evidence aggregate is available for this candidate."), + evidenceSources: uiText(lang, "证据来源", "Evidence Sources"), + publishedAt: uiText(lang, "发布时间", "Published"), + observedAt: uiText(lang, "观察时间", "Observed"), + visitSource: uiText(lang, "访问来源", "Open Source"), + sourceUnavailable: uiText(lang, "本次数据没有来源明细;仍可查看证据聚合与谁在讨论。", "Source detail rows are not included in this run; evidence aggregates and discussion identity remain available."), + whoIsDiscussing: uiText(lang, "谁在讨论", "Who Is Discussing"), + publicSources: uiText(lang, "公开来源", "Public Sources"), + namedSources: uiText(lang, "具名来源", "Named Sources"), + anonymizedSources: uiText(lang, "未命中具名来源", "No Named Match"), + anonymizedDiscussionNote: uiText(lang, "暂未命中具名讨论者;当前仅能确认 {count} 个脱敏独立来源,覆盖 {platforms}。", "No named discussion source matched; currently only {count} anonymized distinct source(s) are confirmed across {platforms}."), + additionalAnonymizedDiscussionNote: uiText(lang, "另有 {count} 个脱敏独立来源,覆盖 {platforms}。", "{count} additional anonymized distinct source(s) are confirmed across {platforms}."), + noDiscussionIdentity: uiText(lang, "暂无可识别讨论者,仅保留平台与证据计数。", "No identifiable discussion source is available; only platform and evidence counts are retained."), + eventUnit: uiText(lang, "条", "events"), + type: uiText(lang, "类型", "Types"), + tier: uiText(lang, "层级", "Tiers"), + failed: uiText(lang, "外部层执行失败", "External Layer Failed"), + selectCandidate: uiText(lang, "选择候选后查看外部证据详情", "Select a candidate to inspect external evidence"), + failedBody: uiText(lang, "请查看平台 coverage 与失败说明,不要把失败渲染成无信号。", "Check platform coverage and failure notes instead of rendering failures as no signal."), + emptyBody: uiText(lang, "今天暂无可展示的外部候选,页面仍保留平台审计状态。", "No external candidates are available today. Platform audit state is still retained."), + observing: uiText(lang, "观察中", "Observing"), + externalSupplement: uiText(lang, "外部补证", "External Evidence"), + attention: uiText(lang, "关注度", "Attention"), + objectBinding: uiText(lang, "它是什么", "What It Is"), + whyWatch: uiText(lang, "为什么值得看", "Why Watch"), + confirmationGaps: uiText(lang, "还需要确认什么", "What Still Needs Confirmation"), + directionSignal: uiText(lang, "方向线索", "Direction Signal"), + boundObject: uiText(lang, "已绑定对象", "Bound Object"), + discussionSignal: uiText(lang, "讨论", "Discussion"), + needsPrimary: uiText(lang, "需主源确认", "Needs Primary Confirmation"), + enterDaily: uiText(lang, "进入日报", "Daily"), + enterWeekly: uiText(lang, "进入周报", "Weekly"), + yes: uiText(lang, "是", "Yes"), + no: uiText(lang, "否", "No"), + secondaryOnly: uiText(lang, "外部信号只作为次级判断", "External signals are secondary-only"), + judgement: uiText(lang, "当前判断和建议", "Current Judgement"), + defaultReason: uiText(lang, "先查看证据来源,再决定是否进入后续观察。", "Review evidence sources before deciding whether to keep observing."), + noCaveat: uiText(lang, "无额外 caveat", "No extra caveat"), + noConfirmationGap: uiText(lang, "暂无额外确认项", "No extra confirmation gap"), + viewEvidence: uiText(lang, "查看证据", "View Evidence"), + pendingObserverBridge: uiText(lang, "待接入观察", "Observer Bridge Pending"), + observerBridgeReadOnly: uiText(lang, "观察池写入待接入,当前仅支持查看证据。", "Observer write-back is pending; evidence review is read-only for now."), + actionReadOnlyNoSource: uiText(lang, "本条没有可打开的来源明细,观察池写入待接入。", "No openable source detail is available for this item; observer write-back is pending."), + footerKicker: uiText(lang, "方向观察与平台状态", "Direction Watch & Platform Status"), + footerTitle: uiText(lang, "今日外部讨论快照", "Today External Discussion Snapshot"), + trendKicker: uiText(lang, "外部趋势", "External Trend"), + trendTitle: uiText(lang, "外部讨论观察", "External Discussion Watch"), + trendSubtitle: uiText(lang, "基于当前外部候选整理,趋势判断需等待近 7 日窗口确认。", "Built from current external candidates; trend claims still need the 7-day window."), + projectTrend: uiText(lang, "项目趋势", "Project Trends"), + directionTrend: uiText(lang, "方向观察", "Direction Watch"), + trendCode: uiText(lang, "外部趋势", "External Trend"), + trendAttention: uiText(lang, "外部关注", "Attention"), + trendSources: uiText(lang, "来源", "Sources"), + trendActive: uiText(lang, "活跃", "Active"), + trendDays: uiText(lang, "天", "days"), + trendObserveOnly: uiText(lang, "仅观察", "Observe Only"), + trendExternalBoost: uiText(lang, "外部补强", "External Boost"), + trendSingleDay: uiText(lang, "单日快照", "Single-day"), + trendNoItems: uiText(lang, "当前暂无可展示的外部趋势项。", "No external trend items are available yet."), + platformStatusTitle: uiText(lang, "平台与数据状态", "Platform & Data Status"), + platformStatusKicker: uiText(lang, "覆盖情况", "Coverage"), + platformStatusSubtitle: uiText(lang, "用于判断外部层可信度,不是项目排名指标。", "Used to judge external-layer reliability, not project ranking."), + platformBoundary: uiText(lang, "外部信号只作为次级判断,需 GitHub / Trendshift 主链路确认。", "External signals are secondary evidence and need GitHub / Trendshift confirmation."), + directionEvidence: uiText(lang, "方向证据", "Direction Evidence"), + directionCandidates: uiText(lang, "方向候选", "Direction Candidates"), + pendingExternalSignals: uiText(lang, "待归档外部信号", "Pending External Signals"), + zeroRelevant: uiText(lang, "无相关结果", "Zero Relevant Results"), + notConfigured: uiText(lang, "未配置", "Not Configured"), + heroPill: uiText(lang, "AgentReach 外部层", "AgentReach External Layer"), + heroTitle: uiText(lang, "外部发现", "External Discovery"), + heroBody: uiText( + lang, + "从 X / Reddit / HN / 官方来源发现项目与补证信号,先看候选,再看证据。", + "Discover project candidates and supporting signals from X, Reddit, HN, and official sources. Review candidates first, then evidence.", + ), + secondaryWarning: uiText(lang, "外部信号只作为次级判断,不单独形成主结论。", "External signals are secondary-only and do not form primary conclusions alone."), + dataSource: uiText(lang, "数据源", "Data Source"), + filters: uiText(lang, "候选范围", "Candidate Range"), + all: uiText(lang, "全部", "All"), + sort: uiText(lang, "排序", "Sort"), + candidateList: uiText(lang, "候选列表", "Candidates"), + candidateTitle: uiText(lang, "项目 / 方向候选", "Project / Direction Candidates"), + listFailed: uiText(lang, "外部层执行失败", "External layer failed"), + noCandidates: uiText(lang, "今天暂无可展示的外部候选", "No external candidates today"), + noCandidatesBody: uiText( + lang, + "已保留平台 coverage,避免把 failed / not_configured 误写成无信号。", + "Platform coverage is retained so failed / not_configured is not mistaken for no signal.", + ), + externalCandidateSummary: uiText( + lang, + "外部层候选,需要结合主源数据继续确认。", + "External-only candidate. Continue validating it with primary-source data before drawing conclusions.", + ), + evidenceAuthoritySummary: uiText( + lang, + "外部来源提供了候选证据,但只能作为次级判断信号。", + "External sources provide supporting evidence, but this remains a secondary signal.", + ), + evidenceIntensitySummary: uiText( + lang, + "当前证据来自已脱敏的外部事件聚合。", + "The current evidence is aggregated from sanitized external events.", + ), + evidencePersistenceSummary: uiText( + lang, + "当前仅作为今日外部讨论快照,后续趋势需要多日聚合确认。", + "This is a single-day external discussion snapshot; trend claims require multi-day aggregation.", + ), + externalOnlyCaveat: uiText( + lang, + "外部热度不能直接提升为主榜结论。", + "External attention cannot be promoted directly into a primary ranking conclusion.", + ), + statusReasonFallback: uiText(lang, "外部层已执行。", "External layer has run."), + warningFallback: uiText(lang, "存在外部层提示,请查看平台状态。", "External-layer notes are available. Check platform status."), + previousPage: uiText(lang, "上一页", "Prev"), + nextPage: uiText(lang, "下一页", "Next"), + }; +} + +function isEnglish(copy: AgentReachUiCopy): boolean { + return copy.lang === "en"; +} + +function enByKey(key: string, fallback: string, values: Record): string { + return values[key] ?? fallback; +} + +function summaryCardLabel(card: AgentReachSummaryCardViewModel, copy: AgentReachUiCopy): string { + if (!isEnglish(copy)) return card.label; + return enByKey(card.key, card.label, { + overview: "Overview", + new_discoveries: "New Candidates", + project_evidence: "Existing Candidate Evidence", + direction_observations: "Direction Watch", + official_releases: "Official Confirmation", + }); +} + +function summaryCardBody(card: AgentReachSummaryCardViewModel, copy: AgentReachUiCopy): string { + if (!isEnglish(copy)) return card.body; + return enByKey(card.key, card.body, { + overview: "External signals across discovery, supporting evidence, and direction watch.", + new_discoveries: "Externally discovered projects or objects that still need primary-source confirmation.", + project_evidence: "External discussion that supplements existing candidates.", + direction_observations: "Direction-level signals for today's snapshot only.", + official_releases: "Official-source signals that still remain secondary to the primary ranking.", + }); +} + +function typeLabel(key: string, fallback: string, copy: AgentReachUiCopy): string { + if (!isEnglish(copy)) return fallback; + return enByKey(key, fallback, { + new_discovery: "New Discovery", + project_evidence: "External Evidence", + direction_watch: "Direction Watch", + official_signal: "Official Signal", + needs_confirmation: "Needs Confirmation", + }); +} + +function sortLabel(key: string, fallback: string, copy: AgentReachUiCopy): string { + if (!isEnglish(copy)) return fallback; + return enByKey(key, fallback, { + attention: "Attention", + time: "Time", + evidence: "Evidence", + }); +} + +function platformLabelForView(platform: string, fallback: string | undefined, copy: AgentReachUiCopy): string { + if (!isEnglish(copy)) return fallback ?? platform; + return enByKey(platform, fallback ?? platform, { + x_twitter: "X", + reddit: "Reddit", + hacker_news: "HN", + official_web: "Official Web", + official_blog: "Official Blog", + official: "Official", + }); +} + +function platformLabelsForView(platforms: string[], fallbackLabels: string[], copy: AgentReachUiCopy): string[] { + return platforms.map((platform, index) => platformLabelForView(platform, fallbackLabels[index], copy)); +} + +function targetTypeLabelForView(type: string, fallback: string, copy: AgentReachUiCopy): string { + if (!isEnglish(copy)) return fallback; + return enByKey(type, fallback, { + project: "Project", + paper: "Paper", + product: "Product", + topic: "Topic", + unknown: "Object", + }); +} + +function objectLabelForView(candidate: AgentReachCandidateCardViewModel, copy: AgentReachUiCopy): string { + if (!isEnglish(copy)) return candidate.object_label; + const level = candidate.scope === "direction" ? "Direction level" : "Project level"; + return `${targetTypeLabelForView(candidate.target_type, candidate.target_type_label, copy)} · ${level}`; +} + +function qualificationLabelForView(candidate: AgentReachCandidateCardViewModel, copy: AgentReachUiCopy): string { + if (!isEnglish(copy)) return candidate.qualification_label; + return enByKey(candidate.qualification, candidate.qualification_label, { + observe: "Observe", + needs_primary_confirmation: "Needs Primary Confirmation", + supporting_evidence_only: "Supporting Evidence Only", + }); +} + +function bindingConfidenceLabelForView(candidate: AgentReachCandidateCardViewModel, copy: AgentReachUiCopy): string { + if (!isEnglish(copy)) return candidate.binding_confidence_label; + return enByKey(candidate.binding_confidence, candidate.binding_confidence_label, { + high: "High", + medium: "Medium", + low: "Low", + unbound: "Unbound", + }); +} + +function rawEventKindLabelForView(kind: string, fallback: string, copy: AgentReachUiCopy): string { + if (!isEnglish(copy)) return fallback; + return enByKey(kind, fallback, { + mention: "Mention", + discussion: "Discussion", + official_release: "Official Release", + blog_post: "Blog Update", + question: "Question", + showcase: "Showcase", + unknown: "Unknown Event", + }); +} + +function actorTypeLabelForView(type: string, fallback: string, copy: AgentReachUiCopy): string { + if (!isEnglish(copy)) return fallback; + return enByKey(type, fallback, { + institution: "Institution", + team: "Team", + person: "Person", + community: "Community", + unknown: "Unknown", + }); +} + +function actorTierLabelForView(tier: string, fallback: string, copy: AgentReachUiCopy): string { + if (!isEnglish(copy)) return fallback; + return enByKey(tier, fallback, { + core: "Core", + proven: "Proven", + watch: "Watch", + ordinary: "Ordinary", + unknown: "Unknown", + }); +} + +function coverageStatusLabelForView(item: AgentReachCoverageViewModel, copy: AgentReachUiCopy): string { + if (!isEnglish(copy)) return item.status_label; + return enByKey(item.status, item.status_label, { + ok: "OK", + partial: "Partial", + zero_relevant_results: "No Relevant Results", + not_configured: "Not Configured", + manual_import_only: "Manual Import Only", + unavailable: "Unavailable", + failed: "Failed", + missing: "Missing", + }); +} + +function metricLabelForView(key: string, fallback: string, copy: AgentReachUiCopy): string { + if (!isEnglish(copy)) return fallback; + return enByKey(key, fallback, { + likes: "likes", + reposts: "reposts", + comments: "comments", + upvotes: "upvotes", + replies: "replies", + }); +} + +function isDirectionSignalForView(candidate: AgentReachCandidateCardViewModel): boolean { + return candidate.scope === "direction" || (candidate.binding_confidence === "unbound" && !candidate.target_url); +} + +function boundObjectTextForView(candidate: AgentReachCandidateCardViewModel, copy: AgentReachUiCopy): string { + if (!isEnglish(copy)) { + if (candidate.target_type === "project" && /^https?:\/\/github\.com\//i.test(candidate.target_url ?? "")) return "GitHub repo"; + return `${candidate.target_type_label}对象`; + } + if (candidate.target_type === "project" && /^https?:\/\/github\.com\//i.test(candidate.target_url ?? "")) return "GitHub repo"; + return `${targetTypeLabelForView(candidate.target_type, candidate.target_type_label, copy).toLowerCase()} object`; +} + +function hasConfirmedBindingForView(candidate: AgentReachCandidateCardViewModel): boolean { + return Boolean(candidate.target_url && candidate.binding_confidence !== "unbound"); +} + +function platformTextForView(candidate: AgentReachCandidateCardViewModel, copy: AgentReachUiCopy): string { + const platforms = candidate.platforms.length > 0 ? platformLabelsForView(candidate.platforms, candidate.platform_labels, copy).join(" / ") : copy.pending; + return platforms; +} + +function candidateIntroLineForView(candidate: AgentReachCandidateCardViewModel, copy: AgentReachUiCopy): string { + if (!isEnglish(copy)) return candidate.intro_line; + const platforms = platformTextForView(candidate, copy); + const boundObject = boundObjectTextForView(candidate, copy); + if (isDirectionSignalForView(candidate)) { + return `Direction signal, not yet bound to a specific GitHub repo or paper; found through ${platforms}.`; + } + if (candidate.type_key === "project_evidence") { + return `Existing candidate with external supporting evidence from ${platforms}; bound to a ${boundObject}.`; + } + if (candidate.type_key === "official_signal") { + return hasConfirmedBindingForView(candidate) + ? `Official-source release or update signal bound to a ${boundObject}.` + : `Official-source release or update signal found a ${boundObject} URL; binding still needs confirmation.`; + } + return hasConfirmedBindingForView(candidate) + ? `${boundObject} candidate found through ${platforms}; bound object is available.` + : `${boundObject} candidate found through ${platforms}; object URL is available but binding still needs primary-source confirmation.`; +} + +function candidateAppearanceReasonForView(candidate: AgentReachCandidateCardViewModel, copy: AgentReachUiCopy): string { + if (!isEnglish(copy)) return candidate.appearance_reason; + const platforms = platformTextForView(candidate, copy); + if (isDirectionSignalForView(candidate)) { + return `${platforms} surfaced direction-level discussion, so this is kept as an external direction signal.`; + } + if (candidate.type_key === "project_evidence") { + return `${platforms} surfaced discussion around an existing candidate, so this is kept as external supporting evidence.`; + } + if (candidate.type_key === "official_signal") { + return `${platforms} surfaced an official release or update signal.`; + } + return `${platforms} surfaced discussion around this object, so it enters as an external candidate.`; +} + +function candidateWhyWatchReasonsForView(candidate: AgentReachCandidateCardViewModel, copy: AgentReachUiCopy): string[] { + if (!isEnglish(copy)) return candidate.why_watch_reasons.length > 0 ? candidate.why_watch_reasons : [copy.defaultReason]; + const platforms = platformTextForView(candidate, copy); + const reasons = [ + candidate.external_attention_score !== null ? `External attention ${candidate.external_attention_score}` : null, + candidate.type_key === "official_signal" ? "Official source published or updated this signal." : `${platforms} discussion is available.`, + candidate.cross_platform ? "Cross-platform confirmation lowers single-platform noise risk." : null, + isDirectionSignalForView(candidate) + ? "Direction signal can feed follow-up trend watch." + : hasConfirmedBindingForView(candidate) + ? "Bound to a specific object." + : "Object URL is available, but binding still needs confirmation.", + candidate.can_enter_daily ? "Can enter the daily report as secondary evidence." : null, + candidate.can_enter_weekly ? "Can enter the weekly report as secondary evidence." : null, + candidate.named_actor_count > 0 + ? `Matched ${candidate.named_actor_count} named discussion source(s).` + : candidate.distinct_actor_count > 0 + ? `Confirmed ${candidate.distinct_actor_count} sanitized distinct source(s).` + : null, + ].filter((reason): reason is string => Boolean(reason)); + return [...new Set(reasons)].slice(0, 5); +} + +function candidateConfirmationGapsForView(candidate: AgentReachCandidateCardViewModel, copy: AgentReachUiCopy): string[] { + if (!isEnglish(copy)) return candidate.confirmation_gaps.length > 0 ? candidate.confirmation_gaps : [copy.noConfirmationGap]; + const gaps = [ + isDirectionSignalForView(candidate) ? "Not yet bound to a specific GitHub repo or paper." : null, + candidate.qualification === "needs_primary_confirmation" ? "Needs GitHub / Trendshift primary-source confirmation." : null, + candidate.target_url && candidate.binding_confidence === "unbound" ? "Object binding confidence still needs confirmation." : null, + candidate.cannot_be_primary_conclusion ? "Cannot be used as a primary ranking conclusion." : null, + !candidate.cross_platform ? "Needs cross-platform or multi-day confirmation." : null, + candidate.named_actor_count === 0 ? "No named discussion source matched yet." : null, + ].filter((gap): gap is string => Boolean(gap)); + return [...new Set(gaps)].slice(0, 4); +} + +function candidateReasonChipsForView(candidate: AgentReachCandidateCardViewModel, copy: AgentReachUiCopy): string[] { + const platform = candidate.platforms[0] + ? platformLabelForView(candidate.platforms[0], candidate.platform_labels[0], copy) + : null; + const chips = [ + isDirectionSignalForView(candidate) ? copy.directionSignal : hasConfirmedBindingForView(candidate) ? copy.boundObject : null, + platform ? `${platform} ${copy.discussionSignal}` : null, + candidate.cross_platform ? copy.crossPlatform : null, + candidate.cannot_be_primary_conclusion ? copy.cannotPrimary : null, + ].filter((chip): chip is string => Boolean(chip)); + return [...new Set(chips)].slice(0, 3); +} + +function evidenceSummaryForView( + evidence: AgentReachCandidateCardViewModel["evidence_cards"][number], + field: "authority" | "intensity" | "persistence", + copy: AgentReachUiCopy, +): string { + if (!isEnglish(copy)) { + if (field === "authority") return evidence.authority_summary_cn; + if (field === "intensity") return evidence.intensity_summary_cn; + return evidence.persistence_summary_cn; + } + if (field === "authority") return copy.evidenceAuthoritySummary; + if (field === "intensity") return copy.evidenceIntensitySummary; + return copy.evidencePersistenceSummary; +} + +function evidenceCaveatForView(evidence: AgentReachCandidateCardViewModel["evidence_cards"][number], copy: AgentReachUiCopy): string | null { + if (!isEnglish(copy)) return evidence.caveats.length > 0 ? evidence.caveats.join(";") : null; + return evidence.caveats.length > 0 ? copy.externalOnlyCaveat : null; +} + +function statusReasonForView(reason: string | null | undefined, copy: AgentReachUiCopy): string { + if (!reason) return copy.statusReasonFallback; + if (!isEnglish(copy)) return reason; + return /[\u3400-\u9fff]/.test(reason) ? copy.statusReasonFallback : reason; +} + +function warningForView(warning: string, copy: AgentReachUiCopy): string { + if (!isEnglish(copy)) return warning; + if (!/[\u3400-\u9fff]/.test(warning)) return warning; + if (warning.includes("来源明细不可用")) return copy.sourceUnavailable; + if (warning.includes("只读取到 run summary")) return "Only run-summary metadata is available; candidate and source details are unavailable."; + if (warning.includes("aggregate 读取状态")) return "Aggregate read status is not OK. Check data generation logs."; + return copy.warningFallback; +} + +const DEFAULT_PROPS: AgentReachViewProps = { + lang: "zh", + initialTheme: "light", + initialSelectedId: null, + status: { + provider_status: "missing", + label: "missing", + reason: "DailyExternalAggregate 未生成", + tone: "neutral", + data_source: "missing", + aggregate_path: null, + }, + summary_cards: [], + filters: { + type_chips: [], + source_chips: [], + sort_options: [], + status_chips: [], + }, + candidates: [], + coverage: [], + direction_snapshot: { + direction_evidence_count: 0, + direction_candidate_count: 0, + direction_label_counts: [], + pending_external_signal_count: 0, + zero_relevant_results_count: 0, + not_configured_count: 0, + partial_count: 0, + }, + warnings: [], + state: { + status: "failed", + reasons: [], + }, + banner: { + title: "AgentReach", + context_label: "daily", + generated_at: null, + enhancement_status: "unknown", + mode_label: "unknown", + github_enrichment_status: "external-discovery", + source_health: "missing", + notes: [], + }, +}; + +function readPayloadFromDom(): Partial | null { + if (typeof document === "undefined") return null; + const payloadNode = document.getElementById("agentreach-react-payload"); + if (!payloadNode?.textContent) return null; + try { + return JSON.parse(payloadNode.textContent) as Partial; + } catch { + return null; + } +} + +function readPayloadFromWindow(): Partial | null { + if (typeof window === "undefined") return null; + const payload = window.__AGENTREACH_INITIAL_DATA__; + return payload && typeof payload === "object" ? payload : null; +} + +export function parseAgentReachViewPayload(): AgentReachViewProps { + const payload = readPayloadFromDom() ?? readPayloadFromWindow() ?? {}; + return { + ...DEFAULT_PROPS, + ...payload, + status: payload.status ?? DEFAULT_PROPS.status, + summary_cards: payload.summary_cards ?? DEFAULT_PROPS.summary_cards, + filters: payload.filters ?? DEFAULT_PROPS.filters, + candidates: payload.candidates ?? DEFAULT_PROPS.candidates, + coverage: payload.coverage ?? DEFAULT_PROPS.coverage, + direction_snapshot: payload.direction_snapshot ?? DEFAULT_PROPS.direction_snapshot, + warnings: payload.warnings ?? DEFAULT_PROPS.warnings, + state: payload.state ?? DEFAULT_PROPS.state, + banner: payload.banner ?? DEFAULT_PROPS.banner, + }; +} + +function toneClass(tone: AgentReachChipTone | undefined): string { + switch (tone) { + case "good": + return "agentreach-tone-good"; + case "warn": + return "agentreach-tone-warn"; + case "bad": + return "agentreach-tone-bad"; + case "accent": + return "agentreach-tone-accent"; + default: + return "agentreach-tone-neutral"; + } +} + +function formatDateTime(value: string | null | undefined, copy: AgentReachUiCopy): string { + if (!value) return copy.unknown; + return value.replace("T", " ").replace(/\.000Z?$/, "").replace(/Z$/, ""); +} + +function sortCandidates(candidates: AgentReachCandidateCardViewModel[], sortMode: AgentReachSortMode): AgentReachCandidateCardViewModel[] { + return [...candidates].sort((left, right) => { + const priorityDelta = candidateDisplayPriority(left) - candidateDisplayPriority(right); + if (priorityDelta !== 0) return priorityDelta; + if (sortMode === "time") { + return String(right.last_seen_at ?? "").localeCompare(String(left.last_seen_at ?? "")) || right.evidence_count - left.evidence_count; + } + if (sortMode === "evidence") { + return right.evidence_count - left.evidence_count || right.platforms.length - left.platforms.length; + } + return (right.external_attention_score ?? -1) - (left.external_attention_score ?? -1) || right.evidence_count - left.evidence_count; + }); +} + +function candidateDisplayPriority(candidate: AgentReachCandidateCardViewModel): number { + if (candidate.type_key === "new_discovery" && candidate.target_url) return 0; + if (candidate.type_key === "official_signal" && candidate.target_url) return 1; + if (candidate.type_key === "project_evidence") return 2; + if (candidate.type_key === "needs_confirmation" && candidate.target_url) return 3; + if (candidate.scope === "direction" || candidate.type_key === "direction_watch") return 4; + return 5; +} + +function defaultActiveType(props: AgentReachViewProps): string { + const newDiscovery = props.filters.type_chips.find((chip) => chip.key === "new_discovery"); + if (newDiscovery && Number(newDiscovery.count ?? 0) > 0) return "new_discovery"; + return "all"; +} + +function candidateMatchesType(candidate: AgentReachCandidateCardViewModel, activeType: string): boolean { + return activeType === "all" || candidate.type_key === activeType; +} + +function paginate(items: T[], page: number): { items: T[]; page: number; pageCount: number; start: number; end: number } { + const pageCount = Math.max(1, Math.ceil(items.length / PAGE_SIZE)); + const currentPage = Math.min(Math.max(1, page), pageCount); + const start = (currentPage - 1) * PAGE_SIZE; + const paged = items.slice(start, start + PAGE_SIZE); + return { + items: paged, + page: currentPage, + pageCount, + start, + end: start + paged.length, + }; +} + +function SummaryCard(props: { card: AgentReachSummaryCardViewModel; copy: AgentReachUiCopy }) { + return ( +
+ {summaryCardLabel(props.card, props.copy)} + {props.card.value} +

{summaryCardBody(props.card, props.copy)}

+
+ ); +} + +function CandidateCard(props: { + candidate: AgentReachCandidateCardViewModel; + selected: boolean; + onSelect: (id: string) => void; + copy: AgentReachUiCopy; +}) { + const candidate = props.candidate; + const copy = props.copy; + return ( + + ); +} + +function MetricCell(props: { label: string; value: string | number }) { + return ( +
+ {props.label} + {props.value} +
+ ); +} + +function EvidenceSection(props: { candidate: AgentReachCandidateCardViewModel; copy: AgentReachUiCopy; sectionRef?: React.Ref }) { + const candidate = props.candidate; + const copy = props.copy; + return ( +
+
+

{copy.evidenceAggregation}

+ {candidate.evidence_cards.length} +
+ {candidate.evidence_cards.length > 0 ? ( +
+ {candidate.evidence_cards.map((evidence) => ( +
+
+ {platformLabelsForView(evidence.platforms, evidence.platform_labels, copy).join(" / ") || evidence.evidence_id} + + {evidence.cross_platform ? copy.crossPlatformConfirm : copy.singlePlatformSignal} + +
+
+ + + + +
+

{evidenceSummaryForView(evidence, "authority", copy)}

+

{evidenceSummaryForView(evidence, "intensity", copy)}

+

{evidenceSummaryForView(evidence, "persistence", copy)}

+ {evidenceCaveatForView(evidence, copy) ?

{evidenceCaveatForView(evidence, copy)}

: null} +
+ ))} +
+ ) : ( +

{copy.noEvidenceAggregate}

+ )} +
+ ); +} + +function SourceCards(props: { candidate: AgentReachCandidateCardViewModel; copy: AgentReachUiCopy }) { + const sources = props.candidate.source_cards; + const copy = props.copy; + return ( +
+
+

{copy.evidenceSources}

+ {sources.length} +
+ {sources.length > 0 ? ( +
+ {sources.map((source) => ( +
+
+ {platformLabelForView(source.platform, source.platform_label, copy)} + {rawEventKindLabelForView(source.raw_event_kind, source.raw_event_kind_label, copy)} +
+

{source.title}

+

{source.target_name}

+
+ {copy.publishedAt} {formatDateTime(source.source_published_at, copy)} + {copy.observedAt} {formatDateTime(source.observed_at, copy)} + {actorTypeLabelForView(source.actor_type, source.actor_type_label, copy)} / {actorTierLabelForView(source.actor_tier, source.actor_tier_label, copy)} +
+ {source.metrics.length > 0 ? ( +
+ {source.metrics.map((metric) => ( + + {metricLabelForView(metric.key, metric.label, copy)} {metric.value} + + ))} +
+ ) : null} + {source.source_url ? ( + + {copy.visitSource} + + ) : null} +
+ ))} +
+ ) : ( +

{copy.sourceUnavailable}

+ )} +
+ ); +} + +function WhoIsDiscussing(props: { candidate: AgentReachCandidateCardViewModel; copy: AgentReachUiCopy }) { + const candidate = props.candidate; + const copy = props.copy; + const namedActors = candidate.named_registry_actors ?? []; + const publicActors = candidate.public_actors ?? []; + const platformLabel = candidate.platforms.length > 0 ? platformLabelsForView(candidate.platforms, candidate.platform_labels, copy).join(" / ") : copy.pending; + const anonymizedRemainder = Math.max(0, candidate.distinct_actor_count - publicActors.length - namedActors.length); + const anonymizedNote = candidate.distinct_actor_count > 0 + ? copy.anonymizedDiscussionNote + .replace("{count}", String(candidate.distinct_actor_count)) + .replace("{platforms}", platformLabel) + : copy.noDiscussionIdentity; + const additionalAnonymizedNote = anonymizedRemainder > 0 + ? copy.additionalAnonymizedDiscussionNote + .replace("{count}", String(anonymizedRemainder)) + .replace("{platforms}", platformLabel) + : null; + const identifiedCount = publicActors.length + namedActors.length; + + return ( +
+
+

{copy.whoIsDiscussing}

+ {identifiedCount > 0 ? `${copy.publicSources} ${identifiedCount}` : copy.anonymizedSources} +
+ {identifiedCount > 0 ? ( +
+ {publicActors.map((actor) => ( +
+ {actor.display_name} + {actorTypeLabelForView(actor.actor_type, actor.actor_type_label, copy)} · {actor.source_kind_label} + {platformLabelsForView(actor.platforms, actor.platform_labels, copy).join(" / ") || copy.pending} + {actor.event_count} {copy.eventUnit} +
+ ))} + {namedActors.map((actor) => ( +
+ {actor.display_name} + {actorTypeLabelForView(actor.actor_type, actor.actor_type_label, copy)} · {actorTierLabelForView(actor.registry_tier, actor.registry_tier_label, copy)} + {platformLabelsForView(actor.platforms, actor.platform_labels, copy).join(" / ") || copy.pending} + {actor.event_count} {copy.eventUnit} +
+ ))} + {additionalAnonymizedNote ?

{additionalAnonymizedNote}

: null} +
+ ) : ( +

{anonymizedNote}

+ )} +
+ ); +} + +function DetailPanel(props: { candidate: AgentReachCandidateCardViewModel | null; statusFailed: boolean; copy: AgentReachUiCopy }) { + const candidate = props.candidate; + const copy = props.copy; + const detailScrollRef = useRef(null); + const evidenceSectionRef = useRef(null); + + function handleViewEvidence(): void { + const container = detailScrollRef.current; + const evidenceSection = evidenceSectionRef.current; + if (!container || !evidenceSection) return; + + const top = evidenceSection.offsetTop - container.offsetTop; + container.scrollTo({ + top: Math.max(0, top), + behavior: "smooth", + }); + } + + if (!candidate) { + return ( + + ); + } + + const firstSourceUrl = candidate.source_cards.find((source) => source.source_url)?.source_url ?? candidate.target_url; + const confirmationGaps = candidateConfirmationGapsForView(candidate, copy).slice(0, 2); + return ( + + ); +} + +function trendVerdict(candidate: AgentReachCandidateCardViewModel, copy: AgentReachUiCopy): { label: string; tone: AgentReachChipTone } { + if (candidate.type_key === "project_evidence" || candidate.cross_platform) { + return { label: copy.trendExternalBoost, tone: "accent" }; + } + if (candidate.evidence_cards.some((item) => item.active_day_count > 1)) { + return { label: copy.trendObserveOnly, tone: "good" }; + } + return { label: copy.trendSingleDay, tone: "warn" }; +} + +function trendCandidates( + candidates: AgentReachCandidateCardViewModel[], + mode: "project" | "direction", +): AgentReachCandidateCardViewModel[] { + return candidates + .filter((candidate) => (mode === "direction" ? isDirectionSignalForView(candidate) : !isDirectionSignalForView(candidate))) + .sort( + (left, right) => + (right.external_attention_score ?? -1) - (left.external_attention_score ?? -1) || + right.evidence_count - left.evidence_count || + left.display_name.localeCompare(right.display_name), + ) + .slice(0, 4); +} + +function TrendObservationPanel(props: { + candidates: AgentReachCandidateCardViewModel[]; + directionSnapshot: AgentReachViewProps["direction_snapshot"]; + selectedCandidateId: string | null; + onSelectCandidate: (candidateId: string) => void; + copy: AgentReachUiCopy; +}) { + const copy = props.copy; + const projectItems = trendCandidates(props.candidates, "project"); + const directionItems = trendCandidates(props.candidates, "direction"); + const [mode, setMode] = useState<"project" | "direction">(projectItems.length > 0 ? "project" : "direction"); + const items = mode === "project" ? projectItems : directionItems; + + return ( +
+
+
+ {copy.trendKicker} +

{copy.trendTitle}

+

{copy.trendSubtitle}

+
+
+ + +
+
+ + {items.length > 0 ? ( +
+ {items.map((candidate, index) => { + const verdict = trendVerdict(candidate, copy); + const platforms = candidate.platforms.length > 0 ? platformLabelsForView(candidate.platforms, candidate.platform_labels, copy).join(" / ") : copy.pending; + const activeDays = Math.max(1, ...candidate.evidence_cards.map((item) => item.active_day_count)); + const selected = candidate.candidate_id === props.selectedCandidateId; + return ( +
props.onSelectCandidate(candidate.candidate_id)} + onKeyDown={(event) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + props.onSelectCandidate(candidate.candidate_id); + } + }} + > +
+
+ {copy.trendCode} · {String(index + 1).padStart(2, "0")} + {verdict.label} +
+

{candidate.display_name}

+

{candidateAppearanceReasonForView(candidate, copy)}

+
+
+ {copy.trendAttention} {candidate.external_attention_label} + {copy.trendSources} {candidate.platforms.length || candidate.source_count} + {copy.trendActive} {activeDays} {copy.trendDays} +
+
+ {platforms} + {candidateWhyWatchReasonsForView(candidate, copy)[0] ?? copy.defaultReason} +
+
+ ); + })} +
+ ) : ( +
+

{copy.trendNoItems}

+
+ )} +
+ ); +} + +function PlatformStatusPanel(props: { + coverage: AgentReachCoverageViewModel[]; + directionSnapshot: AgentReachViewProps["direction_snapshot"]; + copy: AgentReachUiCopy; +}) { + const copy = props.copy; + return ( + + ); +} + +function CoverageStrip(props: { + candidates: AgentReachCandidateCardViewModel[]; + coverage: AgentReachCoverageViewModel[]; + directionSnapshot: AgentReachViewProps["direction_snapshot"]; + selectedCandidateId: string | null; + onSelectCandidate: (candidateId: string) => void; + copy: AgentReachUiCopy; +}) { + const copy = props.copy; + return ( +
+ + +
+ ); +} + +export default function AgentReachView(inputProps: AgentReachViewProps) { + const props = { ...DEFAULT_PROPS, ...inputProps }; + const copy = agentReachCopy(props.lang); + const [activeType, setActiveType] = useState(() => defaultActiveType(props)); + const [sortMode, setSortMode] = useState("attention"); + const [page, setPage] = useState(1); + const [selectedId, setSelectedId] = useState(props.initialSelectedId ?? props.candidates[0]?.candidate_id ?? null); + const workbenchRef = useRef(null); + + const filteredCandidates = useMemo(() => { + return sortCandidates( + props.candidates.filter((candidate) => candidateMatchesType(candidate, activeType)), + sortMode, + ); + }, [activeType, props.candidates, sortMode]); + + const paged = useMemo(() => paginate(filteredCandidates, page), [filteredCandidates, page]); + const visibleIds = useMemo(() => new Set(paged.items.map((candidate) => candidate.candidate_id)), [paged.items]); + + useEffect(() => { + setPage(1); + }, [activeType, sortMode]); + + useEffect(() => { + if (page !== paged.page) { + setPage(paged.page); + } + }, [page, paged.page]); + + useEffect(() => { + if (selectedId && visibleIds.has(selectedId)) return; + setSelectedId(paged.items[0]?.candidate_id ?? null); + }, [paged.items, selectedId, visibleIds]); + + const selectedCandidate = paged.items.find((candidate) => candidate.candidate_id === selectedId) ?? paged.items[0] ?? null; + const statusFailed = props.status.provider_status === "failed"; + + function handlePageChange(nextPage: number) { + const nextPaged = paginate(filteredCandidates, nextPage); + setPage(nextPaged.page); + setSelectedId(nextPaged.items[0]?.candidate_id ?? null); + } + + function handleTrendCandidateSelect(candidateId: string) { + const allCandidates = sortCandidates(props.candidates, sortMode); + const candidateIndex = allCandidates.findIndex((candidate) => candidate.candidate_id === candidateId); + setActiveType("all"); + if (candidateIndex >= 0) { + setPage(Math.floor(candidateIndex / PAGE_SIZE) + 1); + } + setSelectedId(candidateId); + window.setTimeout(() => { + workbenchRef.current?.scrollIntoView({ behavior: "smooth", block: "start" }); + }, 0); + } + + return ( +
+
+
+ {copy.heroPill} +

{copy.heroTitle}

+

{copy.heroBody}

+
+ {props.status.label} + {copy.secondaryWarning} + {copy.dataSource} {props.status.data_source} +
+
+ +
+
+ {copy.filters} +
+ + {props.filters.type_chips.map((chip) => ( + + ))} +
+
+
+ {copy.sort} +
+ {props.filters.sort_options.map((chip) => ( + + ))} +
+
+
+ +
+ {props.summary_cards.map((card) => )} +
+ +
+
+
+
+ {copy.candidateList} +

{copy.candidateTitle}

+
+ {filteredCandidates.length} +
+ + {statusFailed ? ( +
+

{copy.listFailed}

+

{statusReasonForView(props.status.reason, copy)}

+
+ ) : paged.items.length > 0 ? ( +
+ {paged.items.map((candidate) => ( + + ))} +
+ ) : ( +
+

{copy.noCandidates}

+

{copy.noCandidatesBody}

+
+ )} + +
+ {filteredCandidates.length > 0 ? `${paged.start + 1}-${paged.end} / ${filteredCandidates.length}` : "0 / 0"} +
+ + {paged.page} / {paged.pageCount} + +
+
+
+ + +
+ + +
+
+ ); +} diff --git a/app/client/mount.tsx b/app/client/mount.tsx index 6b28bf4..84ad786 100644 --- a/app/client/mount.tsx +++ b/app/client/mount.tsx @@ -1,6 +1,7 @@ -import React from "react"; -import { createRoot, hydrateRoot, type Root } from "react-dom/client"; -import App, { parseOverviewAppPayload } from "./App.tsx"; +import React from "react"; +import { createRoot, hydrateRoot, type Root } from "react-dom/client"; +import AgentReachView, { parseAgentReachViewPayload } from "./AgentReachView.tsx"; +import App, { parseOverviewAppPayload } from "./App.tsx"; import ObserverView, { parseObserverViewPayload } from "./ObserverView.tsx"; import RunHealthView, { parseRunHealthViewPayload } from "./RunHealth.tsx"; import WeeklyView, { parseWeeklyViewPayload } from "./WeeklyView.tsx"; @@ -18,11 +19,12 @@ type ClientPayloadConfig = { declare global { interface Window { - __mountVisualConsoleApps?: () => void; - __visualConsoleMountedRoots?: { - overview?: MountedRoot; - observer?: MountedRoot; - runHealth?: MountedRoot; + __mountVisualConsoleApps?: () => void; + __visualConsoleMountedRoots?: { + overview?: MountedRoot; + agentreach?: MountedRoot; + observer?: MountedRoot; + runHealth?: MountedRoot; weekly?: MountedRoot; }; } @@ -60,8 +62,8 @@ async function ensureClientPayload(container: Element, payloadConfig: ClientPayl } } -function mountIntoRoot( - key: "overview" | "observer" | "runHealth" | "weekly", +function mountIntoRoot( + key: "overview" | "agentreach" | "observer" | "runHealth" | "weekly", containerId: string, elementFactory: () => React.ReactElement, payloadConfig?: ClientPayloadConfig, @@ -109,9 +111,13 @@ function mountIntoRoot( } } -export function mountVisualConsoleApps(): void { - mountIntoRoot("overview", "overview-react-root", () => ); - mountIntoRoot("observer", "observer-react-root", () => , { +export function mountVisualConsoleApps(): void { + mountIntoRoot("overview", "overview-react-root", () => ); + mountIntoRoot("agentreach", "agentreach-react-root", () => , { + scriptId: "agentreach-react-payload", + windowKey: "__AGENTREACH_INITIAL_DATA__", + }); + mountIntoRoot("observer", "observer-react-root", () => , { scriptId: "observer-react-payload", windowKey: "__OBSERVER_INITIAL_DATA__", }); diff --git a/app/server.ts b/app/server.ts index 6f531a7..4dce7d4 100644 --- a/app/server.ts +++ b/app/server.ts @@ -12,8 +12,9 @@ import { FUZZY_SEARCH_RESPONSE_SCHEMA_VERSION } from "../src/search/fuzzyQueryTy import { buildProjectsView } from "../src/visualConsole/build.ts"; import { renderDocument } from "./visualConsole/ossDocument.ts"; import { buildProjectsLocalDetailPayload } from "./visualConsole/ossProjectsPage.ts"; -import { - buildObserverReactProps, +import { + buildAgentReachReactProps, + buildObserverReactProps, buildOverviewReactProps, buildRunHealthReactProps, buildWeeklyReactProps, @@ -66,9 +67,9 @@ function serializeJsonForHtml(value: unknown): string { .replace(/\u2029/g, "\\u2029"); } -function routeRequiresClientReactRuntime(route: RenderedRoute["route"]): boolean { - return route === "overview" || route === "observer" || route === "weekly" || route === "run-health"; -} +function routeRequiresClientReactRuntime(route: RenderedRoute["route"]): boolean { + return route === "overview" || route === "observer" || route === "agentreach" || route === "weekly" || route === "run-health"; +} function renderReactImportMap(): string { return [ @@ -198,11 +199,15 @@ function resolveHydrationPayloadScripts(rendered: RenderedRoute, requestUrl: URL id: "run-health-react-payload", value: rendered.route === "run-health" ? buildRunHealthReactProps(rendered.model, lang) : null, }, - { - id: "observer-react-payload", - value: rendered.route === "observer" ? buildObserverReactProps(rendered.model, requestUrl, lang, theme) : null, - }, - ]; + { + id: "observer-react-payload", + value: rendered.route === "observer" ? buildObserverReactProps(rendered.model, requestUrl, lang, theme) : null, + }, + { + id: "agentreach-react-payload", + value: rendered.route === "agentreach" ? buildAgentReachReactProps(rendered.model, requestUrl, lang, theme) : null, + }, + ]; return payloads .filter((entry) => entry.value !== null) diff --git a/app/styles.css b/app/styles.css index f6d77bb..e55e5fe 100644 --- a/app/styles.css +++ b/app/styles.css @@ -4807,7 +4807,7 @@ body.theme-dark[data-route="kb"]::after { justify-content: space-between; gap: 28px; position: relative; - width: min(100%, 1040px); + width: min(100%, 1240px); max-width: calc(100vw - 32px); min-height: 58px; margin: 0 auto; @@ -4868,7 +4868,7 @@ body.theme-dark[data-route="kb"]::after { .homepage-nav-main { display: flex; align-items: center; - gap: 26px; + gap: 18px; flex: 1 1 auto; min-width: 0; } @@ -4943,9 +4943,16 @@ body.theme-dark[data-route="kb"]::after { align-items: center; justify-content: flex-start; gap: 6px; - flex: 0 1 auto; + flex: 1 1 auto; min-width: 0; align-self: center; + overflow-x: auto; + overflow-y: hidden; + scrollbar-width: none; +} + +.homepage-primary-nav::-webkit-scrollbar { + display: none; } .homepage-nav-tools { @@ -5000,6 +5007,7 @@ body.theme-dark[data-route="kb"]::after { line-height: 1; color: var(--text-muted); transform: none; + white-space: nowrap; transition: color 0.3s ease, font-weight 0.2s ease, background-color 0.25s ease, border-color 0.25s ease, box-shadow 0.25s ease; } @@ -12481,3 +12489,1198 @@ body.theme-dark .projects-ai-message { .projects-ai-submit { font-size: 14px; } + +body[data-route="agentreach"] { + background: #f8fafc; + overflow-x: clip; +} + +body[data-route="agentreach"] .atmosphere-layer, +body[data-route="agentreach"] .atmosphere-layer-grid { + display: none; +} + +body[data-route="agentreach"] .app-shell.app-shell-console { + max-width: min(1680px, calc(100vw - 24px)); + padding: 6px 10px 28px; + gap: 14px; +} + +.agentreach-view { + color: #172033; +} + +.agentreach-shell { + width: min(100%, 1280px); + margin: 0 auto; + display: grid; + gap: 18px; +} + +.agentreach-filter-panel, +.agentreach-summary-card, +.agentreach-list-panel, +.agentreach-detail-card, +.agentreach-footer-status, +.agentreach-trend-panel, +.agentreach-platform-panel { + background: rgba(255, 255, 255, 0.86); + border: 1px solid rgba(123, 97, 255, 0.14); + box-shadow: 0 18px 48px rgba(38, 54, 96, 0.06); + border-radius: 22px; +} + +.agentreach-hero { + width: min(100%, 1024px); + margin: 0 auto; + padding: 36px 24px 18px; + overflow: hidden; + text-align: center; +} + +.agentreach-pill, +.agentreach-section-kicker { + display: inline-flex; + align-items: center; + min-height: 28px; + border-radius: 999px; + padding: 0 12px; + background: rgba(110, 84, 255, 0.1); + color: #5b42cf; + font-size: 12px; + font-weight: 800; +} + +.agentreach-hero h1 { + margin: 14px 0 10px; + color: #050814; + font-size: clamp(30px, 4.2vw, 56px); + line-height: 0.94; + font-weight: 900; + letter-spacing: 0; +} + +.agentreach-hero p { + margin: 0 auto; + max-width: 896px; + color: #556070; + font-size: 15px; + line-height: 1.7; +} + +.agentreach-hero-foot, +.agentreach-card-tags, +.agentreach-chip-scroll { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + +.agentreach-hero-foot { + justify-content: center; + margin: 14px auto 0; + max-width: 860px; +} + +.agentreach-chip, +.agentreach-filter-chip { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 28px; + border-radius: 999px; + padding: 0 11px; + font-size: 12px; + font-weight: 800; + line-height: 1; + white-space: nowrap; + border: 1px solid transparent; +} + +.agentreach-tone-neutral { + background: rgba(99, 111, 135, 0.08); + color: #526070; + border-color: rgba(99, 111, 135, 0.12); +} + +.agentreach-tone-accent { + background: rgba(112, 84, 255, 0.12); + color: #5b42cf; + border-color: rgba(112, 84, 255, 0.2); +} + +.agentreach-tone-good { + background: rgba(16, 185, 129, 0.12); + color: #047857; + border-color: rgba(16, 185, 129, 0.2); +} + +.agentreach-tone-warn { + background: rgba(245, 158, 11, 0.14); + color: #a16207; + border-color: rgba(245, 158, 11, 0.22); +} + +.agentreach-tone-bad { + background: rgba(239, 68, 68, 0.12); + color: #b91c1c; + border-color: rgba(239, 68, 68, 0.22); +} + +.agentreach-filter-panel { + padding: 14px 16px; + display: grid; + grid-template-columns: minmax(0, 1fr) max-content; + align-items: center; + gap: 14px 22px; +} + +.agentreach-filter-row { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + align-items: center; + gap: 12px; + min-width: 0; +} + +.agentreach-filter-row-main { + justify-self: stretch; +} + +.agentreach-filter-row-sort { + grid-template-columns: auto max-content; + justify-self: end; +} + +.agentreach-filter-row-sort .agentreach-chip-scroll { + justify-content: flex-end; +} + +.agentreach-filter-row > span { + color: #758197; + font-size: 12px; + font-weight: 800; + white-space: nowrap; +} + +.agentreach-chip-scroll { + min-width: 0; + overflow-x: auto; + flex-wrap: nowrap; + padding-bottom: 2px; + scrollbar-width: thin; +} + +.agentreach-filter-chip { + cursor: pointer; + background: rgba(255, 255, 255, 0.9); + color: #536071; + border-color: rgba(123, 97, 255, 0.16); + box-shadow: 0 8px 18px rgba(31, 41, 55, 0.04); +} + +.agentreach-filter-chip.is-active { + color: #fff; + background: #6d5df6; + border-color: #6d5df6; +} + +.agentreach-summary-grid { + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + gap: 12px; +} + +.agentreach-summary-card { + position: relative; + overflow: hidden; + padding: 16px 16px 15px; + min-width: 0; + min-height: 96px; + background: rgba(255, 255, 255, 0.88); +} + +.agentreach-summary-card.agentreach-tone-neutral, +.agentreach-summary-card.agentreach-tone-accent, +.agentreach-summary-card.agentreach-tone-good, +.agentreach-summary-card.agentreach-tone-warn, +.agentreach-summary-card.agentreach-tone-bad { + background: rgba(255, 255, 255, 0.88); +} + +.agentreach-summary-card::before { + content: ""; + position: absolute; + inset: 0 auto 0 0; + width: 3px; + background: rgba(109, 93, 246, 0.55); +} + +.agentreach-summary-card.agentreach-tone-good::before { + background: rgba(16, 185, 129, 0.55); +} + +.agentreach-summary-card.agentreach-tone-warn::before { + background: rgba(245, 158, 11, 0.6); +} + +.agentreach-summary-card.agentreach-tone-bad::before { + background: rgba(239, 68, 68, 0.55); +} + +.agentreach-summary-card span { + color: #6b7280; + font-size: 12px; + font-weight: 800; +} + +.agentreach-summary-card strong { + display: block; + margin: 8px 0 6px; + color: #152033; + font-size: 28px; + line-height: 1; +} + +.agentreach-summary-card p { + margin: 0; + color: #647084; + font-size: 12px; + line-height: 1.5; +} + +.agentreach-workbench { + display: grid; + grid-template-columns: minmax(420px, 0.48fr) minmax(0, 0.52fr); + gap: 18px; + align-items: stretch; +} + +.agentreach-workbench[data-empty="true"] { + align-items: start; +} + +.agentreach-list-panel, +.agentreach-detail-card { + min-width: 0; + padding: 18px; +} + +.agentreach-list-panel { + display: flex; + flex-direction: column; + min-height: 460px; +} + +.agentreach-workbench[data-empty="true"] .agentreach-list-panel, +.agentreach-workbench[data-empty="true"] .agentreach-detail-card { + min-height: 340px; +} + +.agentreach-panel-head, +.agentreach-section-head, +.agentreach-detail-head, +.agentreach-evidence-head, +.agentreach-pagination, +.agentreach-detail-actions { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.agentreach-panel-head h2, +.agentreach-footer-status h2 { + margin: 8px 0 0; + font-size: 20px; + font-weight: 900; +} + +.agentreach-panel-head > span, +.agentreach-section-head > span { + color: #6d5df6; + font-weight: 900; +} + +.agentreach-candidate-list { + display: grid; + gap: 12px; + margin-top: 16px; + max-height: 640px; + overflow-y: auto; + padding-right: 6px; +} + +.agentreach-candidate-card { + width: 100%; + min-width: 0; + text-align: left; + border: 1px solid rgba(123, 97, 255, 0.12); + background: rgba(255, 255, 255, 0.92); + border-radius: 18px; + padding: 17px; + cursor: pointer; + transition: border-color 0.18s ease, box-shadow 0.18s ease, transform 0.18s ease; +} + +.agentreach-candidate-card:hover, +.agentreach-candidate-card.is-selected { + border-color: rgba(109, 93, 246, 0.5); + box-shadow: 0 18px 36px rgba(64, 58, 132, 0.12); + transform: translateY(-1px); +} + +.agentreach-candidate-card.is-selected { + background: linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(249, 248, 255, 0.96)); +} + +.agentreach-card-head { + display: flex; + justify-content: space-between; + gap: 8px; + align-items: center; +} + +.agentreach-card-score { + color: #6d5df6; + font-size: 12px; + font-weight: 900; +} + +.agentreach-candidate-card h3 { + margin: 12px 0 8px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: #172033; + font-size: 18px; + font-weight: 900; +} + +.agentreach-candidate-card p, +.agentreach-detail-section p, +.agentreach-source-card p, +.agentreach-empty-copy, +.agentreach-list-empty p, +.agentreach-empty-detail p { + margin: 0; + color: #59677a; + font-size: 13px; + line-height: 1.65; +} + +.agentreach-card-meta { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 6px; + margin: 16px 0 12px; +} + +.agentreach-card-meta span { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: #667085; + background: rgba(255, 255, 255, 0.72); + border-radius: 10px; + padding: 7px 8px; + font-size: 11px; + font-weight: 800; +} + +.agentreach-pagination { + margin-top: auto; + padding-top: 16px; + color: #6b7280; + font-size: 12px; +} + +.agentreach-pagination > div { + display: inline-flex; + align-items: center; + gap: 8px; +} + +.agentreach-pagination button, +.agentreach-primary-button, +.agentreach-secondary-button, +.agentreach-link-button { + min-height: 34px; + border-radius: 12px; + border: 1px solid rgba(109, 93, 246, 0.2); + padding: 0 12px; + font-size: 12px; + font-weight: 900; + text-decoration: none; +} + +.agentreach-pagination button { + background: #fff; + color: #5b42cf; +} + +.agentreach-pagination button:disabled, +.agentreach-secondary-button.is-disabled { + opacity: 0.45; + cursor: not-allowed; +} + +.agentreach-detail-card { + display: flex; + flex-direction: column; + gap: 14px; + min-height: 460px; + max-height: min(860px, calc(100vh - 92px)); +} + +.agentreach-detail-head { + align-items: flex-start; + border-bottom: 1px solid rgba(123, 97, 255, 0.1); + padding-bottom: 12px; +} + +.agentreach-detail-head h2 { + margin: 8px 0 6px; + font-size: 24px; + line-height: 1.15; + font-weight: 900; + overflow-wrap: anywhere; +} + +.agentreach-detail-head p { + margin: 0; + color: #667085; + font-size: 12px; + line-height: 1.55; + overflow-wrap: anywhere; +} + +.agentreach-en-object-line { + display: none; +} + +.agentreach-view[data-lang="en"] .agentreach-en-object-line { + display: block; +} + +.agentreach-view[data-lang="en"] .agentreach-source-object-line { + display: none; +} + +.agentreach-detail-scroll { + min-height: 0; + overflow-y: auto; + display: grid; + gap: 12px; + padding-right: 6px; +} + +.agentreach-metric-grid, +.agentreach-mini-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 8px; +} + +.agentreach-mini-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.agentreach-metric-cell { + min-width: 0; + border: 1px solid rgba(123, 97, 255, 0.1); + background: rgba(248, 250, 255, 0.86); + border-radius: 14px; + padding: 10px; +} + +.agentreach-metric-cell span { + display: block; + color: #7a8497; + font-size: 11px; + font-weight: 800; +} + +.agentreach-metric-cell strong { + display: block; + margin-top: 5px; + color: #172033; + font-size: 15px; + font-weight: 900; + overflow-wrap: anywhere; +} + +.agentreach-detail-section, +.agentreach-evidence-card, +.agentreach-source-card { + border: 1px solid rgba(123, 97, 255, 0.1); + background: rgba(250, 252, 255, 0.9); + border-radius: 18px; + padding: 14px; + min-width: 0; +} + +.agentreach-section-head { + margin-bottom: 10px; +} + +.agentreach-section-head h3 { + margin: 0; + color: #172033; + font-size: 14px; + font-weight: 900; +} + +.agentreach-binding-card { + padding: 16px 18px; +} + +.agentreach-binding-card .agentreach-section-head { + align-items: flex-start; + gap: 12px; + margin-bottom: 12px; +} + +.agentreach-binding-card .agentreach-section-head span { + flex: 0 0 auto; + max-width: 42%; + text-align: right; + line-height: 1.25; +} + +.agentreach-binding-card p { + margin: 0; + color: #667085; + line-height: 1.75; +} + +.agentreach-object-link { + display: block; + margin: 12px 0 0; + padding: 8px 10px; + border: 1px solid rgba(123, 97, 255, 0.12); + border-radius: 10px; + background: rgba(255, 255, 255, 0.72); + color: #5b42cf; + font-size: 12px; + font-weight: 800; + line-height: 1.45; + overflow-wrap: anywhere; + word-break: break-all; +} + +.agentreach-binding-card .agentreach-card-tags { + gap: 8px; + margin-top: 12px; +} + +.agentreach-profile-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 10px; +} + +.agentreach-profile-grid > div > span { + display: block; + margin-bottom: 8px; + color: #758197; + font-size: 12px; + font-weight: 800; +} + +.agentreach-actor-list { + display: grid; + gap: 8px; +} + +.agentreach-actor-row { + display: grid; + grid-template-columns: minmax(120px, 1.2fr) minmax(96px, 0.8fr) minmax(96px, 1fr) auto; + gap: 10px; + align-items: center; + min-width: 0; + padding: 10px 12px; + border: 1px solid rgba(123, 97, 255, 0.1); + border-radius: 12px; + background: rgba(255, 255, 255, 0.72); + color: #59677a; + font-size: 12px; + font-weight: 800; +} + +.agentreach-actor-row strong { + min-width: 0; + color: #172033; + font-size: 13px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.agentreach-actor-row span { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.agentreach-discussion-note { + padding: 10px 12px; + border: 1px solid rgba(123, 97, 255, 0.1); + border-radius: 12px; + background: rgba(255, 255, 255, 0.7); +} + +.agentreach-evidence-stack, +.agentreach-source-list { + display: grid; + gap: 10px; +} + +.agentreach-evidence-card p + p, +.agentreach-detail-section p + p { + margin-top: 8px; +} + +.agentreach-caveat { + color: #a16207 !important; +} + +.agentreach-confirmation-gaps { + margin-top: 10px; + display: grid; + gap: 6px; +} + +.agentreach-confirmation-gaps > span { + color: #7a8497; + font-size: 12px; + font-weight: 900; +} + +.agentreach-source-card h4 { + margin: 10px 0 6px; + color: #172033; + font-size: 14px; + font-weight: 900; + overflow-wrap: anywhere; +} + +.agentreach-source-meta { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 6px; + margin: 10px 0; +} + +.agentreach-source-meta span { + min-width: 0; + color: #647084; + background: rgba(255, 255, 255, 0.78); + border-radius: 10px; + padding: 7px; + font-size: 11px; + overflow-wrap: anywhere; +} + +.agentreach-detail-actions { + display: grid; + grid-template-columns: minmax(120px, 1fr) auto minmax(180px, 1fr); + align-items: center; + gap: 12px; + border-top: 1px solid rgba(123, 97, 255, 0.1); + padding-top: 12px; +} + +.agentreach-detail-actions > .agentreach-secondary-button:first-child { + justify-self: start; +} + +.agentreach-detail-actions > .agentreach-primary-button { + justify-self: center; +} + +.agentreach-action-note { + justify-self: end; + min-width: 220px; + max-width: 340px; + color: #7a8497; + font-size: 12px; + font-weight: 800; + line-height: 1.5; + text-align: right; +} + +.agentreach-primary-button { + background: #6d5df6; + color: #fff; +} + +.agentreach-secondary-button, +.agentreach-link-button { + background: #fff; + color: #5b42cf; +} + +.agentreach-list-empty, +.agentreach-empty-detail { + margin-top: 14px; + border: 1px dashed rgba(123, 97, 255, 0.22); + border-radius: 18px; + padding: 28px 24px; + text-align: center; + background: rgba(248, 250, 255, 0.72); +} + +.agentreach-list-empty { + display: grid; + place-content: center; + min-height: 220px; +} + +.agentreach-empty-detail { + display: grid; + place-content: center; + flex: 1; + min-height: 260px; +} + +.agentreach-workbench[data-empty="true"] .agentreach-list-empty, +.agentreach-workbench[data-empty="true"] .agentreach-empty-detail { + min-height: 190px; + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.68), rgba(248, 250, 255, 0.8)), + repeating-linear-gradient(135deg, rgba(109, 93, 246, 0.035) 0 1px, transparent 1px 12px); +} + +.agentreach-workbench[data-empty="true"] .agentreach-detail-card { + max-height: none; +} + +.agentreach-list-empty h3, +.agentreach-empty-detail h2 { + margin: 10px 0 8px; + font-weight: 900; +} + +.agentreach-footer-status { + display: grid; + grid-template-columns: minmax(0, 1.45fr) minmax(320px, 0.55fr); + gap: 14px; + align-items: start; + background: transparent; + border: 0; + box-shadow: none; + border-radius: 0; +} + +.agentreach-trend-panel, +.agentreach-platform-panel { + min-width: 0; + padding: 18px; +} + +.agentreach-trend-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + margin-bottom: 12px; +} + +.agentreach-trend-head h2, +.agentreach-platform-panel h2 { + margin: 8px 0 4px; + color: #172033; + font-size: 20px; + font-weight: 900; + letter-spacing: 0; +} + +.agentreach-trend-head p, +.agentreach-platform-panel > p { + margin: 0; + color: #647084; + font-size: 12px; + line-height: 1.55; +} + +.agentreach-trend-tabs { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 4px; + border-radius: 999px; + background: rgba(112, 84, 255, 0.08); + border: 1px solid rgba(112, 84, 255, 0.12); + white-space: nowrap; +} + +.agentreach-trend-tabs button { + min-height: 28px; + border: 0; + border-radius: 999px; + padding: 0 10px; + background: transparent; + color: #5f687a; + font-size: 11px; + font-weight: 900; + cursor: pointer; +} + +.agentreach-trend-tabs button.is-active { + background: #6d5df6; + color: #fff; + box-shadow: 0 10px 22px rgba(109, 93, 246, 0.22); +} + +.agentreach-trend-list { + display: grid; + gap: 9px; +} + +.agentreach-trend-row { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(170px, 0.34fr); + gap: 9px 12px; + padding: 12px 14px; + border: 1px solid rgba(123, 97, 255, 0.13); + border-radius: 16px; + background: linear-gradient(180deg, rgba(255, 255, 255, 0.96), rgba(250, 251, 255, 0.92)); + cursor: pointer; + transition: border-color 0.18s ease, box-shadow 0.18s ease, transform 0.18s ease; +} + +.agentreach-trend-row:hover, +.agentreach-trend-row:focus-visible, +.agentreach-trend-row.is-selected { + border-color: rgba(109, 93, 246, 0.46); + box-shadow: 0 14px 28px rgba(64, 58, 132, 0.1); + outline: 0; +} + +.agentreach-trend-row:hover, +.agentreach-trend-row.is-selected { + transform: translateY(-1px); +} + +.agentreach-trend-row-top, +.agentreach-trend-foot, +.agentreach-platform-counts { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + +.agentreach-trend-row-top { + margin-bottom: 6px; +} + +.agentreach-trend-code { + display: inline-flex; + min-height: 24px; + align-items: center; + border-radius: 999px; + padding: 0 9px; + background: #6d5df6; + color: #fff; + font-size: 10px; + font-weight: 900; +} + +.agentreach-trend-row h3 { + margin: 0 0 5px; + color: #172033; + font-size: 16px; + font-weight: 900; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.agentreach-trend-row p, +.agentreach-trend-empty p, +.agentreach-platform-note p { + margin: 0; + color: #667085; + font-size: 12px; + line-height: 1.55; +} + +.agentreach-trend-metrics { + display: grid; + gap: 6px; + align-content: center; +} + +.agentreach-trend-metrics span, +.agentreach-platform-counts span { + display: flex; + justify-content: space-between; + gap: 6px; + min-width: 0; + border-radius: 10px; + padding: 7px 9px; + background: rgba(255, 255, 255, 0.72); + color: #667085; + font-size: 11px; + font-weight: 800; +} + +.agentreach-trend-metrics strong, +.agentreach-platform-counts strong { + color: #172033; + font-weight: 900; +} + +.agentreach-trend-foot { + grid-column: 1 / -1; + color: #7a8495; + font-size: 11px; + font-weight: 800; +} + +.agentreach-trend-foot span { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.agentreach-trend-foot span:last-child { + color: #9a6b12; +} + +.agentreach-trend-empty { + border: 1px dashed rgba(123, 97, 255, 0.22); + border-radius: 18px; + padding: 22px; + background: rgba(248, 250, 252, 0.72); +} + +.agentreach-platform-grid { + display: flex; + gap: 7px; + flex-wrap: wrap; + margin: 14px 0 12px; +} + +.agentreach-platform-counts { + display: grid; + grid-template-columns: 1fr; + margin-bottom: 12px; +} + +.agentreach-platform-note { + display: grid; + grid-template-columns: 28px minmax(0, 1fr); + gap: 10px; + align-items: start; + margin-top: auto; + padding: 12px; + border-radius: 14px; + border: 1px solid rgba(59, 130, 246, 0.14); + background: rgba(239, 246, 255, 0.76); +} + +.agentreach-platform-note > span { + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + border-radius: 999px; + color: #4f46e5; + background: rgba(99, 102, 241, 0.12); + font-size: 14px; + font-weight: 900; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; +} + +.agentreach-warning-list { + margin-top: 10px; + display: grid; + gap: 6px; +} + +.agentreach-warning-list p { + margin: 0; + color: #a16207; + font-size: 12px; +} + +body.theme-dark[data-route="agentreach"] { + background: + radial-gradient(circle at 12% 0%, rgba(124, 92, 255, 0.12), transparent 32%), + radial-gradient(circle at 88% 8%, rgba(14, 165, 233, 0.08), transparent 34%), + linear-gradient(180deg, #07111f 0%, #0d1524 52%, #0b1020 100%); +} + +body.theme-dark[data-route="agentreach"] .agentreach-view { + color: #eef2ff; +} + +body.theme-dark[data-route="agentreach"] .agentreach-filter-panel, +body.theme-dark[data-route="agentreach"] .agentreach-summary-card, +body.theme-dark[data-route="agentreach"] .agentreach-list-panel, +body.theme-dark[data-route="agentreach"] .agentreach-detail-card, +body.theme-dark[data-route="agentreach"] .agentreach-footer-status, +body.theme-dark[data-route="agentreach"] .agentreach-trend-panel, +body.theme-dark[data-route="agentreach"] .agentreach-platform-panel, +body.theme-dark[data-route="agentreach"] .agentreach-candidate-card, +body.theme-dark[data-route="agentreach"] .agentreach-detail-section, +body.theme-dark[data-route="agentreach"] .agentreach-evidence-card, +body.theme-dark[data-route="agentreach"] .agentreach-source-card, +body.theme-dark[data-route="agentreach"] .agentreach-metric-cell, +body.theme-dark[data-route="agentreach"] .agentreach-actor-row, +body.theme-dark[data-route="agentreach"] .agentreach-discussion-note, +body.theme-dark[data-route="agentreach"] .agentreach-trend-row, +body.theme-dark[data-route="agentreach"] .agentreach-trend-empty, +body.theme-dark[data-route="agentreach"] .agentreach-platform-note { + background: rgba(12, 20, 36, 0.82); + border-color: rgba(148, 163, 184, 0.14); +} + +body.theme-dark[data-route="agentreach"] .agentreach-hero p, +body.theme-dark[data-route="agentreach"] .agentreach-candidate-card p, +body.theme-dark[data-route="agentreach"] .agentreach-detail-section p, +body.theme-dark[data-route="agentreach"] .agentreach-source-card p, +body.theme-dark[data-route="agentreach"] .agentreach-detail-head p, +body.theme-dark[data-route="agentreach"] .agentreach-trend-head p, +body.theme-dark[data-route="agentreach"] .agentreach-platform-panel > p, +body.theme-dark[data-route="agentreach"] .agentreach-trend-row p, +body.theme-dark[data-route="agentreach"] .agentreach-platform-note p { + color: #aab6ca; +} + +body.theme-dark[data-route="agentreach"] .agentreach-footer-status { + background: transparent; + border: 0; + box-shadow: none; +} + +body.theme-dark[data-route="agentreach"] .agentreach-summary-card strong, +body.theme-dark[data-route="agentreach"] .agentreach-candidate-card h3, +body.theme-dark[data-route="agentreach"] .agentreach-detail-head h2, +body.theme-dark[data-route="agentreach"] .agentreach-section-head h3, +body.theme-dark[data-route="agentreach"] .agentreach-source-card h4, +body.theme-dark[data-route="agentreach"] .agentreach-metric-cell strong, +body.theme-dark[data-route="agentreach"] .agentreach-actor-row strong, +body.theme-dark[data-route="agentreach"] .agentreach-trend-head h2, +body.theme-dark[data-route="agentreach"] .agentreach-platform-panel h2, +body.theme-dark[data-route="agentreach"] .agentreach-trend-row h3, +body.theme-dark[data-route="agentreach"] .agentreach-trend-metrics strong, +body.theme-dark[data-route="agentreach"] .agentreach-platform-counts strong { + color: #f8fafc; +} + +@media (max-width: 1120px) { + .agentreach-summary-grid { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } + + .agentreach-filter-panel { + grid-template-columns: 1fr; + } + + .agentreach-workbench { + grid-template-columns: minmax(0, 1fr); + } + + .agentreach-footer-status { + grid-template-columns: minmax(0, 1fr); + } + + .agentreach-detail-card { + position: relative; + top: auto; + max-height: none; + } + + .agentreach-detail-scroll { + max-height: none; + overflow: visible; + } +} + +@media (max-width: 760px) { + body[data-route="agentreach"] .app-shell.app-shell-console { + max-width: 100%; + padding: 8px 8px 20px; + } + + .agentreach-shell { + gap: 10px; + } + + .agentreach-hero, + .agentreach-filter-panel, + .agentreach-list-panel, + .agentreach-detail-card, + .agentreach-footer-status, + .agentreach-trend-panel, + .agentreach-platform-panel { + border-radius: 18px; + padding: 14px; + } + + .agentreach-hero h1 { + font-size: 44px; + } + + .agentreach-filter-row { + grid-template-columns: 1fr; + } + + .agentreach-filter-row-sort { + justify-self: stretch; + } + + .agentreach-filter-row-sort .agentreach-chip-scroll { + justify-content: flex-start; + } + + .agentreach-trend-head { + display: grid; + } + + .agentreach-trend-tabs { + justify-self: start; + max-width: 100%; + overflow-x: auto; + } + + .agentreach-summary-grid, + .agentreach-card-meta, + .agentreach-source-meta, + .agentreach-metric-grid, + .agentreach-mini-grid, + .agentreach-profile-grid, + .agentreach-trend-row { + grid-template-columns: 1fr; + } + + .agentreach-candidate-list { + max-height: none; + overflow: visible; + } + + .agentreach-panel-head, + .agentreach-detail-head, + .agentreach-detail-actions { + align-items: flex-start; + } + + .agentreach-detail-actions { + grid-template-columns: 1fr; + } + + .agentreach-detail-actions > .agentreach-primary-button, + .agentreach-action-note { + justify-self: start; + text-align: left; + } +} diff --git a/app/visualConsole/clientScript.ts b/app/visualConsole/clientScript.ts index dc3e9d8..8d2d748 100644 --- a/app/visualConsole/clientScript.ts +++ b/app/visualConsole/clientScript.ts @@ -524,8 +524,8 @@ export function renderClientScriptSource(): string { }; const scrollKey = "visual-console-scroll-target"; - const routes = new Set(["/overview", "/projects", "/weekly", "/run-health", "/observer", "/kb"]); - const reactRuntimeRoutes = new Set(["overview", "weekly", "run-health", "observer"]); + const routes = new Set(["/overview", "/projects", "/weekly", "/run-health", "/observer", "/agentreach", "/kb"]); + const reactRuntimeRoutes = new Set(["overview", "weekly", "run-health", "observer", "agentreach"]); const reactImportMapId = "visual-console-react-import-map"; const reactMountScriptId = "visual-console-react-mount-script"; const reactMountScriptSrc = "/app-client/mount.js"; @@ -555,11 +555,15 @@ export function renderClientScriptSource(): string { scriptId: "run-health-react-payload", windowKey: "__RUN_HEALTH_INITIAL_DATA__", }, - { - scriptId: "observer-react-payload", - windowKey: "__OBSERVER_INITIAL_DATA__", - }, - ]; + { + scriptId: "observer-react-payload", + windowKey: "__OBSERVER_INITIAL_DATA__", + }, + { + scriptId: "agentreach-react-payload", + windowKey: "__AGENTREACH_INITIAL_DATA__", + }, + ]; const routePrefetchCache = new Map(); const routePayloadCache = new Map(); const warmRoutePrefetchCache = diff --git a/app/visualConsole/copy.ts b/app/visualConsole/copy.ts index d6948e8..29160a9 100644 --- a/app/visualConsole/copy.ts +++ b/app/visualConsole/copy.ts @@ -10,8 +10,9 @@ const COPY = { navOverview: "首页", navProjects: "项目库", navWeekly: "本周趋势", - navRunHealth: "数据状态", - navKb: "资料卡", + navRunHealth: "数据状态", + navAgentReach: "外部发现", + navKb: "资料卡", langLabel: "语言", langZh: "中文", langEn: "English", @@ -132,8 +133,9 @@ const COPY = { navOverview: "Overview", navProjects: "Projects", navWeekly: "Weekly", - navRunHealth: "Run Health", - navKb: "Knowledge Base", + navRunHealth: "Run Health", + navAgentReach: "AgentReach", + navKb: "Knowledge Base", langLabel: "Language", langZh: "Chinese", langEn: "English", diff --git a/app/visualConsole/ossCopy.ts b/app/visualConsole/ossCopy.ts index 966d588..a5e09ee 100644 --- a/app/visualConsole/ossCopy.ts +++ b/app/visualConsole/ossCopy.ts @@ -7,9 +7,10 @@ const COPY = { navOverview: "首页", navProjects: "项目库", navWeekly: "本周趋势", - navRunHealth: "数据状态", - navObserver: "新兴潜力项目", - navKb: "资料卡", + navRunHealth: "数据状态", + navObserver: "新兴潜力项目", + navAgentReach: "外部发现", + navKb: "资料卡", badgeOverview: "今日优先", badgeOverviewHint: "先看最值得继续跟进的项目", trustSummary: "可信度摘要", @@ -91,9 +92,10 @@ const COPY = { navOverview: "Overview", navProjects: "Projects", navWeekly: "Weekly", - navRunHealth: "Run Health", - navObserver: "Observer", - navKb: "Knowledge Base", + navRunHealth: "Run Health", + navObserver: "Observer", + navAgentReach: "AgentReach", + navKb: "Knowledge Base", badgeOverview: "Today First", badgeOverviewHint: "Start with the most actionable projects", trustSummary: "Trust Summary", diff --git a/app/visualConsole/ossDocument.ts b/app/visualConsole/ossDocument.ts index ca4c1e1..08bb71f 100644 --- a/app/visualConsole/ossDocument.ts +++ b/app/visualConsole/ossDocument.ts @@ -143,9 +143,10 @@ function routeIntent(route: WebRoute, lang: UiLang): string { overview: ["先快速判断今天这批结果值不值得读。", "Decide quickly whether today's slice is worth reading."], projects: ["先扫一遍今天值得继续研究的项目。", "Scan which projects deserve deeper work today."], weekly: ["先抓住本周真正形成的趋势主线。", "Capture the real trend structure for this week."], - "run-health": ["先定位这轮产物哪里需要谨慎。", "Locate what in this run needs caution first."], - observer: ["先看哪些长尾候选值得继续观察。", "Check which observer candidates are worth continued follow-up."], - kb: ["先读摘要,再决定要不要深入。", "Read the summary first, then decide whether to dive deeper."], + "run-health": ["先定位这轮产物哪里需要谨慎。", "Locate what in this run needs caution first."], + observer: ["先看哪些长尾候选值得继续观察。", "Check which observer candidates are worth continued follow-up."], + agentreach: ["先看外部层发现了什么候选,再看证据来自哪里。", "Check external candidates first, then inspect their evidence sources."], + kb: ["先读摘要,再决定要不要深入。", "Read the summary first, then decide whether to dive deeper."], }; const [zh, en] = copyByRoute[route]; return lang === "zh" ? zh : en; @@ -159,10 +160,11 @@ function renderHeader(route: WebRoute, requestUrl: URL, rendered: RenderedRoute, const nav: Array<{ label: string; route: WebRoute; href: string }> = [ { label: ui.navOverview, route: "overview", href: toViewHref("overview", lang, theme, { date: dailyDate }) }, { label: ui.navProjects, route: "projects", href: toViewHref("projects", lang, theme, { date: dailyDate }) }, - { label: ui.navWeekly, route: "weekly", href: toViewHref("weekly", lang, theme, { anchor: weeklyAnchor, date: dailyDate }) }, - { label: ui.navRunHealth, route: "run-health", href: toViewHref("run-health", lang, theme, { date: dailyDate, source_view: route }) }, - { label: uiText(lang, "新兴潜力项目", "Observer"), route: "observer", href: toViewHref("observer", lang, theme, { date: dailyDate, source_view: route }) }, - ]; + { label: ui.navWeekly, route: "weekly", href: toViewHref("weekly", lang, theme, { anchor: weeklyAnchor, date: dailyDate }) }, + { label: ui.navRunHealth, route: "run-health", href: toViewHref("run-health", lang, theme, { date: dailyDate, source_view: route }) }, + { label: uiText(lang, "新兴潜力项目", "Observer"), route: "observer", href: toViewHref("observer", lang, theme, { date: dailyDate, source_view: route }) }, + { label: ui.navAgentReach, route: "agentreach", href: toViewHref("agentreach", lang, theme, { date: dailyDate, source_view: route }) }, + ]; return `
@@ -349,9 +351,10 @@ export function renderDocument( rendered.route === "overview" || rendered.route === "projects" || rendered.route === "weekly" || - rendered.route === "run-health" || - rendered.route === "observer" - ? "" + rendered.route === "run-health" || + rendered.route === "observer" || + rendered.route === "agentreach" + ? "" : renderContextBar(rendered, requestUrl, lang), bannerHtml: "", routeFrameHtml, diff --git a/app/visualConsole/ossPages.ts b/app/visualConsole/ossPages.ts index 65dbcf5..6993747 100644 --- a/app/visualConsole/ossPages.ts +++ b/app/visualConsole/ossPages.ts @@ -9,9 +9,10 @@ import type { ObserverPresetBucket, ObserverPresetFilter, } from "../../src/types.ts"; -import { buildProjectsView } from "../../src/visualConsole/build.ts"; -import type { KnowledgeBaseViewModel, ObserverViewModel, OverviewViewModel, ProjectsViewModel, RunHealthViewModel, WeeklyViewModel } from "../../src/visualConsole/types.ts"; -import App, { type AppProps as OverviewReactAppProps } from "../client/App.tsx"; +import { buildProjectsView } from "../../src/visualConsole/build.ts"; +import type { AgentReachViewModel, KnowledgeBaseViewModel, ObserverViewModel, OverviewViewModel, ProjectsViewModel, RunHealthViewModel, WeeklyViewModel } from "../../src/visualConsole/types.ts"; +import AgentReachView, { type AgentReachViewProps } from "../client/AgentReachView.tsx"; +import App, { type AppProps as OverviewReactAppProps } from "../client/App.tsx"; import ObserverView, { type ObserverViewProps } from "../client/ObserverView.tsx"; import RunHealthView, { type RunHealthViewProps } from "../client/RunHealth.tsx"; import WeeklyView, { buildWeeklyMatrixAxes, type WeeklyViewProps } from "../client/WeeklyView.tsx"; @@ -46,10 +47,12 @@ export function renderPrimary(rendered: RenderedRoute, requestUrl: URL, lang: Ui return renderWeeklyPage(rendered.model, requestUrl, lang, theme); case "run-health": return renderRunHealthPage(rendered.model, lang); - case "observer": - return renderObserverPage(rendered.model, requestUrl, lang, theme); - case "kb": - return renderKnowledgeBasePage(rendered.model, requestUrl, lang, theme); + case "observer": + return renderObserverPage(rendered.model, requestUrl, lang, theme); + case "agentreach": + return renderAgentReachPage(rendered.model, requestUrl, lang, theme); + case "kb": + return renderKnowledgeBasePage(rendered.model, requestUrl, lang, theme); } })(); @@ -2200,15 +2203,45 @@ export function buildObserverReactProps(model: ObserverViewModel, requestUrl: UR }; } -function renderObserverPage(model: ObserverViewModel, requestUrl: URL, lang: UiLang, theme: UiTheme): string { - return ` +function renderObserverPage(model: ObserverViewModel, requestUrl: URL, lang: UiLang, theme: UiTheme): string { + return `
- ${renderToStaticMarkup(createElement(ObserverView, buildObserverReactProps(model, requestUrl, lang, theme)))} -
- `; -} - -function renderKnowledgeBasePage(model: KnowledgeBaseViewModel, requestUrl: URL, lang: UiLang, theme: UiTheme): string { + ${renderToStaticMarkup(createElement(ObserverView, buildObserverReactProps(model, requestUrl, lang, theme)))} + + `; +} + +export function buildAgentReachReactProps(model: AgentReachViewModel, requestUrl: URL, lang: UiLang, theme: UiTheme): AgentReachViewProps { + const requestedCandidateId = requestUrl.searchParams.get("candidate") ?? ""; + const initialSelectedId = model.candidates.some((candidate) => candidate.candidate_id === requestedCandidateId) + ? requestedCandidateId + : model.candidates[0]?.candidate_id ?? null; + + return { + lang, + initialTheme: theme, + initialSelectedId, + status: model.status, + summary_cards: model.summary_cards, + filters: model.filters, + candidates: model.candidates, + coverage: model.coverage, + direction_snapshot: model.direction_snapshot, + warnings: model.warnings, + state: model.state, + banner: model.banner, + }; +} + +function renderAgentReachPage(model: AgentReachViewModel, requestUrl: URL, lang: UiLang, theme: UiTheme): string { + return ` +
+ ${renderToStaticMarkup(createElement(AgentReachView, buildAgentReachReactProps(model, requestUrl, lang, theme)))} +
+ `; +} + +function renderKnowledgeBasePage(model: KnowledgeBaseViewModel, requestUrl: URL, lang: UiLang, theme: UiTheme): string { const ui = copy(lang); const selected = model.selected_card; const ribbonHtml = selected diff --git a/app/visualConsole/ossRouting.ts b/app/visualConsole/ossRouting.ts index 5f500cf..15f12d1 100644 --- a/app/visualConsole/ossRouting.ts +++ b/app/visualConsole/ossRouting.ts @@ -1,6 +1,7 @@ import { - buildKnowledgeBaseView, - buildObserverView, + buildKnowledgeBaseView, + buildAgentReachView, + buildObserverView, buildOverviewView, buildProjectsView, buildRunHealthView, @@ -27,10 +28,12 @@ export function normalizeRoutePath(pathname: string): WebRoute | null { return "weekly"; case "/run-health": return "run-health"; - case "/observer": - return "observer"; - case "/kb": - return "kb"; + case "/observer": + return "observer"; + case "/agentreach": + return "agentreach"; + case "/kb": + return "kb"; default: return null; } @@ -61,10 +64,12 @@ export function toViewHref( return `/weekly${encodeParams(baseParams)}`; case "run-health": return `/run-health${encodeParams(baseParams)}`; - case "observer": - return `/observer${encodeParams(baseParams)}`; - case "kb": - return `/kb${encodeParams(baseParams)}`; + case "observer": + return `/observer${encodeParams(baseParams)}`; + case "agentreach": + return `/agentreach${encodeParams(baseParams)}`; + case "kb": + return `/kb${encodeParams(baseParams)}`; } } @@ -88,10 +93,12 @@ export function routeTitle(route: WebRoute, lang: UiLang): string { return ui.navWeekly; case "run-health": return ui.navRunHealth; - case "observer": - return ui.navObserver; - case "kb": - return ui.navKb; + case "observer": + return ui.navObserver; + case "agentreach": + return ui.navAgentReach; + case "kb": + return ui.navKb; default: return ui.navOverview; } @@ -124,9 +131,11 @@ export function buildRoute(route: WebRoute, requestUrl: URL): RenderedRoute { }; case "run-health": return { route, model: buildRunHealthView(date) }; - case "observer": - return { route, model: buildObserverView(date) }; - case "kb": + case "observer": + return { route, model: buildObserverView(date) }; + case "agentreach": + return { route, model: buildAgentReachView(date) }; + case "kb": return { route, model: buildKnowledgeBaseView( diff --git a/app/visualConsole/ossTypes.ts b/app/visualConsole/ossTypes.ts index 983abe0..65f0d8e 100644 --- a/app/visualConsole/ossTypes.ts +++ b/app/visualConsole/ossTypes.ts @@ -1,13 +1,14 @@ import type { - KnowledgeBaseViewModel, - ObserverViewModel, + KnowledgeBaseViewModel, + AgentReachViewModel, + ObserverViewModel, OverviewViewModel, ProjectsViewModel, RunHealthViewModel, WeeklyViewModel, } from "../../src/visualConsole/types.ts"; -export type WebRoute = "overview" | "projects" | "weekly" | "run-health" | "observer" | "kb"; +export type WebRoute = "overview" | "projects" | "weekly" | "run-health" | "observer" | "agentreach" | "kb"; export type UiLang = "zh" | "en"; export type UiTheme = "light" | "dark"; @@ -16,6 +17,7 @@ export type RenderedRoute = | { route: "overview"; model: OverviewViewModel } | { route: "projects"; model: ProjectsViewModel } | { route: "weekly"; model: WeeklyViewModel } - | { route: "run-health"; model: RunHealthViewModel } - | { route: "observer"; model: ObserverViewModel } - | { route: "kb"; model: KnowledgeBaseViewModel }; + | { route: "run-health"; model: RunHealthViewModel } + | { route: "observer"; model: ObserverViewModel } + | { route: "agentreach"; model: AgentReachViewModel } + | { route: "kb"; model: KnowledgeBaseViewModel }; diff --git a/docs/specs/design-docs/agentreach-external-discovery-page-ui-design.md b/docs/specs/design-docs/agentreach-external-discovery-page-ui-design.md new file mode 100644 index 0000000..17367cc --- /dev/null +++ b/docs/specs/design-docs/agentreach-external-discovery-page-ui-design.md @@ -0,0 +1,649 @@ +# AgentReach 外部发现页面 UI 设计 + +## 状态 +- Date: 2026-06-28 +- Status: Proposed +- Scope: `visual-console /agentreach page`,只覆盖 AgentReach 外部发现与补证信号的前端展示层 +- 对应需求:`docs/specs/product-specs/外部发现与补证信号层需求分析.md` +- 设计基线:`codex/agentreach-foundation-clean` 已稳定的 external discovery 后端字段,以及现有 `ObserverView` 的页面体验 + +## Summary + +新增独立的 `/agentreach` 外部发现页面,用来展示 AgentReach 外部层当天发现的项目候选、已有候选补证、方向级观察、证据来源与平台 coverage 状态。 + +本设计中的第一版是 UI 完成基线,不是占位 MVP。实现阶段不能只接入 mock 数据、不能只做静态页面、不能省略候选详情联动、coverage 状态、secondary-only 边界和真实字段映射。 + +这个页面的用户心智不是“新的主榜”,而是“外部讨论证据工作台”: + +- 先看今天外部层发现了什么候选。 +- 再看该候选绑定到什么对象。 +- 再看证据来自哪些平台、哪些类型的人群在讨论。 +- 最后看本次外部层是否完整、哪些平台 partial / failed / not configured。 + +页面视觉上参考现有“新兴潜力项目 / Observer”页面与当前确认的 AgentReach 效果图方向:居中窄版、浅蓝背景、白色卡片、紫色主强调、轻量 chip、左侧候选列表、右侧详情面板、底部弱化平台状态。实现不能退化成宽屏 dashboard、营销页或 mock-only 展示。 + +## Goals / Non-goals + +### Goals + +- 新增一级页面 `/agentreach`。 +- 中文导航文案固定为“外部发现”,位置固定在“新兴潜力项目”右侧。 +- 基于真实 external discovery 后端字段展示,不再依赖 mock-only 字段。 +- 复用 Observer 页面成熟的候选列表、分页、选中态、详情面板、移动端体验和视觉语言。 +- 清楚表达外部层是 secondary-only 信号:可用于发现与补证,不替代 GitHub / Trendshift 主链路,不单独形成高置信主结论。 +- 在 `ok / partial / failed / skipped` 以及平台 coverage 异常时,仍保留页面结构和审计信息,让用户知道本轮外部层到底发生了什么。 + +### Non-goals + +- 不新增后端 external discovery schema。 +- 不修改 `DailyExternalAggregate`、`ObservationCandidate`、`ExternalEvidence` 或 `ExternalEvidenceSource` 的语义。 +- 不修改主榜评分、`total_score`、GitHub / Trendshift 主链路排序或主结论生成逻辑。 +- 不新增 AgentReach live search、平台 API 查询、自由搜索框或浏览器实时搜索能力。 +- 不把单日 aggregate 包装成真正多日趋势图。 +- 不在第一版承诺展示具体账号、handle、profile URL 或机构名单命中实体,除非后端 public-safe 字段明确提供。 +- 不把“加入观察”伪装成已可用写动作;没有 AgentReach -> observer/tracking 桥接前只能做禁用态或只读动作。 + +## Existing Code References + +### 页面风格与交互参考 + +- `app/client/ObserverView.tsx` + - `OBSERVER_PAGE_SIZE` + - `paginateObserverEntries` + - `selectedKey / setSelectedKey` + - `observer-react-candidate-stage` + - `observer-react-detail-stage` + - `observer-react-detail-scroll-region` + - 移动端详情面板与 body scroll lock +- `app/styles.css` + - `observer-*` 相关样式可作为视觉参考,但 AgentReach 应使用独立 class 命名,避免污染 Observer。 + +### 视图模型与投影参考 + +- `src/visualConsole/build.ts` + - `buildObserverView` + - `observerEntryToProjection` + - `buildObserverRouteFrame` + - daily context / navigator 的读取方式 +- `src/visualConsole/types.ts` + - `VisualConsoleView` + - `RouteFrameModel` + - `TopLevelViewStatus` + - `TimeNavigatorModel` + +### 路由、导航和渲染接入参考 + +- `app/server.ts` + - `routeRequiresClientReactRuntime` + - `resolveHydrationPayloadScripts` +- `app/client/mount.tsx` + - React 页面 hydration 入口 +- `app/visualConsole/ossRouting.ts` + - `normalizeRoutePath` + - `toViewHref` + - `routeTitle` + - `buildRoute` +- `app/visualConsole/ossDocument.ts` + - 顶部导航生成 + - route intent 文案 +- `app/visualConsole/ossPages.ts` + - React props 生成与 route page 渲染方式 +- `app/visualConsole/clientScript.ts` + - route prefetch / payload prefetch / client navigation 中 route 白名单与 payload 识别 + +### 后端数据契约参考 + +- `src/externalDiscovery/types.ts` + - `DailyExternalAggregate` + - `ObservationCandidate` + - `ExternalEvidence` + - `ExternalEvidenceSource` + - `ExternalDiscoveryCoverage` + - `ExternalProviderStatus` + - `ExternalCoverageStatus` + +## Data Contract / Field Mapping + +AgentReach UI 必须新增 display-only view-model adapter,命名为 `AgentReachViewModel` 或 `ExternalDiscoveryViewModel`。该 adapter 只做展示适配,不改 canonical artifact,不改 primary score。 + +### 输入来源 + +页面读取顺序固定如下: + +1. `DailyExternalAggregate`:作为 `/agentreach` 页面主体数据源,负责候选、证据、来源和 coverage 的完整展示。 +2. daily report 中已经嵌入的 `external_discovery` section:仅在 `DailyExternalAggregate` 缺失或不可解析时作为 fallback。 +3. run summary 中的 `external_discovery`:只补充运行状态、summary 级 coverage、warnings 和路径提示,不作为候选与证据主体。 + +若 `DailyExternalAggregate` 与 daily `external_discovery` 同时存在但候选数、证据数或 status 不一致,页面主体仍以 `DailyExternalAggregate` 为准,并在底部平台状态或 audit 区显示“daily section 与 aggregate 不一致”的 warning。实现不能按数量混选不同来源,也不能把两个来源合并成后端没有的事实。 + +### 顶部概览字段 + +| UI 文案 | 字段来源 | 说明 | +| --- | --- | --- | +| 运行状态 | `status` | provider 顶层状态,允许值为 `ok / skipped / partial / failed` | +| 状态说明 | `status_reason` | 原样用于状态说明,空值时显示“外部层已执行”或“无额外说明” | +| 已采纳事件 | `accepted_event_count` | 只表达本轮进入聚合的外部事件数量 | +| 已拒绝事件 | `rejected_event_count` | 用于审计,不作为负面质量结论 | +| 外部候选 | `observation_candidates.length` | 候选列表总数 | +| 项目补证 | `project_evidence.length` | 项目级 evidence 聚合数 | +| 方向观察 | `direction_evidence.length` | 方向级 evidence 聚合数 | +| 来源平台 | `platform_counts` | 各平台 accepted event 数 | +| 平台 coverage | `audit.coverage` | 展示平台状态,不等同于候选质量 | + +### 总览卡片字段 + +页面固定展示 5 张总览卡: + +| 卡片 | 计数规则 | 文案边界 | +| --- | --- | --- | +| 总览 | `accepted_event_count` | “不同来源的外部信号总览,包括发现、补证与方向观察。” | +| 新发现候选 | `candidate_kind=new_external_discovery` 或 `display_bucket=new_discoveries` | 不写成“已确认项目” | +| 已有候选补证 | `candidate_kind=external_evidence_boost` 或 `display_bucket=project_evidence` | 表达为补充证据 | +| 方向观察 | `scope=direction` 或 `candidate_kind=direction_watch` | 表达为方向线索,不等于趋势已成立 | +| 官方确认 | `display_bucket=official_releases`,或通过 `evidence_ids -> evidence.event_ids -> evidence_sources.raw_event_kind` 命中 `official_release / blog_post` 的候选 | 表达为官方信号,不等于主源确认 | + +### 候选卡字段 + +| UI 文案 | 字段来源 | 展示规则 | +| --- | --- | --- | +| 名称 | `display_name` | 卡片单行省略,详情展示完整可换行 | +| 类型 chip | `candidate_kind / display_bucket` | 映射为“新发现 / 外部补证 / 方向观察 / 官方信号 / 待确认” | +| 对象 | `target_type` + `scope` | project/paper/product/topic 映射为中文 | +| 外部关注度 | `external_attention_score` | 文案固定为“外部关注度”,无值显示“待评估”,禁止“证据强度” | +| 证据 | `evidence_ids.length` | 文案固定为“证据” | +| 来源 | 由 `evidence_ids -> ExternalEvidence.platforms` 反查,必要时从 `evidence_sources` 按 target 兜底 | `ObservationCandidate` 本身没有 `platforms` 字段,不能伪造 | +| 资格 | `qualification` | 映射为“观察 / 需主源确认 / 仅外部补证” | +| 进入日报 | `can_enter_daily` | 显示“是 / 否”,不能解释为已进主榜 | +| 进入周报 | `can_enter_weekly` | 显示“是 / 否”,方向候选可为周报观察 | +| 次级边界 | `cannot_be_primary_conclusion` | 若为 true,固定显示“不能作为主结论” | + +候选卡底部可展示一个短线索标签,例如: + +- `cross-platform`:来自关联 `ExternalEvidence.cross_platform=true` +- `observe-only`:`qualification=needs_primary_confirmation` 或 `cannot_be_primary_conclusion=true` +- `official-signal`:官方类 bucket 或 raw event + +### 右侧详情字段 + +详情面板必须由左侧选中候选驱动,点击不同候选时右侧内容必须切换。 + +| UI 文案 | 字段来源 | 展示规则 | +| --- | --- | --- | +| 完整名称 | `display_name` | 可读换行,不强制单行省略 | +| 对象绑定 | `target_type / target_key / target_url / repo_url / paper_url` | 明确显示对象类型与可访问 URL | +| 外部关注度 | `external_attention_score` | 主指标之一 | +| 证据 | `evidence_ids.length` | 主指标之一 | +| 来源 | 反查 evidence platforms | 主指标之一 | +| 独立来源 | 关联 evidence 的 `distinct_actor_count` 最大值 | 中文文案为“独立来源”;避免跨 evidence 简单求和造成重复计数 | +| 绑定置信 | `binding_confidence` | 映射为 high/proven、medium/watch、low/unbound 等中文态 | +| 讨论来源画像 | evidence 的 `actor_types / actor_tiers / distinct_actor_count` | 不承诺具体账号 | +| 跨平台确认 | evidence 的 `cross_platform` | 只能表达“跨平台外部确认”,不是主源确认 | +| 进入日报 / 周报 | `can_enter_daily / can_enter_weekly` | 与主榜结论隔离 | +| 主结论边界 | `cannot_be_primary_conclusion` | 固定可见 | +| 注意事项 | `caveats` | 缺失时显示“无额外 caveat”,不隐藏边界区 | + +### 证据聚合卡字段 + +`ExternalEvidence` 用于解释“这个候选为什么值得看”: + +| UI 文案 | 字段来源 | +| --- | --- | +| 证据 ID | `evidence_id` | +| 平台 | `platforms` | +| 提及 | `mention_count` | +| 独立来源 | `distinct_actor_count` | +| 活跃天数 | `active_day_count` | +| 首次看到 | `first_seen_at` | +| 最近看到 | `last_seen_at` | +| 跨平台 | `cross_platform` | +| 权威性摘要 | `authority_summary_cn` | +| 强度摘要 | `intensity_summary_cn` | +| 持续性摘要 | `persistence_summary_cn` | +| caveats | `caveats` | + +### 证据来源卡字段 + +`ExternalEvidenceSource` 用于解释“证据具体来自哪里”: + +| UI 文案 | 字段来源 | 展示规则 | +| --- | --- | --- | +| 平台 | `platform` | X / Reddit / HN / 官方网页 / 官方博客 | +| 标题 | `source_title` | 无标题时展示平台 + raw event kind | +| 来源链接 | `source_url` | 可访问时显示“访问来源” | +| 事件类型 | `raw_event_kind` | mention / discussion / official_release 等中文化 | +| 目标 | `target_name / target_url` | 表达该来源提到的对象 | +| 发布时间 | `source_published_at` | 没有时显示“发布时间未知” | +| 观察时间 | `observed_at` | 表达 AgentReach 看到的时间 | +| 互动指标 | `metrics.likes / reposts / comments / upvotes / replies` | 不展示后端不存在的 `points` 字段 | +| 讨论者类型 | `actor_type` | 机构 / 团队 / 个人 / 社区 / unknown | +| 讨论者层级 | `actor_tier` | core / proven / watch / ordinary / unknown | + +## Page IA / Layout + +页面固定为六段: + +1. Top Nav +2. Hero +3. 筛选与状态区 +4. 总览卡片区 +5. 主工作区 +6. 方向观察与平台状态 + +### Top Nav + +导航顺序固定为: + +1. 首页 +2. 项目库 +3. 本周趋势 +4. 数据状态 +5. 新兴潜力项目 +6. 外部发现 + +中文文案为“外部发现”,英文可暂用 `AgentReach` 或 `External Discovery`。中文环境禁止继续显示 `AgentReach` 作为主导航文案。 + +导航 href 需要保留: + +- `lang` +- `theme` +- `date` +- 必要时保留 `source_view` + +### Hero + +Hero 采用居中窄版: + +- 小 pill:`AgentReach 外部层` +- 主标题:`外部发现` +- 副文案:`从 X / Reddit / HN / 官方来源发现项目与补证信号,先看候选,再看证据。` +- 边界说明:`外部信号只作为次级判断,不单独形成主结论。` + +Hero 高度不能占满首屏。桌面首屏必须能看到筛选区、总览卡片和主工作区开头。 + +### 筛选与状态区 + +筛选区不是自由搜索框,只展示候选筛选 chips、排序控件和只读状态 chips。 + +- 类型 chips:`新发现`、`外部补证`、`方向观察`、`官方信号`、`待确认`。这些 chips 可以筛选候选。 +- 来源 chips:`X`、`Reddit`、`HN`、`官方`。这些 chips 可以按候选关联 evidence / source 平台筛选候选。 +- 排序:`关注排序`、`时间排序`、`证据排序`。排序只作用于当前候选集合。 +- 状态 chips:`ok`、`partial`、`failed`、`not configured`、`zero relevant results`。这些 chips 默认只读展示 provider / coverage 状态,不筛选候选。 + +chips 仅筛选已加载数据,不触发外部平台实时查询。 + +若后续要让状态 chips 参与筛选,必须另行定义“候选与 platform coverage 的绑定规则”。本设计第一版不允许把 `not_configured` 或 `zero_relevant_results` 当作候选过滤条件。 + +### 总览卡片区 + +总览卡片一行 5 张,窄屏自动换行。卡片视觉参考效果图与 Observer 的浅色卡片: + +- 紫色强调用于“总览”或当前重点。 +- 绿色用于 ok / 可进入。 +- 橙色用于 partial / 待确认 / 需补证。 +- 红色仅用于 failed 或 “不能作为主结论”边界。 + +### 主工作区 + +桌面为左右两列: + +- 左侧:候选列表,约 44% 宽度。 +- 右侧:候选详情,约 56% 宽度。 +- 两列顶部对齐,视觉底部尽量对齐。 +- 右侧详情不使用无意义固定大高度;内容较短时保持紧凑,内容较长时内部滚动。 + +左侧候选列表参考 Observer 卡片: + +- 卡片可点击。 +- 有选中态。 +- 长标题单行省略。 +- 卡片底部展示证据、来源、对象、cross-platform / observe-only 标签。 +- 候选多时使用分页,优先参考 Observer 的分页体验;不把页面无限拉长。 + +右侧详情参考 Observer 详情面板,但字段独立: + +- 顶部显示选中状态,如“观察中”。 +- 对象绑定 URL 作为独立行展示。 +- 主指标为“外部关注度 / 证据 / 来源 / 独立来源”。 +- 中部展示“为什么值得看”和“当前判断和建议”。 +- 下部展示“讨论来源画像”和“证据来源”。 +- 操作按钮固定在详情卡内部底部。 + +### 底部弱化区 + +底部模块命名为:`方向观察与平台状态`。 + +展示内容: + +- 今日方向快照:`direction_evidence.length`、`direction_label_counts` 与方向级候选数量。 +- 待归档外部信号数量。 +- `zero_relevant_results` 数量。 +- `not_configured` 数量。 +- `partial` 数量。 +- 平台 coverage chips,例如 `X partial`、`Reddit ok`、`HN ok`、`官方 not configured`。 + +底部只做审计和状态提示,不抢主工作区注意力。 + +## Component Design + +### AgentReachViewModel + +新增 display-only view-model adapter,建议字段形态如下: + +```ts +interface AgentReachViewModel { + context: ViewContext; + state: VisualConsoleState; + time_navigator: TimeNavigatorModel; + route_frame: RouteFrameModel; + status: AgentReachStatusViewModel; + summary_cards: AgentReachSummaryCardViewModel[]; + filters: AgentReachFilterViewModel; + candidates: AgentReachCandidateCardViewModel[]; + evidence_by_id: Record; + sources_by_event_id: Record; + coverage: AgentReachCoverageViewModel[]; + warnings: string[]; +} +``` + +该 view-model 的职责是: + +- 把 canonical 字段映射为中文 UI 文案。 +- 建立 `candidate.evidence_ids -> ExternalEvidence -> evidence.event_ids -> ExternalEvidenceSource` 的反查索引。 +- 计算候选卡需要的来源平台、证据数、独立来源数。 +- 生成状态 chips 与 coverage chips。 +- 不新增判断,不改变后端 score,不把 missing 字段填成假数据。 + +### 候选列表组件 + +候选列表组件输入只接受 `AgentReachCandidateCardViewModel[]`。 + +卡片固定展示: + +- 类型 chip +- `display_name` +- 简短摘要:优先 `evidence_summary_cn`,其次 `display_reasons` +- `外部关注度` +- `证据` +- `来源` +- `对象` +- 次级边界标签 + +候选排序: + +- `关注排序`:`external_attention_score` 降序,无值排后。 +- `时间排序`:关联 evidence 的 `last_seen_at` 降序,无值排后。 +- `证据排序`:`evidence_ids.length` 降序,之后按来源平台数。 + +### 详情组件 + +详情组件必须只读当前 `selectedCandidateId`。 + +详情内部分区: + +1. 对象绑定 +2. 指标区 +3. 候选判断 +4. 讨论来源画像 +5. 证据聚合 +6. 证据来源 +7. 操作区 + +操作区第一版允许: + +- `查看证据` +- `访问来源` +- `待接入观察` + +若没有 tracking 桥接,`加入观察` 和 `加入方向观察` 必须 disabled,或文案改为 `待接入观察`。不能提交真实 form,也不能调用 Observer 的 tracking endpoint。 + +### 讨论来源画像组件 + +组件标题固定为:`讨论来源画像`。 + +展示: + +- `actor_types` 分布:机构、团队、个人、社区、unknown。 +- `actor_tiers` 分布:core、proven、watch、ordinary、unknown。 +- `distinct_actor_count`:独立来源数。 + +不展示: + +- handle +- profile URL +- 未脱敏账号名 +- 后端未提供的机构名单命中实体 + +### 证据来源组件 + +证据来源卡每张显示: + +- 平台图标或文字标识。 +- 标题。 +- 事件类型。 +- 目标名称。 +- 发布时间 / 观察时间。 +- metrics。 +- 访问来源按钮。 + +metrics 文案必须平台中立: + +- `likes` -> `likes` +- `reposts` -> `转发` +- `comments` -> `comments` +- `upvotes` -> `upvotes` +- `replies` -> `replies` + +如果用户界面需要全中文,可在后续统一翻译,但不能展示不存在的 `points`。 + +## States & Empty / Failed UX + +状态表达必须区分 provider 顶层状态和 platform coverage 状态。 + +### Provider 状态 + +| 状态 | UI 表达 | 规则 | +| --- | --- | --- | +| `ok` | 绿色 chip | 正常展示候选、证据和平台状态 | +| `partial` | 橙色 chip | 明确说明哪些平台 partial,不能写成“无信号” | +| `failed` | 红色 chip | 显示失败说明、coverage 和 warnings,不能渲染成空列表 | +| `skipped` | 中性 chip | 表达未执行或被跳过,不写成“没有外部讨论” | + +### Platform coverage 状态 + +| 状态 | UI 文案 | 规则 | +| --- | --- | --- | +| `ok` | 正常 | 平台正常参与 | +| `partial` | 部分可用 | 平台有部分失败或结果不完整 | +| `zero_relevant_results` | 已执行但无相关结果 | 不能和 failed / not_configured 混淆 | +| `not_configured` | 未配置 | 显示为未配置 | +| `manual_import_only` | 仅手动导入 | 显示为该平台没有自动 runner | +| `unavailable` | 不可用 | 表达平台不可用 | +| `failed` | 失败 | 显示失败原因和 warnings | + +### Empty + +当候选为空但 provider 非 failed: + +- 保留 Hero、筛选、总览、主工作区、底部状态。 +- 左侧候选区显示:`今天暂无可展示的外部候选`。 +- 右侧详情显示:`选择候选后查看外部证据详情` 或 `本轮没有可绑定详情的候选`。 +- 底部仍展示 coverage。 + +### Failed + +当 provider failed: + +- 顶部 status chip 显示 failed。 +- 总览卡显示已采纳事件为 0 或实际值。 +- 候选列表显示失败说明,不显示“暂无讨论”。 +- 底部必须展示 `audit.coverage`、`audit.warnings`、`status_reason`。 + +## Reuse From Observer + +### 可以复用 + +- 页面视觉密度与居中容器感觉。 +- 候选卡的选中态、hover 态、分页控件。 +- 左候选 / 右详情布局。 +- 移动端单列和详情面板体验。 +- chip、卡片、按钮和浅色背景语言。 +- `paginateObserverEntries` 的分页思路,可抽成更通用 helper 或在 AgentReach 内局部复刻。 + +### 不能直接复用 + +- Observer 的 repo 字段语义。 +- Observer 的 star/fork/issues/PR 指标。 +- Observer 的 `observerScore` 或 `radarScore`。 +- Observer 的 tracking form。 +- Observer 的搜索框体验。 +- Observer 的 `presetBucket` 语义。 + +AgentReach 只能复用“形态”和“交互模式”,不能复用“字段含义”。 + +## Interaction Rules + +- 左侧点击候选,右侧详情必须切换。 +- 默认选中第一页第一条候选。 +- 当前选中候选切换筛选后若不可见,应清空选中或切到当前结果第一条,不能保留幽灵详情。 +- 候选分页时,右侧详情只能展示当前页可见候选或明确提示“候选不在当前页”。 +- 证据来源较多时,详情内部滚动或局部分页,不撑破页面。 +- 筛选 chips 横向可滚动,不撑破容器。 +- 排序切换不能触发网络请求。 +- “访问来源”只打开已有 `source_url`。 +- “待接入观察”不提交数据。 + +## Responsive Rules + +### Desktop + +- 页面最大宽度参考 Observer 的居中工作台,但不使用过宽 dashboard。 +- 主工作区左右两列。 +- 右侧详情面板随内容自适应,设置合理 `max-height` 与内部滚动。 +- 两列视觉底部尽量对齐;若右侧内容短,不用硬撑出大空白。 + +### Tablet + +- 总览卡 2-3 列换行。 +- 主工作区可继续两列,但右侧宽度不得小于可读详情宽度。 +- chips 横向滚动。 + +### Mobile + +- 单列布局。 +- 顺序固定:Hero -> 筛选 -> 总览 -> 候选列表 -> 详情 -> 平台状态。 +- 候选列表在前,详情在后。 +- 若采用抽屉式详情,需要明确返回列表入口。 +- 详情按钮仍在详情卡内部,不悬浮遮挡内容。 + +## Visual Acceptance Criteria + +- 页面整体接近确认的 AgentReach 效果图:浅蓝背景、白色卡片、紫色强调、橙/绿状态 chip、圆角卡片。 +- 视觉上与 Observer / 新兴潜力项目风格一致,不出现另一个产品的割裂感。 +- 不做宽屏 dashboard,不做营销页,不做自由搜索主入口。 +- 首屏必须能看到主工作区开头。 +- `外部关注度` 必须替代所有 `score` 主指标文案。 +- 右侧主指标固定为中文:`关注度`、`证据`、`来源`、`独立来源`。 +- 不出现 `证据强度`。 +- 不出现 `score / sources / mentions` 作为主指标标题;平台卡内部可以保留 `likes / comments / upvotes / replies` 这类原始指标名。 +- 候选列表与详情区域无文本、按钮、证据卡出界。 +- 长项目名卡片单行省略,详情可换行。 +- 底部状态条右边缘与主内容宽度一致。 +- partial / failed / not_configured / zero_relevant_results 颜色和文案必须可区分。 + +## Test & Review Plan + +设计进入 exec-plan 前,必须准备 4 类 sanitized deterministic fixture。真实产物切片只能作为补充人工验收,不能替代回归 fixture,因为真实外部结果会随时间漂移。 + +1. `ok` + - 有多个候选。 + - 有项目级 evidence。 + - 有方向级 evidence。 + - 有 evidence sources。 +2. `partial` + - 至少一个平台 `partial`。 + - 至少一个平台 `ok`。 + - 候选仍可展示。 +3. `failed` + - provider `failed`。 + - 有 `status_reason` 和 `audit.coverage`。 + - 页面不渲染成空列表。 +4. `empty` + - provider 非 failed。 + - `observation_candidates=[]`。 + - coverage 仍可见。 + +实现后需要验证: + +- `/agentreach?lang=zh&theme=light&date=latest` 正常打开。 +- 中文导航显示“外部发现”,位置在“新兴潜力项目”右侧。 +- 左侧候选点击后右侧详情切换。 +- 筛选 chips 不触发网络请求。 +- 候选分页可用。 +- 证据来源卡显示真实 source 与 metrics。 +- failed / partial / zero_relevant_results / not_configured 文案不混淆。 +- 移动端单列顺序正确。 +- 没有 `score / sources / mentions` 作为主指标标题。 +- 没有 `证据强度`。 +- 没有将外部候选写入 Observer tracking 的假动作。 + +建议测试文件方向: + +- `src/__tests__/visualConsoleAgentReachViewModel.test.ts` +- `src/__tests__/visualConsoleAgentReachRoute.test.ts` +- `src/__tests__/visualConsoleAgentReachPresentation.test.ts` + +如实现为 React 页面,还需要覆盖: + +- selected candidate state +- pagination +- filter chips +- mobile detail fallback + +## Risks / Open Questions + +### Risk: 旧 UI WIP 与新设计混用 + +当前已有旧 `codex/agentreach-external-discovery-ui` 工作区。新实现必须以 `codex/agentreach-ui-redesign-v2` 为准,旧 UI 只能参考,不应直接叠加旧 mock-only 字段。 + +### Risk: 候选来源平台被错误读取 + +`ObservationCandidate` 没有 `platforms`。实现若直接读 `candidate.platforms` 会导致来源为 0 或 undefined。必须通过 `evidence_ids` 反查 `ExternalEvidence.platforms`,必要时再通过 `evidence_sources` 兜底。 + +### Risk: 证据来源卡误用字段 + +`first_seen_at / last_seen_at` 在 `ExternalEvidence` 上,不在 `ExternalEvidenceSource` 上。来源卡应使用 `source_published_at / observed_at`。 + +### Risk: HN points 文案 + +后端 metrics 当前没有 `points` 字段。若界面展示 HN points,必须先有 adapter 映射或 schema 变更。第一版只展示真实存在的 `upvotes / comments / replies`。 + +### Risk: “谁在讨论”被过度承诺 + +第一版只能展示 actor 类型与层级分布。具体机构、账号、profile URL 需要 public-safe 后端字段明确提供后才能设计。 + +### Risk: “趋势”被过度承诺 + +单日 aggregate 只能展示“今日外部讨论快照”。真正多日趋势需要 weekly 或多日 aggregate 聚合设计,不能在本页面第一版画趋势折线误导用户。 + +### Risk: 加入观察动作误导 + +没有 AgentReach -> observer/tracking 写入桥接前,“加入观察”不能是可提交动作。若后续要做真实写入,需要单独设计权限、对象类型、direction topic、repo/product/paper 的落库规则。 + +### Open Question: `/agentreach` 是否使用 React 页面 + +推荐使用 React 页面,以便复用 Observer 的选中态、分页、移动端详情体验。但如果实现成本要求更低,也可以用 SSR + 少量 client script。无论方式如何,点击候选切换详情必须可用。 + +### Open Question: 英文界面文案 + +中文是第一优先级。英文可沿用 `AgentReach` / `External Discovery`,但不阻塞中文版实现。 + +### Open Question: AgentReach 与 Observer 的后续桥接 + +本设计只保留只读/禁用动作。真实桥接需要后续独立设计: + +- project / repo 候选进入 Observer tracking +- paper / product 候选进入独立观察池 +- topic / direction 候选进入方向观察 + +这些动作不能混在本次 UI 展示设计里默认完成。 diff --git a/docs/specs/exec-plans/agentreach-external-discovery-page-ui.exec-plan.md b/docs/specs/exec-plans/agentreach-external-discovery-page-ui.exec-plan.md new file mode 100644 index 0000000..0342d3d --- /dev/null +++ b/docs/specs/exec-plans/agentreach-external-discovery-page-ui.exec-plan.md @@ -0,0 +1,533 @@ +# 执行计划:AgentReach 外部发现页面 UI + +## 文档状态 + +- 版本:`v0.1` +- 当前状态:`Proposed` +- 负责人:`Codex` +- 风险等级:`Medium` +- 关联需求: + - `docs/specs/product-specs/外部发现与补证信号层需求分析.md` +- 关联设计: + - `docs/specs/design-docs/agentreach-external-discovery-page-ui-design.md` +- 说明:本计划只承接 `/agentreach` 前端展示实现,不新增后端 schema,不新增平台查询,不修改主评分、主榜单、daily / weekly 主结论或 AgentReach 后端消费链路。 + +## 任务信息 + +| 字段 | 内容 | +| --- | --- | +| 任务名称 | AgentReach 外部发现页面 UI | +| 用户目标 | 基于真实 `DailyExternalAggregate` 字段重新实现 `/agentreach` 页面,风格接近 Observer / 新兴潜力项目页面 | +| 主要影响面 | `src/visualConsole/*`、`app/visualConsole/*`、`app/client/*`、`app/server.ts`、`app/styles.css`、`src/__tests__/*`、`docs/specs/exec-plans/README.md` | +| 交付目标 | 新增一级 `/agentreach` 页面,中文导航为“外部发现”,展示候选、详情、证据来源、讨论来源画像与平台 coverage 状态 | + +## 目标 + +1. 新增独立 `/agentreach` 页面,并在中文导航中以“外部发现”展示,位置固定在“新兴潜力项目”右侧。 +2. 新增 display-only view-model adapter,优先消费 `DailyExternalAggregate`,把后端字段映射为稳定中文 UI 文案。 +3. 页面采用 Observer 风格的居中窄版工作台:Hero、筛选与状态区、5 张总览卡、左侧候选列表、右侧详情面板、底部弱化平台状态。 +4. 候选列表支持筛选、排序、分页和选中态;点击左侧候选时,右侧详情必须切换。 +5. 右侧详情展示对象绑定、外部关注度、证据、来源、独立来源、讨论来源画像、证据聚合、证据来源和只读/禁用动作。 +6. 明确表达外部层是 secondary-only 信号,不替代 GitHub / Trendshift 主链路,不单独形成高置信主结论。 +7. 覆盖 `ok / partial / failed / empty` 四类 deterministic fixture,避免真实外部结果漂移导致回归不可测。 + +## 设计对齐 + +| 设计章节 | 本计划承接位置 | 实施要求 | +| --- | --- | --- | +| `Goals / Non-goals` | `目标`、`边界约束` | 只做前端展示,不新增 schema、live search、主评分或 Observer 写入桥接 | +| `Data Contract / Field Mapping` | `Phase 1`、`Phase 2` | 固定 `DailyExternalAggregate` 为主数据源,daily section 仅 fallback,run summary 只补状态 | +| `Page IA / Layout` | `Phase 4`、`Phase 5` | 六段结构、左候选右详情、底部状态条、导航位置必须落地 | +| `Component Design` | `Phase 2`、`Phase 5` | 新增 AgentReach view-model、候选列表、详情、讨论来源画像和证据来源组件 | +| `States & Empty / Failed UX` | `Phase 3`、`Phase 5` | provider 状态和 platform coverage 状态分开表达,failed 不渲染成空列表 | +| `Reuse From Observer` | `Phase 4`、`Phase 5` | 复用 Observer 的布局和交互模式,不复用字段语义、score、tracking form 或搜索框 | +| `Interaction Rules / Responsive Rules` | `Phase 5`、`Phase 6` | 候选点击联动、分页、筛选、移动端单列和 chips 横向滚动必须可测 | +| `Visual Acceptance Criteria` | `Phase 6`、`验收标准` | 不宽屏 dashboard,不营销页,不自由搜索,不出现 `证据强度` | +| `Test & Review Plan` | `Phase 0`、`Phase 6` | 先准备 4 类 deterministic fixture,再实现 view-model / route / presentation 测试 | + +## 边界约束 + +### 允许修改 + +- `src/visualConsole/types.ts` +- `src/visualConsole/readLayer.ts` +- `src/visualConsole/build.ts` +- 允许新增 `src/visualConsole/agentReachViewModel.ts` 或同职责文件 +- `app/visualConsole/ossTypes.ts` +- `app/visualConsole/ossRouting.ts` +- `app/visualConsole/ossDocument.ts` +- `app/visualConsole/copy.ts` +- `app/visualConsole/ossCopy.ts` +- `app/visualConsole/ossPages.ts` +- `app/visualConsole/clientScript.ts` +- `app/server.ts` +- `app/client/mount.tsx` +- 允许新增 `app/client/AgentReachView.tsx` +- `app/styles.css` +- `src/__tests__/*` +- `docs/specs/exec-plans/README.md` +- 本执行计划文档自身 + +### 禁止修改 + +- 不修改 `src/externalDiscovery/types.ts` 的 canonical schema。 +- 不修改 `DailyExternalAggregate`、`ObservationCandidate`、`ExternalEvidence`、`ExternalEvidenceSource` 的字段语义。 +- 不修改主榜评分、`total_score`、`discussion_score`、GitHub / Trendshift 主链路排序或主结论生成逻辑。 +- 不新增 AgentReach live search、平台 API 查询、自由搜索框、浏览器实时搜索或后台轮询。 +- 不把 run summary 当作候选或证据主体。 +- 不直接读取 `candidate.platforms`,因为 `ObservationCandidate` 没有该字段。 +- 不把 `first_seen_at / last_seen_at` 放到 source card 上;source card 只能使用 `source_published_at / observed_at`。 +- 不展示后端不存在的 `points` 字段。 +- 不把 `加入观察` 做成可提交动作;没有桥接前只能显示 `待接入观察`、disabled 或只读动作。 +- 不复用 Observer 的 repo score、star/fork/issues/PR 指标、tracking form、搜索框或 `presetBucket` 语义。 + +### 数据源约束 + +1. 页面主体读取优先级固定为: + - `DailyExternalAggregate` + - daily report `external_discovery` section fallback + - run summary `external_discovery` 只补状态、coverage、warnings 和路径提示 +2. 若 `DailyExternalAggregate` 与 daily section 同时存在且不一致,页面主体以 aggregate 为准,并在 audit / 平台状态区展示 warning。 +3. daily section fallback 缺少 `evidence_sources` 时,不得补假 source;只能降级展示候选、项目 evidence、方向 summary 和 coverage,并显示“来源明细不可用”的 warning。 +4. `独立来源` 口径固定为关联 evidence 的 `distinct_actor_count` 最大值,不跨 evidence 简单求和。 + +## 当前状态 + +1. AgentReach 后端链路已能产出 `DailyExternalAggregate` 相关字段,包括 `observation_candidates`、`project_evidence`、`direction_evidence`、`evidence_sources`、`platform_counts`、`audit.coverage`。 +2. 旧 `/agentreach` UI 曾出现 mock-only、来源为 0、右侧详情不联动、布局与参考图偏离等问题;本计划以 `agentreach-ui-redesign-v2` worktree 中的新设计文档为准,不叠加旧 UI WIP。 +3. Observer / 新兴潜力项目页面已经有可参考的卡片、分页、选中态、详情面板和移动端体验。 +4. 当前阶段只生成 exec-plan,不开始代码实现。 + +## 已落地内容 + +- `docs/specs/design-docs/agentreach-external-discovery-page-ui-design.md` 已完成并通过设计审核,可作为本计划上游设计。 +- 本 exec-plan 已落盘,当前尚未开始代码实现。 +- 后续实施时必须按阶段更新本节,至少分组记录: + - view-model / read layer 已落地内容 + - route / navigation / payload 已落地内容 + - React 页面与交互已落地内容 + - CSS / responsive 已落地内容 + - fixture / tests / verification 已落地内容 + +## 文件结构与职责切分 + +### 视图模型层 + +- 新增或修改 `src/visualConsole/agentReachViewModel.ts` + - 构建 `AgentReachViewModel`。 + - 负责输入选择、字段映射、索引构建、候选派生字段、summary cards、coverage chips、warnings。 + - 不改 canonical artifact,不产生新判断,不改后端 score。 +- 修改 `src/visualConsole/readLayer.ts` + - 新增 `getDailyExternalAggregate(date)` 或同职责 reader。 + - 路径必须使用 `src/externalDiscovery/paths.ts` 中的 `externalAggregatePath(date)`,不得在 view-model 中手写路径。 + - 返回统一 `ReadResult`,保留 `not_found / parse_error / unsupported_context` 语义。 +- 修改 `src/visualConsole/types.ts` + - 增加 `agentreach` route/view 类型。 + - 增加 AgentReach display-only view-model 类型。 +- 修改 `src/visualConsole/build.ts` + - 接入 `buildAgentReachView`。 + - 读取 daily context 中的 aggregate / daily section / run summary fallback。 + - 保持其他页面的 view-model 不变。 + +### 路由与文档渲染层 + +- 修改 `app/visualConsole/ossTypes.ts` + - 将 `agentreach` 加入 `WebRoute`。 + - 将 `agentreach` 加入 `RenderedRoute` discriminated union,model 类型为 AgentReach view-model。 +- 修改 `app/visualConsole/ossRouting.ts` + - 增加 `/agentreach` route normalization、href 和 title。 + - 保留 `lang`、`theme`、`date` 查询参数。 +- 修改 `app/visualConsole/copy.ts` 与 `app/visualConsole/ossCopy.ts` + - 增加中文 `navAgentReach` 或同等 copy key,文案固定为“外部发现”。 + - 增加英文 `AgentReach` 或 `External Discovery` 文案。 + - 不把中文主导航继续写成 `AgentReach`。 +- 修改 `app/visualConsole/ossDocument.ts` + - 顶部导航增加“外部发现”,位置在“新兴潜力项目”右侧。 + - 中文环境禁止显示 `AgentReach` 作为主导航文案。 + - `routeIntent` 必须增加 `agentreach`,中文表达为“先看外部层发现了什么候选,再看证据来自哪里。”或等价语义。 +- 修改 `app/visualConsole/ossPages.ts` + - 为 `/agentreach` 生成 React hydration payload 或 SSR fallback。 +- 修改 `app/visualConsole/clientScript.ts` + - 把 `/agentreach` 纳入客户端导航、payload prefetch 和 route 白名单。 +- 修改 `app/server.ts` + - `/agentreach` 需要 React runtime 时,接入 payload script 与 hydration。 + +### React 组件层 + +- 新增 `app/client/AgentReachView.tsx` + - 负责 Hero、筛选与状态区、总览卡片、候选列表、详情面板、底部状态条。 + - 只消费 `AgentReachViewModel`。 + - 实现候选筛选、排序、分页、选中态和详情切换。 + - 证据来源较多时在详情内部滚动或局部分页。 +- 修改 `app/client/mount.tsx` + - 增加 `agentreach` hydration 入口。 + +### 样式层 + +- 修改 `app/styles.css` + - 增加 `agentreach-*` 独立 class。 + - 视觉参考 `observer-*`,但不污染或重定义 Observer 样式。 + - 实现浅蓝背景、白色卡片、紫色强调、橙/绿状态 chip、圆角卡片、窄版居中布局。 + - 保证候选列表、详情面板、底部状态条不出界。 + +### 测试与 fixture 层 + +- 新增 `src/__tests__/visualConsoleAgentReachFixtures.ts` + - 提供 `ok / partial / failed / empty` 四类 deterministic fixture。 +- 新增 `src/__tests__/visualConsoleAgentReachViewModel.test.ts` + - 验证字段映射、来源反查、独立来源口径、fallback 降级、warnings。 +- 新增 `src/__tests__/visualConsoleAgentReachRoute.test.ts` + - 验证 `/agentreach` route、导航位置、查询参数保留和 React payload 接入。 +- 新增 `src/__tests__/visualConsoleAgentReachPresentation.test.ts` + - 验证中文文案、状态表达、无禁用词、无 fake action、empty/failed UX。 +- 可选扩展现有 web/visual 测试 + - 覆盖移动端单列、chips 横向滚动、候选点击切换详情和分页。 + +## 阶段进度 + +| 阶段 | 状态 | 设计映射 | 完成标志 | +| --- | --- | --- | --- | +| Phase 0:计划与 fixture 前置 | `Proposed` | `Test & Review Plan` | 4 类 deterministic fixture 定义完成,旧 UI WIP 边界确认 | +| Phase 1:route 与类型壳 | `Proposed` | `Existing Code References`、`Top Nav` | `/agentreach` route 类型、导航、href 保参和 payload 白名单接入 | +| Phase 2:AgentReach display-only view-model | `Proposed` | `Data Contract / Field Mapping` | 候选、证据、来源、coverage、summary cards 和 warnings 可由真实字段稳定生成 | +| Phase 3:状态与降级表达 | `Proposed` | `States & Empty / Failed UX` | ok / partial / failed / empty 均有可测试 UI 状态 | +| Phase 4:React 页面与 Observer 风格复用 | `Proposed` | `Page IA / Layout`、`Reuse From Observer` | 页面六段结构、左候选右详情、分页和详情联动完成 | +| Phase 5:视觉与响应式收口 | `Proposed` | `Responsive Rules`、`Visual Acceptance Criteria` | 桌面/移动布局、chips 滚动、文本不出界、按钮不越界 | +| Phase 6:回归验证与文档账本 | `Proposed` | `Test & Review Plan` | AgentReach UI 单测、route 测试、presentation 测试和必要回归通过 | + +## 实施步骤 + +### Phase 0:计划与 fixture 前置 + +1. 确认当前工作区为 `D:\AgentRadar\worktrees\agentreach-ui-redesign-v2`,分支为 `codex/agentreach-ui-redesign-v2`。 +2. 读取并冻结以下材料: + - `docs/specs/product-specs/外部发现与补证信号层需求分析.md` + - `docs/specs/design-docs/agentreach-external-discovery-page-ui-design.md` + - `app/client/ObserverView.tsx` + - `app/styles.css` 中 `observer-*` 样式 + - `src/externalDiscovery/types.ts` + - `src/types.ts` +3. 新增 deterministic fixtures,至少覆盖: + - `ok`:多个候选、项目 evidence、方向 evidence、source cards、跨平台与 actor 分布。 + - `partial`:至少一个 platform partial、至少一个 platform ok,候选仍可展示。 + - `failed`:provider failed,有 `status_reason` 和 `audit.coverage`,不能渲染为空列表。 + - `empty`:provider 非 failed,候选为空,coverage 仍可见。 +4. fixture 必须 sanitized,不包含 raw text、未脱敏账号、profile URL、cookie、token、session、password 或 OAuth。 +5. 不使用真实实时 AgentReach 结果替代 deterministic fixture。 + +### Phase 1:route 与类型壳 + +1. 在 `src/visualConsole/types.ts` 增加 `agentreach` 顶级 view / route 类型。 +2. 在 `app/visualConsole/ossTypes.ts` 增加 `agentreach`: + - `WebRoute` + - `RenderedRoute` +3. 在 `app/visualConsole/ossRouting.ts` 增加 `/agentreach`: + - `normalizeRoutePath("/agentreach")` + - `toViewHref("agentreach")` + - `routeTitle("agentreach")` + - `buildRoute("agentreach")` +4. 在 `app/visualConsole/copy.ts` 与 `app/visualConsole/ossCopy.ts` 增加导航 copy: + - 中文固定为 `外部发现` + - 英文可为 `AgentReach` 或 `External Discovery` +5. 在 `app/visualConsole/ossDocument.ts` 更新导航顺序: + 1. 首页 + 2. 项目库 + 3. 本周趋势 + 4. 数据状态 + 5. 新兴潜力项目 + 6. 外部发现 +6. `routeIntent` 增加 `agentreach`,文案表达外部发现证据工作台语义。 +7. 中文导航文案固定为“外部发现”;英文可为 `AgentReach` 或 `External Discovery`。 +8. 确认 href 保留 `lang`、`theme`、`date`,并在需要时保留 `source_view`。 +9. 在 `app/server.ts`、`app/visualConsole/ossPages.ts`、`app/visualConsole/clientScript.ts` 和 `app/client/mount.tsx` 接入 `agentreach` React payload。 +10. 保证其他路由导航和产品生态相关路由不受影响。 + +### Phase 2:AgentReach display-only view-model + +1. 新增 `buildAgentReachView` 或等价函数。 +2. 在 `src/visualConsole/readLayer.ts` 新增 aggregate reader: + - 函数名建议为 `getDailyExternalAggregate(date)`。 + - 使用 `externalAggregatePath(date)` 生成路径。 + - 返回 `ReadResult`。 + - date 校验沿用 read layer 现有 `validateDateInput` 语义。 +3. 输入选择顺序固定为: + - 优先读取 `DailyExternalAggregate` + - aggregate 缺失或不可解析时 fallback 到 daily `external_discovery` + - run summary 只补充 status、coverage、warnings、aggregate path +4. 建立索引: + - `evidence_by_id: Record` + - `sources_by_event_id: Record` + - `candidate.evidence_ids -> evidence -> event_ids -> sources` +5. 候选卡派生字段: + - 类型 chip:`candidate_kind / display_bucket` + - 对象:`target_type + scope` + - 外部关注度:`external_attention_score` + - 证据:`evidence_ids.length` + - 来源:由 `ExternalEvidence.platforms` 反查,必要时从 source 兜底 + - 资格:`qualification` + - 日报/周报:`can_enter_daily / can_enter_weekly` + - 次级边界:`cannot_be_primary_conclusion` +6. 详情派生字段: + - 对象绑定 URL:`target_url / repo_url / paper_url` + - 关注度、证据、来源、独立来源 + - 绑定置信:`binding_confidence` + - 讨论来源画像:`actor_types / actor_tiers / distinct_actor_count` + - 跨平台确认:`cross_platform` + - caveats +7. 独立来源口径固定为关联 evidence 的 `distinct_actor_count` 最大值,不求和。 +8. source card 使用: + - `platform` + - `source_title` + - `source_url` + - `raw_event_kind` + - `target_name / target_url` + - `source_published_at / observed_at` + - `metrics.likes / reposts / comments / upvotes / replies` + - `actor_type / actor_tier` +9. 禁止生成不存在的 `points`、`candidate.platforms` 或账号/机构具体名单。 + +### Phase 3:状态与降级表达 + +1. provider 顶层状态映射: + - `ok`:绿色 chip,正常展示候选、证据和平台状态。 + - `partial`:橙色 chip,说明哪些平台 partial,不能写成“无信号”。 + - `failed`:红色 chip,展示失败说明、coverage、warnings,不能渲染为空列表。 + - `skipped`:中性 chip,表达未执行或跳过,不写成“没有外部讨论”。 +2. platform coverage 状态映射: + - `ok` -> `正常` + - `partial` -> `部分可用` + - `zero_relevant_results` -> `已执行但无相关结果` + - `not_configured` -> `未配置` + - `manual_import_only` -> `仅手动导入` + - `unavailable` -> `不可用` + - `failed` -> `失败` +3. 空候选但 provider 非 failed: + - 保留页面结构。 + - 左侧显示 `今天暂无可展示的外部候选`。 + - 右侧显示 `选择候选后查看外部证据详情` 或 `本轮没有可绑定详情的候选`。 + - 底部仍展示 coverage。 +4. provider failed: + - 顶部和底部都展示 failed 状态。 + - 候选区显示失败说明,不显示“暂无讨论”。 + - 必须展示 `status_reason`、`audit.coverage`、`audit.warnings`。 +5. daily section fallback 缺 source 明细时: + - 候选与 evidence summary 可展示。 + - source cards 显示“来源明细不可用”。 + - 底部 warning 明确 fallback 降级,不得填假 source。 + +### Phase 4:React 页面与 Observer 风格复用 + +1. 新增 `AgentReachView` React 组件。 +2. 页面六段结构固定为: + - Top Nav + - Hero + - 筛选与状态区 + - 总览卡片区 + - 主工作区 + - 方向观察与平台状态 +3. Hero 文案固定: + - 小 pill:`AgentReach 外部层` + - 主标题:`外部发现` + - 副文案:`从 X / Reddit / HN / 官方来源发现项目与补证信号,先看候选,再看证据。` + - 边界说明:`外部信号只作为次级判断,不单独形成主结论。` +4. 筛选区不能出现自由搜索框。 +5. 类型 chips: + - `新发现` + - `外部补证` + - `方向观察` + - `官方信号` + - `待确认` +6. 来源 chips: + - `X` + - `Reddit` + - `HN` + - `官方` +7. 排序控件: + - `关注排序` + - `时间排序` + - `证据排序` +8. 状态 chips 只读展示,不筛选候选。 +9. 候选列表: + - 默认选中第一页第一条。 + - 点击候选切换右侧详情。 + - 筛选后选中项不可见时,切到当前结果第一条或清空。 + - 候选多时分页,不无限拉长页面。 +10. 详情面板: + - 只读当前 `selectedCandidateId`。 + - 操作区只允许 `查看证据`、`访问来源`、`待接入观察`。 + - `待接入观察` 不提交 form,不调用 Observer tracking endpoint。 + +### Phase 5:视觉与响应式收口 + +1. `app/styles.css` 新增 `agentreach-*` 样式,参考 `observer-*` 的密度和层次。 +2. 桌面布局: + - 页面居中窄版,不做宽屏 dashboard。 + - 左侧候选约 44%,右侧详情约 56%。 + - 两列顶部对齐,视觉底部尽量对齐。 + - 右侧详情内容短时紧凑,内容长时内部滚动。 +3. 总览卡: + - 一行 5 张,窄屏自动换行。 + - 紫色用于总览或重点。 + - 绿色用于 ok / 可进入。 + - 橙色用于 partial / 待确认 / 需补证。 + - 红色只用于 failed 或主结论边界。 +4. chips 横向可滚动,不能撑破容器。 +5. 移动端单列顺序固定: + - Hero + - 筛选 + - 总览 + - 候选列表 + - 详情 + - 平台状态 +6. 长项目名: + - 候选卡单行省略。 + - 详情标题可换行。 +7. 禁止视觉回归: + - 按钮出界 + - 证据卡出界 + - 底部状态条右边缘超出主内容宽度 + - 文本重叠 + - 详情区因固定大高度产生大量空白 + +### Phase 6:回归验证与文档账本 + +1. 运行 AgentReach UI 单测: + - `corepack pnpm test -- visualConsoleAgentReachViewModel.test.ts` + - `corepack pnpm test -- visualConsoleAgentReachRoute.test.ts` + - `corepack pnpm test -- visualConsoleAgentReachPresentation.test.ts` +2. 运行相关 visual-console 回归: + - `corepack pnpm test -- visualConsole` + - 若仓库已有更细的 web/route 单测,补跑对应测试文件;不得把 `visual-console:web` 当作自动测试命令。 +3. 运行类型检查: + - `corepack pnpm typecheck` +4. 运行 implementation preflight: + - `corepack pnpm run code-implementation:preflight -- --check` +5. 人工浏览验证: + - 启动本地服务:`corepack pnpm run visual-console:web`。 + - `/agentreach?lang=zh&theme=light&date=latest` + - 中文导航显示“外部发现”,位于“新兴潜力项目”右侧。 + - 左侧候选点击后右侧详情切换。 + - failed / partial / zero_relevant_results / not_configured 文案不混淆。 + - 页面无 `证据强度`。 + - 主指标标题无 `score / sources / mentions`。 + - 没有 fake `加入观察` 写动作。 + - 浏览检查完成后停止本地服务。 +6. 如果需要视觉截图,只能作为人工验收辅助,不能替代 deterministic fixture 测试。 + +## 验收标准 + +### 验收 1:`/agentreach` 独立页面可打开 + +- 可观测结果:`/agentreach?lang=zh&theme=light&date=latest` 正常打开。 +- 成功条件:页面显示 Hero、筛选与状态区、总览卡片、候选列表、详情面板和底部平台状态。 +- 失败条件:页面依赖 mock-only 数据、空白报错,或跳回其他页面。 + +### 验收 2:导航文案和位置正确 + +- 可观测结果:中文导航中出现“外部发现”。 +- 成功条件:“外部发现”位于“新兴潜力项目”右侧,且保留 `lang/theme/date` 参数。 +- 失败条件:中文环境仍显示 `AgentReach`,或导航位置不符合设计。 + +### 验收 3:真实字段映射正确 + +- 可观测结果:候选卡和详情来自 `DailyExternalAggregate`。 +- 成功条件:来源由 `evidence_ids -> ExternalEvidence.platforms` 反查,证据来源由 `event_ids -> ExternalEvidenceSource` 反查。 +- 失败条件:读取不存在的 `candidate.platforms`,导致来源为 0 或 undefined。 + +### 验收 4:候选点击与详情联动 + +- 可观测结果:点击不同候选,右侧详情内容变化。 +- 成功条件:选中态、分页、筛选后的选中修正均可用。 +- 失败条件:右侧详情固定不变、显示幽灵详情,或分页后详情指向不可见候选。 + +### 验收 5:状态与降级语义不混淆 + +- 可观测结果:`ok / partial / failed / empty` fixture 都有合理 UI。 +- 成功条件:partial 不写成无信号,failed 不渲染为空列表,zero relevant results 不混成 failed / not configured。 +- 失败条件:外部层异常被写成“暂无讨论”或页面直接空掉。 + +### 验收 6:讨论来源画像和证据来源符合后端能力 + +- 可观测结果:详情展示 actor 类型、actor tier、独立来源、source cards 和 metrics。 +- 成功条件:不承诺具体账号、handle、profile URL 或机构名单命中实体;不展示不存在的 `points`。 +- 失败条件:UI 展示后端没有的字段,或误把 source 时间字段和 evidence 时间字段混用。 + +### 验收 7:secondary-only 边界清楚 + +- 可观测结果:页面显式展示“外部信号只作为次级判断,不单独形成主结论”。 +- 成功条件:`cannot_be_primary_conclusion` 可见,`待接入观察` 不提交数据。 +- 失败条件:外部关注度被写成主榜分数、主推荐、已确认趋势或可提交加入观察。 + +### 验收 8:视觉与响应式达标 + +- 可观测结果:页面接近 Observer / 参考图风格。 +- 成功条件:浅蓝背景、白色卡片、紫色强调、橙/绿状态 chip、窄版居中、左候选右详情、移动端单列。 +- 失败条件:宽屏 dashboard、营销页、自由搜索主入口、按钮/文本/证据卡出界或底部状态条越界。 + +## 关键负例 + +1. 候选来源平台显示为 0,因为实现直接读取了不存在的 `candidate.platforms`。 +2. 点击左侧候选后,右侧详情不变化。 +3. `partial` 被写成“没有外部讨论”。 +4. `failed` 被渲染成“今天暂无可展示的外部候选”。 +5. `zero_relevant_results` 与 `failed / not_configured` 文案或颜色混淆。 +6. 页面出现自由搜索框或触发实时平台查询。 +7. 页面出现“证据强度”。 +8. 主指标标题出现 `score / sources / mentions`。 +9. source card 展示后端不存在的 `points`。 +10. “加入观察”按钮可提交或调用 Observer tracking endpoint。 +11. AgentReach 页面样式污染 Observer 页面,导致 Observer 展示回归。 +12. 为通过测试而使用 mock-only 字段,不消费真实 aggregate。 + +## 验证矩阵 + +| 文件位置或类型 | 验证内容 | 验证方式或命令 | 对应设计 | 通过标准 | +| --- | --- | --- | --- | --- | +| `src/__tests__/visualConsoleAgentReachFixtures.ts` | 4 类 deterministic fixture | 单测引用 | `Test & Review Plan` | ok / partial / failed / empty 均可构建,不含 raw/private 字段 | +| `src/visualConsole/readLayer.ts` | external aggregate reader、路径与 ReadResult 语义 | `corepack pnpm test -- visualConsoleAgentReachViewModel.test.ts` | `Data Contract / Field Mapping` | 使用 `externalAggregatePath(date)`,缺失/解析失败可降级,不在 view-model 手写路径 | +| `src/visualConsole/agentReachViewModel.ts` | view-model 字段映射、来源反查、独立来源口径 | `corepack pnpm test -- visualConsoleAgentReachViewModel.test.ts` | `Data Contract / Field Mapping` | 不读不存在字段,不补假 source,不改 canonical schema | +| `app/visualConsole/ossTypes.ts`、`copy.ts`、`ossCopy.ts` | route union 与导航文案 | `corepack pnpm test -- visualConsoleAgentReachRoute.test.ts` | `Top Nav` | `agentreach` 是合法 route,中文文案固定为“外部发现” | +| `app/visualConsole/ossRouting.ts`、`ossDocument.ts` | `/agentreach` 路由与导航位置 | `corepack pnpm test -- visualConsoleAgentReachRoute.test.ts` | `Top Nav` | 中文“外部发现”在“新兴潜力项目”右侧,href 保参 | +| `app/client/AgentReachView.tsx` | 候选列表、详情联动、筛选、排序、分页 | `corepack pnpm test -- visualConsoleAgentReachPresentation.test.ts` | `Component Design / Interaction Rules` | 点击候选切换详情,筛选不触发网络请求,分页可用 | +| `app/client/AgentReachView.tsx` | 状态表达、empty / failed UX | `corepack pnpm test -- visualConsoleAgentReachPresentation.test.ts` | `States & Empty / Failed UX` | partial / failed / empty / zero relevant results 文案不混淆 | +| `app/styles.css` | 页面视觉、布局和响应式 | web/visual-console 相关测试 + 人工浏览 | `Visual Acceptance Criteria` | 无出界、无重叠、移动端单列,风格接近 Observer | +| 全仓 | 类型与回归 | `corepack pnpm typecheck`、相关 `visualConsole` 测试 | 全设计 | 类型通过,已有 Observer / 产品生态页面不回归 | +| implementation preflight | 代码实现前置门禁 | `corepack pnpm run code-implementation:preflight -- --check` | 项目实现规范 | preflight 通过 | + +## 回滚策略 + +1. 若 `/agentreach` route 接入导致其他页面回归,先从导航隐藏该 route,但保留 view-model 与测试,等待路由修补。 +2. 若 React 页面交互不稳定,保留 `/agentreach` 只读 SSR fallback,但不得退回 mock-only 字段。 +3. 若证据来源反查存在边界错误,先隐藏 source cards,保留 evidence 聚合与 warning,不得生成假 source。 +4. 若视觉样式污染 Observer,回退 `agentreach-*` 样式并重新隔离 class,禁止修改 Observer 语义字段来适配 AgentReach。 +5. 任意回滚不得修改后端 external discovery schema、主评分或 daily / weekly 主链路。 + +## 当前残余风险 + +1. daily section fallback 缺少完整 source 明细,必须用 warning 降级展示,不能伪造来源卡。 +2. `ExternalEvidence` 与 `ExternalEvidenceSource` 字段相近,实现时容易误用 `first_seen_at / last_seen_at` 到 source card。 +3. 旧 UI WIP 可能仍在工作区或历史分支里,代码实现必须以本设计和本计划为准。 +4. 视觉接近参考图仍需人工浏览确认,单测只能守住结构、文案和交互语义。 +5. 真实 AgentReach 结果数量和平台状态会漂移,回归测试必须依赖 deterministic fixture。 + +## 验证记录 + +| 日期 | 验证项 | 结果 | 说明 | +| --- | --- | --- | --- | +| 2026-06-29 | 设计文档再次审核 | 通过,可进入 exec-plan | 已确认输入源优先级、来源反查、状态 chips、趋势降级、fixture 要求和 secondary-only 边界 | +| 2026-06-29 | ExecPlan 初稿落盘 | 待验证 | 本文件生成后需进入 ExecPlan Review Skill 审核 | +| 2026-06-29 | ExecPlan 审核修补 | 待复审 | 已补 route union/copy/readLayer 接入点,修正 `visual-console:web` 验证措辞,并增加“已落地内容”账本位 | + +## 结论记录 + +1. 本计划只做 AgentReach 外部发现页面 UI,不扩展后端能力,也不影响产品生态开发分支。 +2. 实现必须从真实 `DailyExternalAggregate` 出发,旧 mock-only UI 只能作为反例,不作为承接基础。 +3. AgentReach 页面可以复用 Observer 的交互和视觉模式,但字段语义必须独立适配。 +4. 若实现阶段发现需要真实“加入观察”、多日趋势图、具体账号/机构展示或 AgentReach live search,必须另开设计与 exec-plan。 + +## 下一阶段入口 + +1. 使用 `docs/specs/exec-plans/ExecPlan_ReviewSkill.md` 审核本计划。 +2. 根据审核结果修补 exec-plan。 +3. 审核通过后,再按 `Phase 0 -> Phase 6` 顺序实施代码。 +4. 代码实施期间继续保持 AgentReach UI worktree 与产品生态分支隔离。 diff --git a/src/__tests__/visualConsoleAgentReachFixtures.ts b/src/__tests__/visualConsoleAgentReachFixtures.ts new file mode 100644 index 0000000..187fcb0 --- /dev/null +++ b/src/__tests__/visualConsoleAgentReachFixtures.ts @@ -0,0 +1,362 @@ +import type { DailyExternalAggregate, ExternalCandidateExplanationArtifact } from "../externalDiscovery/types.ts"; + +const TARGET_DIAGNOSTICS = { + event_count: 0, + project_event_count: 0, + paper_event_count: 0, + product_event_count: 0, + topic_event_count: 0, + events_with_target_url_count: 0, + events_with_repo_url_count: 0, + events_with_paper_url_count: 0, + candidates_with_target_url_count: 0, + title_only_project_rejected_count: 0, + official_query_name_rejected_count: 0, + unbound_target_event_count: 0, +}; + +function baseAggregate(overrides: Partial = {}): DailyExternalAggregate { + return { + schema_version: "external-discovery.aggregate.v1", + date: "2026-06-29", + generated_at: "2026-06-29T08:00:00.000Z", + provider: "agent-reach", + provider_run_id: "fixture-run", + status: "ok", + status_reason: "fixture ok", + source_input_ref: "fixture", + source_input_hash: "fixture-hash", + public_safe: true, + redaction_policy_version: "external-redaction.v1", + contains_raw_text: false, + contains_profile_urls: false, + event_count: 3, + accepted_event_count: 3, + rejected_event_count: 0, + platform_counts: { + x_twitter: 1, + reddit: 1, + official_blog: 1, + }, + derived_signal_kind_counts: { + discovery: 2, + evidence: 1, + }, + direction_label_counts: { + "workflow-automation-agent": 2, + }, + evidence_sources: [], + project_evidence: [], + direction_evidence: [], + observation_candidates: [], + audit: { + coverage: { + x_twitter: { status: "ok", reason: "fixture ok" }, + reddit: { status: "ok", reason: "fixture ok" }, + hacker_news: { status: "zero_relevant_results", reason: "executed_without_relevant_results" }, + official_web: { status: "not_configured", reason: "not_configured" }, + official_blog: { status: "ok", reason: "fixture ok" }, + }, + target_extraction_diagnostics: { ...TARGET_DIAGNOSTICS, event_count: 3, project_event_count: 2, topic_event_count: 1 }, + rejected_events: [], + warnings: [], + }, + ...overrides, + }; +} + +export function makeOkAgentReachAggregate(): DailyExternalAggregate { + return baseAggregate({ + evidence_sources: [ + { + source_id: "src-x", + event_id: "event-x", + platform: "x_twitter", + raw_event_kind: "mention", + derived_signal_kinds: ["discovery"], + direction_labels: ["workflow-automation-agent"], + source_url: "https://x.com/acme/status/1800000000000000000", + source_title: "AgentFlow launch discussion", + source_published_at: "2026-06-29T06:00:00.000Z", + observed_at: "2026-06-29T07:00:00.000Z", + target_type: "project", + target_name: "AgentFlow", + target_url: "https://github.com/acme/agentflow", + actor_type: "team", + actor_tier: "proven", + metrics: { + likes: 18, + reposts: 4, + comments: 6, + }, + }, + { + source_id: "src-reddit", + event_id: "event-reddit", + platform: "reddit", + raw_event_kind: "discussion", + derived_signal_kinds: ["evidence"], + direction_labels: ["workflow-automation-agent"], + source_url: "https://www.reddit.com/r/LocalLLaMA/comments/abc/teams_comparing_agentflow/", + source_title: "Teams comparing AgentFlow for workflow automation", + source_published_at: "2026-06-29T05:30:00.000Z", + observed_at: "2026-06-29T07:05:00.000Z", + target_type: "project", + target_name: "AgentFlow", + target_url: "https://github.com/acme/agentflow", + actor_type: "community", + actor_tier: "watch", + metrics: { + upvotes: 42, + comments: 11, + replies: 3, + }, + }, + { + source_id: "src-official", + event_id: "event-official", + platform: "official_blog", + raw_event_kind: "official_release", + derived_signal_kinds: ["discovery"], + direction_labels: ["office-agent"], + source_url: "https://example.com/agentdesk-release", + source_title: "AgentDesk official release", + source_published_at: "2026-06-29T04:00:00.000Z", + observed_at: "2026-06-29T07:10:00.000Z", + target_type: "product", + target_name: "AgentDesk", + target_url: "https://example.com/agentdesk", + actor_type: "team", + actor_tier: "core", + metrics: { + likes: 9, + }, + }, + ], + project_evidence: [ + { + evidence_id: "ev-agentflow", + event_ids: ["event-x", "event-reddit"], + scope: "project", + target_key: "https://github.com/acme/agentflow", + derived_signal_kinds: ["discovery", "evidence"], + direction_labels: ["workflow-automation-agent"], + platforms: ["x_twitter", "reddit"], + named_registry_actors: [], + actor_tiers: { proven: 1, watch: 2 }, + actor_types: { team: 1, community: 2 }, + mention_count: 4, + distinct_actor_count: 3, + top_tier_actor_count: 1, + first_seen_at: "2026-06-29T05:30:00.000Z", + last_seen_at: "2026-06-29T07:05:00.000Z", + active_day_count: 1, + cross_platform: true, + authority_summary_cn: "团队和社区都在讨论。", + intensity_summary_cn: "短时间内出现多条讨论。", + persistence_summary_cn: "当前仍是单日快照。", + caveats: ["仍需 GitHub / Trendshift 主链路确认。"], + }, + { + evidence_id: "ev-agentdesk", + event_ids: ["event-official"], + scope: "project", + target_key: "https://example.com/agentdesk", + derived_signal_kinds: ["discovery"], + direction_labels: ["office-agent"], + platforms: ["official_blog"], + named_registry_actors: [], + actor_tiers: { core: 1 }, + actor_types: { team: 1 }, + mention_count: 1, + distinct_actor_count: 1, + top_tier_actor_count: 1, + first_seen_at: "2026-06-29T04:00:00.000Z", + last_seen_at: "2026-06-29T07:10:00.000Z", + active_day_count: 1, + cross_platform: false, + authority_summary_cn: "官方博客发布。", + intensity_summary_cn: "官方发布信号。", + persistence_summary_cn: "当前仍是单日快照。", + caveats: ["官方发布不等于主榜结论。"], + }, + ], + direction_evidence: [ + { + evidence_id: "ev-workflow-direction", + event_ids: ["event-x", "event-reddit"], + scope: "direction", + target_key: "workflow-automation-agent", + derived_signal_kinds: ["discovery"], + direction_labels: ["workflow-automation-agent"], + platforms: ["x_twitter", "reddit"], + named_registry_actors: [], + actor_tiers: { proven: 1, watch: 2 }, + actor_types: { team: 1, community: 2 }, + mention_count: 4, + distinct_actor_count: 3, + top_tier_actor_count: 1, + first_seen_at: "2026-06-29T05:30:00.000Z", + last_seen_at: "2026-06-29T07:05:00.000Z", + active_day_count: 1, + cross_platform: true, + authority_summary_cn: "工作流自动化方向有外部讨论。", + intensity_summary_cn: "方向线索来自多平台。", + persistence_summary_cn: "只有单日数据。", + caveats: ["不能画成多日趋势。"], + }, + ], + observation_candidates: [ + { + candidate_id: "cand-agentflow", + scope: "project", + candidate_kind: "new_external_discovery", + target_type: "project", + target_key: "https://github.com/acme/agentflow", + display_name: "AgentFlow", + target_url: "https://github.com/acme/agentflow", + repo_url: "https://github.com/acme/agentflow", + direction_labels: ["workflow-automation-agent"], + binding_confidence: "high", + evidence_ids: ["ev-agentflow"], + evidence_summary_cn: "X 与 Reddit 同时出现 AgentFlow 讨论。", + qualification: "needs_primary_confirmation", + can_enter_daily: true, + can_enter_weekly: false, + cannot_be_primary_conclusion: true, + caveats: ["外部信号只能作为次级判断。"], + external_attention_score: 72, + display_bucket: "new_discoveries", + display_reasons: ["跨平台讨论", "仍需主源确认"], + }, + { + candidate_id: "cand-agentdesk", + scope: "project", + candidate_kind: "external_evidence_boost", + target_type: "product", + target_key: "https://example.com/agentdesk", + display_name: "AgentDesk", + target_url: "https://example.com/agentdesk", + direction_labels: ["office-agent"], + binding_confidence: "medium", + evidence_ids: ["ev-agentdesk"], + evidence_summary_cn: "官方博客出现 AgentDesk 发布。", + qualification: "supporting_evidence_only", + can_enter_daily: false, + can_enter_weekly: true, + cannot_be_primary_conclusion: true, + caveats: ["官方信号仍需与主链路分离。"], + external_attention_score: 49, + display_bucket: "official_releases", + display_reasons: ["官方发布"], + }, + ], + }); +} + +export function makeAgentReachCandidateExplanationsArtifact( + overrides: Partial = {}, +): ExternalCandidateExplanationArtifact { + return { + schema_version: "external-discovery.candidate-explanations.v1", + date: "2026-06-29", + generated_at: "2026-06-29T08:10:00.000Z", + provider: "deepseek", + explanation_policy_version: "candidate-explanations.v1", + aggregate_source_input_hash: "fixture-hash", + aggregate_generated_at: "2026-06-29T08:00:00.000Z", + input_context_hash: "fixture-context-hash", + public_safe: true, + redaction_policy_version: "external-redaction.v1", + contains_raw_text: false, + contains_profile_urls: false, + status: "ok", + explanations: [ + { + candidate_key: "project:https://github.com/acme/agentflow", + candidate_kind: "project", + target_key: "https://github.com/acme/agentflow", + explanation_scope: "bound_object", + what_it_is_cn: "AgentFlow 是一个已绑定 GitHub repo 的工作流自动化项目候选。", + why_watch_cn: "X 和 Reddit 同时讨论 AgentFlow,适合作为外部层新发现继续观察。", + summary_confidence: "medium", + summary_source: "llm", + evidence_ids: ["ev-agentflow"], + platforms: ["x_twitter", "reddit"], + caveats: ["仍需 GitHub 主链路确认活跃度。"], + generated_at: "2026-06-29T08:10:00.000Z", + }, + ], + audit: { + eligible_count: 1, + attempted_count: 1, + accepted_count: 1, + enhanced_count: 1, + rejected_count: 0, + fallback_count: 0, + warnings: [], + }, + ...overrides, + }; +} + +export function makePartialAgentReachAggregate(): DailyExternalAggregate { + return baseAggregate({ + status: "partial", + status_reason: "x_twitter timeout; reddit ok", + audit: { + ...baseAggregate().audit, + coverage: { + x_twitter: { status: "partial", reason: "timeout", warnings: ["runner_timeout"] }, + reddit: { status: "ok", reason: "ok" }, + hacker_news: { status: "zero_relevant_results", reason: "executed_without_relevant_results" }, + }, + warnings: [{ reason_code: "x_twitter_partial", reason_detail: "x_twitter partial" }], + }, + }); +} + +export function makeFailedAgentReachAggregate(): DailyExternalAggregate { + return baseAggregate({ + status: "failed", + status_reason: "provider failed before aggregation", + event_count: 0, + accepted_event_count: 0, + rejected_event_count: 0, + platform_counts: {}, + evidence_sources: [], + project_evidence: [], + direction_evidence: [], + observation_candidates: [], + audit: { + ...baseAggregate().audit, + coverage: { + x_twitter: { status: "failed", reason: "runner_failed" }, + reddit: { status: "failed", reason: "runner_failed" }, + }, + warnings: [{ reason_code: "provider_failed", reason_detail: "provider_failed" }], + }, + }); +} + +export function makeEmptyAgentReachAggregate(): DailyExternalAggregate { + return baseAggregate({ + status: "ok", + status_reason: "executed without candidates", + event_count: 0, + accepted_event_count: 0, + rejected_event_count: 0, + platform_counts: {}, + evidence_sources: [], + project_evidence: [], + direction_evidence: [], + observation_candidates: [], + audit: { + ...baseAggregate().audit, + coverage: { + x_twitter: { status: "zero_relevant_results", reason: "executed_without_relevant_results" }, + reddit: { status: "ok", reason: "ok" }, + }, + }, + }); +} diff --git a/src/__tests__/visualConsoleAgentReachPresentation.test.ts b/src/__tests__/visualConsoleAgentReachPresentation.test.ts new file mode 100644 index 0000000..b9fa3d8 --- /dev/null +++ b/src/__tests__/visualConsoleAgentReachPresentation.test.ts @@ -0,0 +1,134 @@ +import React from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vitest"; +import AgentReachView from "../../app/client/AgentReachView.tsx"; +import { buildAgentReachDisplayModel } from "../visualConsole/agentReachViewModel.ts"; +import type { AgentReachViewProps } from "../../app/client/AgentReachView.tsx"; +import type { ExternalCandidateExplanationArtifact } from "../externalDiscovery/types.ts"; +import { + makeAgentReachCandidateExplanationsArtifact, + makeFailedAgentReachAggregate, + makeOkAgentReachAggregate, +} from "./visualConsoleAgentReachFixtures.ts"; + +function propsFromAggregate( + aggregate = makeOkAgentReachAggregate(), + candidateExplanations?: ExternalCandidateExplanationArtifact | null, +): AgentReachViewProps { + const display = buildAgentReachDisplayModel({ + aggregate, + candidateExplanations, + aggregateReadStatus: "ok", + candidateExplanationsReadStatus: candidateExplanations ? "ok" : "not_found", + }); + return { + lang: "zh", + initialTheme: "light", + initialSelectedId: display.candidates[0]?.candidate_id ?? null, + status: display.status, + summary_cards: display.summary_cards, + filters: display.filters, + candidates: display.candidates, + coverage: display.coverage, + direction_snapshot: display.direction_snapshot, + warnings: display.warnings, + state: { + status: aggregate.status === "failed" ? "failed" : display.candidates.length > 0 ? "ready" : "empty", + reasons: [], + }, + banner: { + title: "AgentReach", + context_label: aggregate.date, + generated_at: aggregate.generated_at, + enhancement_status: "unknown", + mode_label: "unknown", + github_enrichment_status: "external-discovery", + source_health: display.source_health, + notes: display.warnings, + }, + }; +} + +describe("AgentReach React presentation", () => { + it("renders Chinese metric labels, external attention wording, and read-only observer note", () => { + const html = renderToStaticMarkup(React.createElement(AgentReachView, propsFromAggregate())); + + expect(html).toContain("外部发现"); + expect(html).toContain("外部关注度"); + expect(html).toContain("关注度"); + expect(html).toContain("证据"); + expect(html).toContain("来源"); + expect(html).toContain("独立来源"); + expect(html).toContain("谁在讨论"); + expect(html).toContain("它是什么"); + expect(html).toContain("为什么值得看"); + expect(html).toContain("还需要确认什么"); + expect(html).toContain("外部讨论观察"); + expect(html).toContain("平台与数据状态"); + expect(html).toContain("X / Reddit 出现该对象讨论"); + expect(html).toContain("外部关注度 72"); + expect(html).toContain("公开来源"); + expect(html).toContain("@acme"); + expect(html).toContain("r/LocalLLaMA"); + expect(html).toContain("agentreach-action-note"); + expect(html).not.toContain("is-disabled"); + expect(html).toContain("外部信号只作为次级判断"); + expect(html).not.toContain("证据强度"); + expect(html).not.toContain("加入观察"); + expect(html).not.toContain("unknown 1"); + }); + + it("localizes AgentReach dynamic view-model labels in English mode", () => { + const props = propsFromAggregate(makeOkAgentReachAggregate(), makeAgentReachCandidateExplanationsArtifact()); + props.lang = "en"; + const html = renderToStaticMarkup(React.createElement(AgentReachView, props)); + + expect(html).toContain("New Candidates"); + expect(html).toContain("New Discovery"); + expect(html).toContain("Attention"); + expect(html).toContain("Needs Primary Confirmation"); + expect(html).toContain("Project · Project level"); + expect(html).toContain("Discussion"); + expect(html).toContain("Community / Watch"); + expect(html).toContain("Reddit OK"); + expect(html).toContain("What It Is"); + expect(html).toContain("Why Watch"); + expect(html).toContain("What Still Needs Confirmation"); + expect(html).toContain("External Discussion Watch"); + expect(html).toContain("Platform & Data Status"); + expect(html).toContain("External attention 72"); + expect(html).toContain("X / Reddit surfaced discussion around this object"); + expect(html).not.toContain("新发现"); + expect(html).not.toContain("方向观察"); + expect(html).not.toContain("官方信号"); + expect(html).not.toContain("待确认"); + expect(html).not.toContain("未识别"); + expect(html).not.toContain("为什么值得看"); + expect(html).not.toContain("还需要确认什么"); + expect(html).not.toContain("AgentFlow 是一个已绑定 GitHub repo 的工作流自动化项目候选"); + expect(html).not.toContain("X 和 Reddit 同时讨论 AgentFlow"); + expect(html).not.toContain("外部信号只能作为次级判断"); + expect(html).not.toContain("仍需主源确认"); + expect(html).not.toContain("当前仍是单日快照"); + }); + + it("renders backend candidate explanations on the Chinese page", () => { + const html = renderToStaticMarkup( + React.createElement(AgentReachView, propsFromAggregate(makeOkAgentReachAggregate(), makeAgentReachCandidateExplanationsArtifact())), + ); + + expect(html).toContain("AgentFlow 是一个已绑定 GitHub repo 的工作流自动化项目候选。"); + expect(html).toContain("X 和 Reddit 同时讨论 AgentFlow,适合作为外部层新发现继续观察。"); + expect(html).toContain("仍需 GitHub 主链路确认活跃度。"); + expect(html).not.toContain("证据强度"); + expect(html).not.toContain("高置信推荐"); + }); + + it("does not render failed provider as an empty no-signal state", () => { + const html = renderToStaticMarkup(React.createElement(AgentReachView, propsFromAggregate(makeFailedAgentReachAggregate()))); + + expect(html).toContain("外部层执行失败"); + expect(html).toContain("provider failed before aggregation"); + expect(html).not.toContain("今天暂无可展示的外部候选"); + }); +}); diff --git a/src/__tests__/visualConsoleAgentReachRoute.test.ts b/src/__tests__/visualConsoleAgentReachRoute.test.ts new file mode 100644 index 0000000..b168285 --- /dev/null +++ b/src/__tests__/visualConsoleAgentReachRoute.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from "vitest"; +import { renderClientScriptSource } from "../../app/visualConsole/clientScript.ts"; +import { renderDocument } from "../../app/visualConsole/ossDocument.ts"; +import { renderPrimary } from "../../app/visualConsole/ossPages.ts"; +import { buildRoute, normalizeRoutePath, routeTitle, toViewHref } from "../../app/visualConsole/ossRouting.ts"; + +describe("AgentReach visual-console route", () => { + it("registers /agentreach as an independent route with Chinese nav copy", () => { + expect(normalizeRoutePath("/agentreach")).toBe("agentreach"); + expect(routeTitle("agentreach", "zh")).toBe("外部发现"); + expect(toViewHref("agentreach", "zh", "light", { date: "2026-06-29", source_view: "observer" })).toBe( + "/agentreach?lang=zh&theme=light&date=2026-06-29&source_view=observer", + ); + }); + + it("places 外部发现 immediately after 新兴潜力项目 in the shell nav", () => { + const requestUrl = new URL("http://localhost/agentreach?lang=zh&theme=light&date=2026-06-29"); + const rendered = buildRoute("agentreach", requestUrl); + const html = renderDocument(rendered, requestUrl, "zh", "light", renderPrimary(rendered, requestUrl, "zh", "light")); + const navStart = html.indexOf('