From e3982b4cdf0a5ce8a85caa9cea36378fe013d497 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Mon, 2 Nov 2026 06:25:08 +0530 Subject: [PATCH 1/2] fix(research): preserve question reading intent across reloads Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- .../goal-research-history.tsx | 41 +++++++++++-------- .../research-route-reading.mjs | 8 ++++ 2 files changed, 33 insertions(+), 16 deletions(-) diff --git a/apps/presentation/dashboard/src/features/personal-workspace/goal-research-history.tsx b/apps/presentation/dashboard/src/features/personal-workspace/goal-research-history.tsx index 6c764cef2b..8e7ad258f5 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/goal-research-history.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/goal-research-history.tsx @@ -15,7 +15,7 @@ import {ExploreMeasurements, formatExploreMeasurementValue} from "./explore-meas import {projectTrajectoryDisplay, trajectoryDisplaySnapshotText} from "../../../../../../loopx/control_plane/capabilities/trajectory_assessment_display.js"; type CollectionSelection = {nodes: string[]; day: string | null}; -type Selection = CollectionSelection & {finding?: string; collection?: CollectionSelection}; +type Selection = CollectionSelection & {finding?: string; collection?: CollectionSelection; question?: boolean; returnKey?: string}; type Lens = "timeline" | "graph"; type Read = {events: ExploreHistoryEvent[]; total: number | null; complete: boolean}; const selectedFromUrl = (goal: string): Selection => { @@ -34,7 +34,9 @@ const selectedFromUrl = (goal: string): Selection => { const parentDay = query.get("research_parent_group"), parentNodes = ids("research_parent_node"); const collection = nodes.length === 1 && (parentDay !== null || (parentNodes.length > 1 && parentNodes.includes(nodes[0]!))) ? {nodes: parentDay === null ? parentNodes : [], day: parentDay} : undefined; - return {nodes, day, finding: query.get("research_finding") || undefined, collection}; + const finding = query.get("research_finding") || undefined; + return {nodes, day, finding, collection, question: query.get("research_question") === "1" || !finding, + returnKey: query.get("research_return") || undefined}; }; const viewFromUrl = (goal: string) => { @@ -83,8 +85,8 @@ export function GoalResearchHistory({goalId, active, zh, refreshRevision = 0, re const [loaded, setLoaded] = useState(0); const [revision, setRevision] = useState(0); const [selection, setSelection] = useState(() => selectedFromUrl(goalId)); - // Pinning the displayed finding must not collapse a newly opened question. - const [questionExpanded, setQuestionExpanded] = useState(() => !selectedFromUrl(goalId).finding); + // The same finding can be read directly or as part of its complete question. + const questionExpanded = selection.question ?? !selection.finding; const [recordsOpen, setRecordsOpen] = useState(false); const records = useRef(null); const [lens, setLens] = useState(() => viewFromUrl(goalId).lens); @@ -95,7 +97,7 @@ export function GoalResearchHistory({goalId, active, zh, refreshRevision = 0, re const opener = useRef(null); const collectionOpener = useRef(null); const focusPending = useRef(Boolean(selection.nodes.length || selection.day !== null)); - const focusReturn = useRef<{nodeId?: string; eventId?: string; collection?: CollectionSelection; element: HTMLElement | null} | null>(null); + const focusReturn = useRef<{nodeId?: string; eventId?: string; returnKey?: string; collection?: CollectionSelection; element: HTMLElement | null} | null>(null); const copy = zh ? { title: "研究历程", loading: "正在读取完整记录", reading: "读取中", refresh: "刷新记录", all: "回到全图", scope: "Explore 已记录历史", coverage: "其他来源覆盖、尝试发生时序尚未核验", @@ -266,10 +268,9 @@ export function GoalResearchHistory({goalId, active, zh, refreshRevision = 0, re const returning = returningToCollection || returningToGraph; focusReturn.current = returning ? {nodeId: returningToCollection ? selectedId : undefined, collection: returningToGraph ? {nodes: selection.nodes, day: selection.day} : undefined, - eventId: selectedFinding?.event_id, element: opener.current} : null; + eventId: selectedFinding?.event_id, returnKey: selection.returnKey, 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); @@ -278,11 +279,11 @@ export function GoalResearchHistory({goalId, active, zh, refreshRevision = 0, re useLayoutEffect(() => { if (!active) return; if (focusReturn.current) { - const {nodeId, eventId, collection, element} = focusReturn.current; + const {nodeId, eventId, returnKey: savedReturnKey, collection, 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 origin = !element?.dataset.researchDecision || element.dataset.researchDecision === eventId ? element : null; - const returnKey = origin?.dataset.researchReturn; + const returnKey = origin?.dataset.researchReturn ?? savedReturnKey; const target = nodeId ? detail.current?.querySelector(`[data-research-node="${CSS.escape(nodeId)}"] button`) : (origin?.isConnected ? origin : null) ?? (returnKey ? graph.current?.querySelector(`[data-research-return="${CSS.escape(returnKey)}"]`) : null) @@ -309,27 +310,31 @@ export function GoalResearchHistory({goalId, active, zh, refreshRevision = 0, re opener.current = document.activeElement instanceof HTMLElement ? document.activeElement : null; focusPending.current = true; } + next = {...next, question: next.question ?? !next.finding, + returnKey: next.returnKey ?? (focus ? opener.current?.dataset.researchReturn : selection.returnKey)}; setSelection(next); - setQuestionExpanded(!next.finding); const url = new URL(window.location.href); url.searchParams.delete("research_node"); url.searchParams.delete("research_group"); url.searchParams.delete("research_finding"); url.searchParams.delete("research_parent_node"); url.searchParams.delete("research_parent_group"); + url.searchParams.delete("research_question"); url.searchParams.delete("research_return"); if (next.nodes.length || next.day !== null) { if (next.day !== null) url.searchParams.set("research_group", next.day); else url.searchParams.set("research_node", next.nodes.length === 1 ? next.nodes[0]! : JSON.stringify(next.nodes)); if (next.finding) url.searchParams.set("research_finding", next.finding); + if (next.question) url.searchParams.set("research_question", "1"); + if (next.returnKey) url.searchParams.set("research_return", next.returnKey); if (next.collection?.day !== null && next.collection?.day !== undefined) url.searchParams.set("research_parent_group", next.collection.day); else if (next.collection?.nodes.length) url.searchParams.set("research_parent_node", JSON.stringify(next.collection.nodes)); url.searchParams.set("research_goal", goalId); } else url.searchParams.set("research_goal", goalId); - if (selection.finding !== next.finding || selection.day !== next.day || selection.nodes.length !== next.nodes.length || selection.nodes.some((id, index) => id !== next.nodes[index])) window.history.pushState(null, "", url); + if (selection.finding !== next.finding || selection.question !== next.question || selection.returnKey !== next.returnKey || selection.day !== next.day || selection.nodes.length !== next.nodes.length || selection.nodes.some((id, index) => id !== next.nodes[index])) window.history.pushState(null, "", url); }; const choose = (id: string, focus = true, finding?: string) => {collectionOpener.current = null; select({nodes: id ? [id] : [], day: null, finding}, focus);}; const closeReader = () => { focusPending.current = false; focusReturn.current = {nodeId: collection ? selectedId : undefined, eventId: selectedFinding?.event_id, - collection: !collection ? {nodes: selection.nodes, day: selection.day} : undefined, element: opener.current}; + returnKey: selection.returnKey, collection: !collection ? {nodes: selection.nodes, day: selection.day} : undefined, element: opener.current}; if (collection) opener.current = collectionOpener.current; collectionOpener.current = null; select(collection ?? {nodes: [], day: null}, false); @@ -371,8 +376,9 @@ export function GoalResearchHistory({goalId, active, zh, refreshRevision = 0, re setSelection(previous => ({...previous, finding})); const url = new URL(window.location.href); url.searchParams.set("research_finding", finding); + if (questionExpanded) url.searchParams.set("research_question", "1"); window.history.replaceState(null, "", url); - }, [active, selection.finding, selectedFinding, selected]); + }, [active, selection.finding, selectedFinding, selected, questionExpanded]); const displayControl = display ?