Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ export function GoalResearchHistory({goalId, active, zh, refreshRevision = 0, re
const opener = useRef<HTMLElement | null>(null);
const readerParent = useRef<{selection: Selection; nodeId: string; opener: HTMLElement | null} | null>(null);
const focusPending = useRef(Boolean(selection.nodes.length || selection.day !== null));
const focusReturn = useRef<{nodeId?: string; element: HTMLElement | null} | null>(null);
const focusReturn = useRef<{nodeId?: string; eventId?: string; element: HTMLElement | null} | null>(null);
const copy = zh ? {
title: "研究历程", loading: "正在读取完整记录", reading: "读取中", refresh: "刷新记录",
all: "回到全图", scope: "Explore 已记录历史", coverage: "其他来源覆盖、尝试发生时序尚未核验",
Expand Down Expand Up @@ -158,19 +158,6 @@ export function GoalResearchHistory({goalId, active, zh, refreshRevision = 0, re
};
const updateRecords = (events: readonly ExploreHistoryEvent[]) => <ol className="research-history-records">{events.map(event => <li key={event.event_id}><time>{timeLabel(event.recorded_at)}</time><div><p>{stateLabel(event.status)} · {event.title}</p>{event.summary ? findingBody(event) : null}</div>{event.parent_id ? <code>{copy.group}: {event.parent_id}</code> : null}{event.evidence_refs.map(ref => <code key={ref}>{ref}</code>)}{event.run_id ? <code>{event.run_id}</code> : null}</li>)}</ol>;

useEffect(() => {
const pop = () => {
readerParent.current = null;
focusReturn.current = null;
focusPending.current = true;
const next = selectedFromUrl(goalId);
setSelection(next);
setQuestionExpanded(!next.finding);
const view = viewFromUrl(goalId); setLens(view.lens); setPhase(view.phase);
};
window.addEventListener("popstate", pop);
return () => window.removeEventListener("popstate", pop);
}, [goalId]);
useEffect(() => {
if (!active) return;
let current = true;
Expand Down Expand Up @@ -250,19 +237,38 @@ export function GoalResearchHistory({goalId, active, zh, refreshRevision = 0, re
const positions = new Map(layout.map(item => [item.node.id, item]));
const selectedId = selection.nodes.length === 1 ? selection.nodes[0]! : "";
const selected = value?.nodes.find(node => node.id === selectedId);
const selectedFinding = selected?.findings.find(finding => finding.result_id === selection.finding)
?? (!selection.finding ? selected?.findings.at(-1) : undefined);
const selectedGroup = selection.day !== null ? grouped.groups.find(group => group.day === selection.day) : undefined;
const selectedMembers = selectedGroup?.nodes ?? (selection.nodes.length > 1 ? value?.nodes.filter(node => selection.nodes.includes(node.id)) : undefined);
useEffect(() => {
const pop = () => {
readerParent.current = null;
const next = selectedFromUrl(goalId);
const returning = !next.nodes.length && next.day === null && Boolean(selection.nodes.length || selection.day !== null);
focusReturn.current = returning ? {eventId: selectedFinding?.event_id, element: opener.current} : null;
focusPending.current = !returning;
setSelection(next);
setQuestionExpanded(!next.finding);
const view = viewFromUrl(goalId); setLens(view.lens); setPhase(view.phase);
};
window.addEventListener("popstate", pop);
return () => window.removeEventListener("popstate", pop);
}, [goalId, selection, selectedFinding?.event_id]);
useLayoutEffect(() => {
if (!active) return;
if (focusReturn.current) {
const {nodeId, element} = focusReturn.current;
const {nodeId, eventId, element} = focusReturn.current;
// The full-history view moves while reading. Resolve its remounted
// control by the local return key rather than losing keyboard position.
const returnKey = element?.dataset.researchReturn;
const origin = !element?.dataset.researchDecision || element.dataset.researchDecision === eventId ? element : null;
const returnKey = origin?.dataset.researchReturn;
const target = nodeId ? detail.current?.querySelector<HTMLButtonElement>(`[data-research-node="${CSS.escape(nodeId)}"] button`)
: element?.isConnected ? element : returnKey ? graph.current?.querySelector<HTMLButtonElement>(`[data-research-return="${CSS.escape(returnKey)}"]`) : null;
: (origin?.isConnected ? origin : null)
?? (returnKey ? graph.current?.querySelector<HTMLButtonElement>(`[data-research-return="${CSS.escape(returnKey)}"]`) : null)
?? (eventId ? graph.current?.querySelector<HTMLButtonElement>(`[data-research-decision="${CSS.escape(eventId)}"]`) : null);
focusReturn.current = null;
if (target?.isConnected) {target.focus({preventScroll: true}); target.scrollIntoView({block: "nearest"});}
if (target?.isConnected) {target.focus({preventScroll: true}); target.scrollIntoView({block: "center"});}
else graph.current?.focus();
return;
}
Expand Down Expand Up @@ -298,7 +304,7 @@ export function GoalResearchHistory({goalId, active, zh, refreshRevision = 0, re
const parent = readerParent.current;
readerParent.current = null;
focusPending.current = false;
focusReturn.current = {nodeId: parent?.nodeId, element: opener.current};
focusReturn.current = {nodeId: parent?.nodeId, eventId: selectedFinding?.event_id, element: opener.current};
if (parent) opener.current = parent.opener;
select(parent?.selection ?? {nodes: [], day: null}, false);
};
Expand All @@ -322,8 +328,6 @@ export function GoalResearchHistory({goalId, active, zh, refreshRevision = 0, re
target.scrollIntoView({block: "start"});
}, [active, reportScope, analysis.location.open, analysis.loading, analysis.pack, analysis.readError, analysis.panel, selection.nodes.length, selection.day]);
const readingSelection = Boolean(selected || selectedMembers?.length);
const selectedFinding = selected?.findings.find(finding => finding.result_id === selection.finding)
?? (!selection.finding ? selected?.findings.at(-1) : undefined);
const selectedEdges = selected ? value?.edges.filter(edge => edge.from_node === selected.id || edge.to_node === selected.id) ?? [] : [];
const selectedContextCitations = [...new Map((originalDisplay ? [] : currentDisplay?.fields ?? [])
.filter(field => field.event_id === selected?.latest.event_id || field.event_id === selectedFinding?.event_id)
Expand Down Expand Up @@ -354,7 +358,7 @@ export function GoalResearchHistory({goalId, active, zh, refreshRevision = 0, re
</ContextHelp>
</div> : null;
const historyView = !value ? null : lens === "timeline" && timeline ? <div className="research-history-canvas is-timeline" ref={graph} tabIndex={-1}>
<GoalResearchTimeline timeline={timeline} zh={zh} selectedIds={selection.nodes} selectedDay={selection.day} dayGroups={grouped.groups} onGroupSelect={day => select({nodes: [], day})} phase={phase} onPhase={next => chooseView(lens, next)} onSelect={(id, finding) => choose(id, true, finding)} onSelectMany={nodes => select({nodes, day: null})} timeLabel={fullTime} dayLabel={dayTime}/>
<GoalResearchTimeline timeline={timeline} zh={zh} selectedIds={selection.nodes} selectedEventId={selectedFinding?.event_id} selectedDay={selection.day} dayGroups={grouped.groups} onGroupSelect={day => select({nodes: [], day})} phase={phase} onPhase={next => chooseView(lens, next)} onSelect={(id, finding) => choose(id, true, finding)} onSelectMany={nodes => select({nodes, day: null})} timeLabel={fullTime} dayLabel={dayTime}/>
</div> : <div className="research-history-canvas" ref={graph} tabIndex={0} aria-label={zh ? "完整已载入研究关系图;可滚动" : "Full loaded research graph; scrollable"}>
<div className="research-history-stage" style={{width, height}}>
<svg width={width} height={height} aria-hidden="true" className="research-history-edges">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import {ContextHelp} from "./context-help";
* Position means first record time only; it never encodes progress or cause. */
type Copy = Record<string, string>;
type Props = {
timeline: ExploreTimeline; zh: boolean; selectedIds: readonly string[]; phase: number | null;
timeline: ExploreTimeline; zh: boolean; selectedIds: readonly string[]; selectedEventId?: string; phase: number | null;
selectedDay: string | null; dayGroups: ReturnType<typeof groupUnlinkedHistoryQuestions>["groups"]; onGroupSelect: (day: string) => void;
onSelect: (id: string, finding?: string) => void; onSelectMany: (ids: string[]) => void; onPhase: (index: number | null) => void;
timeLabel: (value: number) => string; dayLabel: (value: number) => string;
Expand All @@ -21,7 +21,7 @@ const LABELS = ["confirmed", "refuted", "tentative"] as const;
const tone = (label: string) => LABELS.includes(label as typeof LABELS[number]) ? `is-${label}` : "is-unlabeled";
const isOpen = (item: TimelineNode) => ["open", "exploring", "blocked"].includes(item.node.latest.status);

export function GoalResearchTimeline({timeline, zh, selectedIds, selectedDay, dayGroups, onGroupSelect, phase, onSelect, onSelectMany, onPhase, timeLabel, dayLabel}: Props) {
export function GoalResearchTimeline({timeline, zh, selectedIds, selectedEventId, selectedDay, dayGroups, onGroupSelect, phase, onSelect, onSelectMany, onPhase, timeLabel, dayLabel}: Props) {
const plot = useRef<HTMLDivElement>(null);
const [plotWidth, setPlotWidth] = useState(0);
useLayoutEffect(() => {
Expand Down Expand Up @@ -195,28 +195,36 @@ export function GoalResearchTimeline({timeline, zh, selectedIds, selectedDay, da
<section className="research-trail" aria-label={copy.trail}>
<h3>{copy.trail}</h3>
{!timeline.decisions.length ? <ContextHelp label={zh ? "暂无结构化决策记录" : "No structured decision records"} icon={CircleHelp}><p className="research-history-note">{timeline.structuredCount ? copy.none : copy.unstructured}</p></ContextHelp>
: <TrailList groups={trail} copy={copy} label={label} timeLabel={timeLabel} selectedIds={selectedIds} onSelect={onSelect}/>}
: <TrailList groups={trail} copy={copy} label={label} timeLabel={timeLabel} selectedEventId={selectedEventId} onSelect={onSelect}/>}
</section>
</div>;
}

function TrailList({groups, copy, label, timeLabel, selectedIds, onSelect}: {
function TrailList({groups, copy, label, timeLabel, selectedEventId, onSelect}: {
groups: ReturnType<typeof groupExploreTimelineDecisions>; copy: Copy; label: (key: string) => string;
timeLabel: (value: number) => string; selectedIds: readonly string[]; onSelect: (id: string, finding?: string) => void;
timeLabel: (value: number) => string; selectedEventId?: string; onSelect: (id: string, finding?: string) => void;
}) {
const [limit, setLimit] = useState(3);
const selectedIndex = groups.findIndex(group => group.items.some(item => item.event.event_id === selectedEventId));
// A bookmarked finding also restores its place in the folded history.
// Keep that range open when the reader closes, without expanding every row.
useLayoutEffect(() => {setLimit(value => Math.max(value, selectedIndex + 1));}, [selectedIndex]);
const visible = Math.max(limit, selectedIndex + 1);
return <>
<ol className="research-trail-groups">{groups.slice(0, limit).map(group => <TrailQuestion key={group.id}
group={group} copy={copy} label={label} timeLabel={timeLabel} selectedIds={selectedIds} onSelect={onSelect}/>)}</ol>
{groups.length > limit ? <button type="button" className="research-link" onClick={() => setLimit(value => value + 6)}>{copy.moreQuestions} · {groups.length - limit}</button> : null}
<ol className="research-trail-groups">{groups.slice(0, visible).map(group => <TrailQuestion key={group.id}
group={group} copy={copy} label={label} timeLabel={timeLabel} selectedEventId={selectedEventId} onSelect={onSelect}/>)}</ol>
{groups.length > visible ? <button type="button" className="research-link" onClick={() => setLimit(value => value + 6)}>{copy.moreQuestions} · {groups.length - visible}</button> : null}
</>;
}

function TrailQuestion({group, copy, label, timeLabel, selectedIds, onSelect}: {
function TrailQuestion({group, copy, label, timeLabel, selectedEventId, onSelect}: {
group: ReturnType<typeof groupExploreTimelineDecisions>[number]; copy: Copy; label: (key: string) => string;
timeLabel: (value: number) => string; selectedIds: readonly string[]; onSelect: (id: string, finding?: string) => void;
timeLabel: (value: number) => string; selectedEventId?: string; onSelect: (id: string, finding?: string) => void;
}) {
const [limit, setLimit] = useState(3);
const selectedIndex = group.items.findIndex(item => item.event.event_id === selectedEventId);
useLayoutEffect(() => {setLimit(value => Math.max(value, selectedIndex + 1));}, [selectedIndex]);
const visible = Math.max(limit, selectedIndex + 1);
const knownTimes = group.items.flatMap(item => item.at === null ? [] : [item.at]);
const exceptions = LABELS.filter(status => status !== "confirmed")
.map(status => ({status, count: group.items.filter(item => item.event.status === status).length})).filter(item => item.count);
Expand All @@ -229,15 +237,15 @@ function TrailQuestion({group, copy, label, timeLabel, selectedIds, onSelect}: {
{exceptions.map(({status, count}) => <span className={tone(status)} key={status}>{label(status)} {count}</span>)}
</div>
</header>
<ol className="research-trail-list">{group.items.slice(0, limit).map(item => {
<ol className="research-trail-list">{group.items.slice(0, visible).map(item => {
const decision = item.sections!.decision!;
// Preview source text, never a generated title. Complete lists live in
// the same exact-finding reader used by every other history selection.
const previewKey = decision.changed.length ? "changed" : decision.stopped.length ? "stopped" : "retained";
return <li key={item.event.event_id}>
<button type="button" data-research-decision={item.event.event_id}
aria-label={`${label(decision.outcome)} · ${item.at === null ? copy.unknownTime : timeLabel(item.at)} · ${label(item.event.status)} · ${item.event.title}`}
className={selectedIds.includes(item.event.node_id) ? "is-selected" : ""}
className={selectedEventId === item.event.event_id ? "is-selected" : ""}
onClick={event => {event.currentTarget.focus({preventScroll: true}); onSelect(item.event.node_id, item.event.result_id);}}>
<span className="research-trail-main">
<span className="research-trail-meta"><b className={`research-outcome is-${decision.outcome}`}>{label(decision.outcome)}</b>
Expand All @@ -252,6 +260,6 @@ function TrailQuestion({group, copy, label, timeLabel, selectedIds, onSelect}: {
</button>
</li>;
})}</ol>
{group.items.length > limit ? <button type="button" className="research-link" onClick={() => setLimit(value => value + 6)}>{copy.more} · {group.items.length - limit}</button> : null}
{group.items.length > visible ? <button type="button" className="research-link" onClick={() => setLimit(value => value + 6)}>{copy.more} · {group.items.length - visible}</button> : null}
</li>;
}
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Loading