From 762fbe2d55c03f127927bab72bf6b9ff9a20bf4d Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Fri, 28 Aug 2026 00:56:01 +0200 Subject: [PATCH] =?UTF-8?q?feat(ui):=20the=20Who-can-help=20card=20?= =?UTF-8?q?=E2=80=94=20contacts=20with=20evidence=20on=20every=20symbol?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Names as plain text with their evidence line (commits to this file this year, repo-wide breadth, last touched when the retained commits prove it), honest empty and no-history states, en+zh from birth. The question index stops claiming the view is planned: F13's remaining gap is the region-level rollup — and, as ever, never a leaderboard. Signed-off-by: Martin Vogel --- graph-ui/src/components/SymbolTab.tsx | 74 +++++++++++++++++++++++++++ graph-ui/src/lib/i18n.ts | 30 +++++++++++ graph-ui/src/lib/questions.ts | 8 +-- graph-ui/src/lib/who.test.ts | 40 +++++++++++++++ graph-ui/src/lib/who.ts | 50 ++++++++++++++++++ graph-ui/src/wiki/entries.ts | 4 +- graph-ui/tsconfig.tsbuildinfo | 2 +- 7 files changed, 201 insertions(+), 7 deletions(-) create mode 100644 graph-ui/src/lib/who.test.ts create mode 100644 graph-ui/src/lib/who.ts diff --git a/graph-ui/src/components/SymbolTab.tsx b/graph-ui/src/components/SymbolTab.tsx index 7d87996b0..77076d21b 100644 --- a/graph-ui/src/components/SymbolTab.tsx +++ b/graph-ui/src/components/SymbolTab.tsx @@ -22,6 +22,7 @@ import { type SymbolCommit, type SymbolHistoryPayload, } from "../lib/rationale"; +import { fetchWho, personEvidence, type WhoPayload } from "../lib/who"; import { AddToPromptButton } from "./PromptBasket"; import { MetricChip } from "./MetricChip"; import { TriggerTree } from "./TriggerTree"; @@ -215,6 +216,8 @@ export function SymbolTab({ const [history, setHistory] = useState(null); const [historyLoading, setHistoryLoading] = useState(false); const [historyError, setHistoryError] = useState(null); + const [who, setWho] = useState(null); + const [whoError, setWhoError] = useState(null); useEffect(() => { let cancelled = false; @@ -264,6 +267,25 @@ export function SymbolTab({ }; }, [project, symbolRef.id, symbolRef.qn]); + /* Who can help needs the file path, so it waits for the bundle. */ + const filePath = bundle?.node.file_path; + useEffect(() => { + let cancelled = false; + setWho(null); + setWhoError(null); + if (!filePath) return; + fetchWho(project, filePath) + .then((payload) => { + if (!cancelled) setWho(payload); + }) + .catch((err) => { + if (!cancelled) setWhoError(err instanceof Error ? err.message : "failed"); + }); + return () => { + cancelled = true; + }; + }, [project, filePath]); + if (error) { return (
@@ -706,6 +728,58 @@ export function SymbolTab({
)} + {/* Who can help — contacts with evidence, never a ranking: names are + * plain text (no links, no shares, no bars), each carrying only its + * evidence line. Absent last_seen is honest, so the part is dropped. */} + {node.file_path && ( +
+

+ + {t.who.heading} + +

+ {!who && !whoError && ( +

{t.who.loading}

+ )} + {whoError && ( +

+ {t.who.unavailable(whoError)} +

+ )} + {who && !who.available && ( +

{t.who.noHistory}

+ )} + {who?.available && (who.people ?? []).length === 0 && ( +

{t.who.noCommits}

+ )} + {who?.available && (who.people ?? []).length > 0 && ( + <> +
+ {(who.people ?? []).map((person) => ( +
+ + {person.name} + + + {personEvidence(person, t.who)} + +
+ ))} +
+

+ {t.who.footer( + who.people_total ?? (who.people ?? []).length, + (who.people ?? []).length, + )} +

+ + )} +
+ )} +

diff --git a/graph-ui/src/lib/i18n.ts b/graph-ui/src/lib/i18n.ts index 781f06b05..8872f961f 100644 --- a/graph-ui/src/lib/i18n.ts +++ b/graph-ui/src/lib/i18n.ts @@ -115,6 +115,22 @@ export const messages = { footnote: "History follows this symbol's line range through renames (git log -L). PR and issue links open the forge — titles are not fetched.", }, + who: { + heading: "Who can help", + loading: "Reading authorship…", + unavailable: (reason: string) => `Authorship unavailable: ${reason}`, + noHistory: "No git history is readable for this project.", + noCommits: "No recorded commits touch this file in the window (1y).", + commitsHere: (n: number) => + `${n.toLocaleString("en-US")} commit${n === 1 ? "" : "s"} to this file this year`, + breadth: (n: number) => + `active in ${n.toLocaleString("en-US")} file${n === 1 ? "" : "s"} repo-wide`, + lastTouched: (date: string) => `last touched ${date}`, + footer: (total: number, shown: number) => + total > shown + ? `${total.toLocaleString("en-US")} people have recorded history here — showing ${shown} of ${total.toLocaleString("en-US")}` + : `${total.toLocaleString("en-US")} ${total === 1 ? "person has" : "people have"} recorded history here`, + }, observed: { word: "observed", allObserved: "This whole path was observed in recorded runs.", @@ -247,6 +263,20 @@ export const messages = { footnote: "历史沿这个符号自身的行范围追踪,可跨重命名(git log -L)。PR 与 issue 链接指向代码托管平台——标题不会被抓取。", }, + who: { + heading: "可求助的人", + loading: "正在读取作者记录…", + unavailable: (reason: string) => `作者记录不可用:${reason}`, + noHistory: "这个项目没有可读的 git 历史。", + noCommits: "窗口期(1 年)内没有提交记录触及这个文件。", + commitsHere: (n: number) => `今年对此文件提交 ${n.toLocaleString("en-US")} 次`, + breadth: (n: number) => `活跃于全仓 ${n.toLocaleString("en-US")} 个文件`, + lastTouched: (date: string) => `最近改动 ${date}`, + footer: (total: number, shown: number) => + total > shown + ? `共 ${total.toLocaleString("en-US")} 人在这里留有提交记录——显示其中 ${shown} 位` + : `共 ${total.toLocaleString("en-US")} 人在这里留有提交记录`, + }, observed: { word: "已观测", allObserved: "这条路径整体都在录制的运行中被观测到。", diff --git a/graph-ui/src/lib/questions.ts b/graph-ui/src/lib/questions.ts index f642cac0a..ff4632033 100644 --- a/graph-ui/src/lib/questions.ts +++ b/graph-ui/src/lib/questions.ts @@ -169,12 +169,12 @@ export const QUESTION_FAMILIES: QuestionFamily[] = [ id: "F13", question: "Who knows this code — who should I ask?", status: "partial", - hint: "Symbol view: file history authors", - gap: "Top authors with evidence per file; a dedicated who-can-help view is planned — never a leaderboard", + hint: "Every symbol page: 'Who can help' — people with their evidence (commits here, breadth, last touched)", + gap: "File-level only; a region-level rollup is not built — and never a leaderboard", zh: { question: "谁了解这段代码——我该问谁?", - hint: "符号页:文件历史作者", - gap: "按文件给出带证据的主要作者;专门的“可求助的人”视图在计划中——绝不做排行榜", + hint: "每个符号页的“可求助的人”——带证据的人(此文件提交数、广度、最近改动)", + gap: "仅到文件粒度;区域级汇总未构建——且绝不做排行榜", }, }, { diff --git a/graph-ui/src/lib/who.test.ts b/graph-ui/src/lib/who.test.ts new file mode 100644 index 000000000..4dc709a4c --- /dev/null +++ b/graph-ui/src/lib/who.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "vitest"; +import { messages } from "./i18n"; +import { personEvidence, type WhoPerson } from "./who"; + +const person = (overrides: Partial = {}): WhoPerson => ({ + name: "Ada", + commits_here: 23, + files_repo_wide: 47, + last_seen: Date.UTC(2026, 7, 12) / 1000, + ...overrides, +}); + +describe("personEvidence", () => { + it("states commits, breadth and last-touched date as evidence", () => { + expect(personEvidence(person())).toBe( + "23 commits to this file this year · active in 47 files repo-wide · last touched 2026-08-12", + ); + }); + + it("drops the recency part honestly when last_seen is unproven", () => { + expect(personEvidence(person({ last_seen: undefined }))).toBe( + "23 commits to this file this year · active in 47 files repo-wide", + ); + }); + + it("keeps singular grammar for one commit in one file", () => { + expect( + personEvidence(person({ commits_here: 1, files_repo_wide: 1, last_seen: undefined })), + ).toBe("1 commit to this file this year · active in 1 file repo-wide"); + }); + + it("speaks the zh catalog with the same evidence when its messages are passed", () => { + expect(personEvidence(person(), messages.zh.who)).toBe( + "今年对此文件提交 23 次 · 活跃于全仓 47 个文件 · 最近改动 2026-08-12", + ); + expect(personEvidence(person({ last_seen: undefined }), messages.zh.who)).toBe( + "今年对此文件提交 23 次 · 活跃于全仓 47 个文件", + ); + }); +}); diff --git a/graph-ui/src/lib/who.ts b/graph-ui/src/lib/who.ts new file mode 100644 index 000000000..a39bd2556 --- /dev/null +++ b/graph-ui/src/lib/who.ts @@ -0,0 +1,50 @@ +/* Who-can-help data layer: /api/who — the people with recorded history in + * one file, as contacts with evidence: commits here, breadth repo-wide, + * last touched. Never a ranking — no shares, no "top", no comparisons; + * every number is an evidence statement. */ +import { messages, type UiMessages } from "./i18n"; +import { fetchJsonFrom } from "./whyfetch"; + +export interface WhoPerson { + name: string; + commits_here: number; + files_repo_wide: number; + /* Epoch seconds; present only when the retained newest commits prove + * it — its absence is honest, never an error. */ + last_seen?: number; +} + +export interface WhoPayload { + window: string; + /* false = no git history readable for this project. */ + available: boolean; + commits_1y?: number; + /* Sorted by commits_here desc, at most 8; may be empty (a file with no + * recorded commits in the window). */ + people?: WhoPerson[]; + /* Everyone with recorded history here — may exceed people.length. */ + people_total?: number; +} + +export async function fetchWho(project: string, file: string): Promise { + const params = new URLSearchParams({ project, file }); + const payload = await fetchJsonFrom( + `/api/who?${params}`, + ); + if (payload.error) throw new Error(payload.error); + return payload; +} + +/* The evidence line under a name: "23 commits to this file this year · + * active in 47 files repo-wide · last touched 2026-08-12". The last part + * appears only when last_seen is proven. Pass the active locale's who + * messages to localize. */ +export function personEvidence( + person: WhoPerson, + m: UiMessages["who"] = messages.en.who, +): string { + const parts = [m.commitsHere(person.commits_here), m.breadth(person.files_repo_wide)]; + if (person.last_seen !== undefined) + parts.push(m.lastTouched(new Date(person.last_seen * 1000).toISOString().slice(0, 10))); + return parts.join(" · "); +} diff --git a/graph-ui/src/wiki/entries.ts b/graph-ui/src/wiki/entries.ts index a87cb4f41..8273c729f 100644 --- a/graph-ui/src/wiki/entries.ts +++ b/graph-ui/src/wiki/entries.ts @@ -406,7 +406,7 @@ export const WIKI_ENTRIES: WikiEntry[] = [ why: "The most-sought information in twenty years of developer studies is what coworkers know. When the graph runs out of answers, the right person is the fastest path — this shows who, with the evidence.", notCovered: "Atlas never ranks people against each other and never shows per-person productivity. Names appear only with their evidence: commits, recency, breadth.", - appearsIn: ["Symbol file history"], + appearsIn: ["Symbol — Who can help", "Symbol file history"], zh: { gloss: "可求助的人", sentence: @@ -414,7 +414,7 @@ export const WIKI_ENTRIES: WikiEntry[] = [ why: "二十年的开发者研究里,被寻求最多的信息是同事知道什么。当图谱给不出答案时,找对人是最快的路——这里展示是谁,并附上证据。", notCovered: "Atlas 从不把人相互排名,也从不展示个人生产力。名字只与证据一同出现:提交、新近度、广度。", - appearsIn: ["符号页文件历史"], + appearsIn: ["符号页“可求助的人”", "符号页文件历史"], }, }, { diff --git a/graph-ui/tsconfig.tsbuildinfo b/graph-ui/tsconfig.tsbuildinfo index 2815dcb9e..468599b00 100644 --- a/graph-ui/tsconfig.tsbuildinfo +++ b/graph-ui/tsconfig.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/rpc.ts","./src/components/changestab.tsx","./src/components/commandk.tsx","./src/components/controltab.tsx","./src/components/dashboardtab.tsx","./src/components/displaysettingsmenu.tsx","./src/components/edgelines.tsx","./src/components/errorboundary.tsx","./src/components/filterpanel.tsx","./src/components/flowstab.observed.test.tsx","./src/components/flowstab.tsx","./src/components/graphloader.tsx","./src/components/graphscene.test.ts","./src/components/graphscene.tsx","./src/components/graphtab.deadcode.test.tsx","./src/components/graphtab.filters.test.tsx","./src/components/graphtab.regions.test.tsx","./src/components/graphtab.test.ts","./src/components/graphtab.tsx","./src/components/halolayer.tsx","./src/components/metricchip.tsx","./src/components/minimap.tsx","./src/components/missedcallout.tsx","./src/components/modulestab.tsx","./src/components/nodecloud.tsx","./src/components/nodedetailpanel.showmore.test.tsx","./src/components/nodedetailpanel.test.tsx","./src/components/nodedetailpanel.tsx","./src/components/nodelabels.tsx","./src/components/nodetooltip.tsx","./src/components/overviewtab.tsx","./src/components/projectcard.tsx","./src/components/projectswitcher.tsx","./src/components/promptbasket.tsx","./src/components/regionpanel.tsx","./src/components/regiontooltip.tsx","./src/components/resizehandle.tsx","./src/components/sidebar.tsx","./src/components/statstab.test.tsx","./src/components/statstab.tsx","./src/components/symboltab.tsx","./src/components/tabbar.tsx","./src/components/triggertree.tsx","./src/components/wikihovercard.test.tsx","./src/components/wikihovercard.tsx","./src/components/wikipanel.test.tsx","./src/components/wikipanel.tsx","./src/components/wikipanel.zh.test.tsx","./src/components/wikitier.ts","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/card.tsx","./src/components/ui/checkbox.tsx","./src/components/ui/input.tsx","./src/components/ui/scroll-area.tsx","./src/components/ui/separator.tsx","./src/hooks/usegraphdata.test.ts","./src/hooks/usegraphdata.ts","./src/hooks/useprojects.ts","./src/lib/atlas.test.ts","./src/lib/atlas.ts","./src/lib/colors.ts","./src/lib/complexity.test.ts","./src/lib/complexity.ts","./src/lib/composer.ts","./src/lib/density.test.ts","./src/lib/density.ts","./src/lib/findings.test.ts","./src/lib/findings.ts","./src/lib/firstread.ts","./src/lib/flowgroup.test.ts","./src/lib/flowgroup.ts","./src/lib/i18n.test.ts","./src/lib/i18n.ts","./src/lib/impact.test.ts","./src/lib/impact.ts","./src/lib/observed.test.ts","./src/lib/observed.ts","./src/lib/questions.test.ts","./src/lib/questions.ts","./src/lib/rationale.test.ts","./src/lib/rationale.ts","./src/lib/regions.test.ts","./src/lib/regions.ts","./src/lib/treemap.ts","./src/lib/types.ts","./src/lib/utils.ts","./src/lib/why.test.ts","./src/lib/why.ts","./src/lib/whyfetch.ts","./src/test/setup.ts","./src/wiki/entries.test.ts","./src/wiki/entries.ts"],"version":"5.9.3"} \ No newline at end of file +{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/rpc.ts","./src/components/changestab.tsx","./src/components/commandk.tsx","./src/components/controltab.tsx","./src/components/dashboardtab.tsx","./src/components/displaysettingsmenu.tsx","./src/components/edgelines.tsx","./src/components/errorboundary.tsx","./src/components/filterpanel.tsx","./src/components/flowstab.observed.test.tsx","./src/components/flowstab.tsx","./src/components/graphloader.tsx","./src/components/graphscene.test.ts","./src/components/graphscene.tsx","./src/components/graphtab.deadcode.test.tsx","./src/components/graphtab.filters.test.tsx","./src/components/graphtab.regions.test.tsx","./src/components/graphtab.test.ts","./src/components/graphtab.tsx","./src/components/halolayer.tsx","./src/components/metricchip.tsx","./src/components/minimap.tsx","./src/components/missedcallout.tsx","./src/components/modulestab.tsx","./src/components/nodecloud.tsx","./src/components/nodedetailpanel.showmore.test.tsx","./src/components/nodedetailpanel.test.tsx","./src/components/nodedetailpanel.tsx","./src/components/nodelabels.tsx","./src/components/nodetooltip.tsx","./src/components/overviewtab.tsx","./src/components/projectcard.tsx","./src/components/projectswitcher.tsx","./src/components/promptbasket.tsx","./src/components/regionpanel.tsx","./src/components/regiontooltip.tsx","./src/components/resizehandle.tsx","./src/components/sidebar.tsx","./src/components/statstab.test.tsx","./src/components/statstab.tsx","./src/components/symboltab.tsx","./src/components/tabbar.tsx","./src/components/triggertree.tsx","./src/components/wikihovercard.test.tsx","./src/components/wikihovercard.tsx","./src/components/wikipanel.test.tsx","./src/components/wikipanel.tsx","./src/components/wikipanel.zh.test.tsx","./src/components/wikitier.ts","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/card.tsx","./src/components/ui/checkbox.tsx","./src/components/ui/input.tsx","./src/components/ui/scroll-area.tsx","./src/components/ui/separator.tsx","./src/hooks/usegraphdata.test.ts","./src/hooks/usegraphdata.ts","./src/hooks/useprojects.ts","./src/lib/atlas.test.ts","./src/lib/atlas.ts","./src/lib/colors.ts","./src/lib/complexity.test.ts","./src/lib/complexity.ts","./src/lib/composer.ts","./src/lib/density.test.ts","./src/lib/density.ts","./src/lib/findings.test.ts","./src/lib/findings.ts","./src/lib/firstread.ts","./src/lib/flowgroup.test.ts","./src/lib/flowgroup.ts","./src/lib/i18n.test.ts","./src/lib/i18n.ts","./src/lib/impact.test.ts","./src/lib/impact.ts","./src/lib/observed.test.ts","./src/lib/observed.ts","./src/lib/questions.test.ts","./src/lib/questions.ts","./src/lib/rationale.test.ts","./src/lib/rationale.ts","./src/lib/regions.test.ts","./src/lib/regions.ts","./src/lib/treemap.ts","./src/lib/types.ts","./src/lib/utils.ts","./src/lib/who.test.ts","./src/lib/who.ts","./src/lib/why.test.ts","./src/lib/why.ts","./src/lib/whyfetch.ts","./src/test/setup.ts","./src/wiki/entries.test.ts","./src/wiki/entries.ts"],"version":"5.9.3"} \ No newline at end of file