feat(ui): the Who-can-help card — contacts with evidence on every symbol

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 <martin.vogel.tech@gmail.com>
This commit is contained in:
Martin Vogel
2026-08-28 00:56:01 +02:00
parent 007a46baf8
commit 762fbe2d55
7 changed files with 201 additions and 7 deletions
+74
View File
@@ -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<SymbolHistoryPayload | null>(null);
const [historyLoading, setHistoryLoading] = useState(false);
const [historyError, setHistoryError] = useState<string | null>(null);
const [who, setWho] = useState<WhoPayload | null>(null);
const [whoError, setWhoError] = useState<string | null>(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 (
<div className="flex items-center justify-center h-full">
@@ -706,6 +728,58 @@ export function SymbolTab({
</div>
)}
{/* 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 && (
<div className="bg-card border border-border/50 rounded-md p-4 mb-4">
<p className="text-[12px] uppercase tracking-widest text-foreground/40 mb-2">
<MetricChip slug="who-can-help" onOpen={onOpenWiki}>
{t.who.heading}
</MetricChip>
</p>
{!who && !whoError && (
<p className="text-[13px] text-foreground/40">{t.who.loading}</p>
)}
{whoError && (
<p className="text-[13px] text-foreground/40">
{t.who.unavailable(whoError)}
</p>
)}
{who && !who.available && (
<p className="text-[13px] text-foreground/40">{t.who.noHistory}</p>
)}
{who?.available && (who.people ?? []).length === 0 && (
<p className="text-[13px] text-foreground/40">{t.who.noCommits}</p>
)}
{who?.available && (who.people ?? []).length > 0 && (
<>
<div className="space-y-px">
{(who.people ?? []).map((person) => (
<div
key={person.name}
className="flex items-baseline gap-2 px-2 py-[3px]"
>
<span className="text-[13px] text-foreground/70 shrink-0">
{person.name}
</span>
<span className="text-[12px] text-foreground/40 truncate">
{personEvidence(person, t.who)}
</span>
</div>
))}
</div>
<p className="text-[12px] text-foreground/40 mt-2">
{t.who.footer(
who.people_total ?? (who.people ?? []).length,
(who.people ?? []).length,
)}
</p>
</>
)}
</div>
)}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
<div className="bg-card border border-border/40 rounded-md p-4">
<p className="text-[12px] uppercase tracking-widest text-foreground/45 mb-2">
+30
View File
@@ -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: "这条路径整体都在录制的运行中被观测到。",
+4 -4
View File
@@ -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: "仅到文件粒度;区域级汇总未构建——且绝不做排行榜",
},
},
{
+40
View File
@@ -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> = {}): 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 个文件",
);
});
});
+50
View File
@@ -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<WhoPayload> {
const params = new URLSearchParams({ project, file });
const payload = await fetchJsonFrom<WhoPayload & { error?: string }>(
`/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(" · ");
}
+2 -2
View File
@@ -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: ["符号页“可求助的人”", "符号页文件历史"],
},
},
{
+1 -1
View File
@@ -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"}
{"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"}