From 05edd379eb765abdbbed1f1084db4837fd0f7238 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Mon, 9 Nov 2026 13:35:20 +0800 Subject: [PATCH] Keep saved case chapters addressable in Research Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- .../smoke/research-analysis-read-smoke.mjs | 5 +- .../goal-research-analysis.tsx | 25 ++++++-- .../research-case-study.css | 6 +- .../research-case-study.tsx | 59 ++++++++++++++++--- 4 files changed, 79 insertions(+), 16 deletions(-) diff --git a/apps/presentation/dashboard/smoke/research-analysis-read-smoke.mjs b/apps/presentation/dashboard/smoke/research-analysis-read-smoke.mjs index 43685e580f..58f45d3452 100644 --- a/apps/presentation/dashboard/smoke/research-analysis-read-smoke.mjs +++ b/apps/presentation/dashboard/smoke/research-analysis-read-smoke.mjs @@ -18,7 +18,10 @@ import {GoalResearchWorkbench} from "../src/features/personal-workspace/goal-res const caseFixture = JSON.parse(await readFile(resolve('src/data/fixtures/case-study-child.json'), 'utf8')); const study = caseFixture.analyses[0].case_study; const renderedStudy = renderToStaticMarkup(createElement(ResearchCaseStudy, { - study, zh: false, historical: true, scope: caseFixture.analyses[0].input.scope, onCitation: () => {throw Error('read executed a citation');}, + study, zh: false, historical: true, scope: caseFixture.analyses[0].input.scope, + navigation: {url: new URL('http://127.0.0.1/chat/'), goalId: 'child', packId: 'saved-pack', namespace: null, + follow: () => {throw Error('read navigated');}}, + onCitation: () => {throw Error('read executed a citation');}, })); assert.ok(renderedStudy.includes(study.title)); assert.ok(renderedStudy.includes('Showing the saved Chinese original.')); diff --git a/apps/presentation/dashboard/src/features/personal-workspace/goal-research-analysis.tsx b/apps/presentation/dashboard/src/features/personal-workspace/goal-research-analysis.tsx index 085f9f582b..2190fc68c8 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/goal-research-analysis.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/goal-research-analysis.tsx @@ -7,7 +7,7 @@ import {requestJson} from "../../data/chat"; import {sourceReadApiUrl} from "../../data/local-status-query"; import {ContextHelp} from "./context-help"; import {ResearchSynthesis} from "./goal-research-synthesis"; -import {ResearchCaseStudy} from "./research-case-study"; +import {ResearchCaseStudy, caseChapterHashPrefix} from "./research-case-study"; import type {TrajectoryCaseStudy} from "../../../../../../loopx/control_plane/capabilities/trajectory_case_study.js"; import "./goal-research-analysis.css"; @@ -196,15 +196,32 @@ export function useResearchAnalysis(goalId: string, active: boolean, zh: boolean timer = setTimeout(poll, 1000); return () => {stopped = true; clearTimeout(timer);}; }, [active, location.open, pending, recordedRunning, read]); - const navigate = (open: boolean, packId = location.packId, push = false) => { + const analysisUrl = (open: boolean, packId: string | null) => { const url = new URL(window.location.href); if (packId) {url.searchParams.set("research_pack", packId); url.searchParams.set("research_analysis_goal", goalId);} else {url.searchParams.delete("research_pack"); url.searchParams.delete("research_analysis_goal");} if (open) url.searchParams.set("research_analysis", goalId); else url.searchParams.delete("research_analysis"); + return url; + }; + const navigate = (open: boolean, packId = location.packId, push = false) => { + const url = analysisUrl(open, packId); + if (packId !== location.packId && url.hash.startsWith(caseChapterHashPrefix)) url.hash = ""; window.history[push ? "pushState" : "replaceState"](window.history.state, "", url); applyLocation({open, packId}); }; + const studyNavigation = (packId: string) => { + const url = analysisUrl(location.open, packId); + url.searchParams.set("goalId", goalId); + url.searchParams.set("view", "research"); + url.searchParams.set("research_goal", goalId); + url.searchParams.set("research_reading", "case"); + const namespace = readSourceUrl ? new URL(readSourceUrl, url).searchParams.get("source_namespace") : null; + return {url, goalId, packId, namespace, follow: (href: URL) => { + if (href.href !== window.location.href) window.history.pushState(window.history.state, "", href); + if (location.packId !== packId) applyLocation({open: location.open, packId}); + }}; + }; const show = () => { navigate(true, pack?.pack_id ?? location.packId, !location.open); requestAnimationFrame(() => {panel.current?.focus({preventScroll: true}); panel.current?.scrollIntoView({block: "start"});}); @@ -246,7 +263,7 @@ export function useResearchAnalysis(goalId: string, active: boolean, zh: boolean const readUsageTurn = (turnId: string, origin: HTMLButtonElement) => readEvidence({turn_id: turnId}, "usage-turn", origin); const closeCitation = () => {citeGeneration.current++; setCitation(null); setCitationError(""); setCitationBusy(false); requestAnimationFrame(() => sourceTrigger.current?.focus());}; return {readOnly: currentSnapshot?.execution_enabled === false || (Boolean(readSourceUrl) && currentSnapshot?.execution_enabled !== true), sourceScope, readUsageComparison, citation, citationBusy, citationError, sourcePanel, readCitation, readUsageTurn, closeCitation, goalId, zh, location, snapshot: currentSnapshot, pack: readingPack, requestPack: pack, savedOriginal, readError, error, loading, pending, cancelPending, recordedRunning, panel, trigger, read, show, execute, cancel, - close: () => {citeGeneration.current++; setCitation(null); setCitationError(""); setCitationBusy(false); navigate(false); requestAnimationFrame(() => trigger.current?.focus());}, select: (id: string) => navigate(location.open, id, true)}; + studyNavigation, close: () => {citeGeneration.current++; setCitation(null); setCitationError(""); setCitationBusy(false); navigate(false); requestAnimationFrame(() => trigger.current?.focus());}, select: (id: string) => navigate(location.open, id, true)}; } type Controller = ReturnType; @@ -365,7 +382,7 @@ export function ResearchCaseReading({analysis: a, onHistory}: {analysis: Control {p?.case_study ? : !a.loading && !a.readError ?

{a.zh ? "选择一份解读,阅读问题背景、策略差异、关键转折与证据。" : "Choose a study to read its background, approaches, turning points and evidence."}

: null} + scope={p.scope} navigation={a.studyNavigation(p.pack_id)} onCitation={a.readCitation}/> : !a.loading && !a.readError ?

{a.zh ? "选择一份解读,阅读问题背景、策略差异、关键转折与证据。" : "Choose a study to read its background, approaches, turning points and evidence."}

: null} ; } diff --git a/apps/presentation/dashboard/src/features/personal-workspace/research-case-study.css b/apps/presentation/dashboard/src/features/personal-workspace/research-case-study.css index 6a65e96d47..6795bffcd7 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/research-case-study.css +++ b/apps/presentation/dashboard/src/features/personal-workspace/research-case-study.css @@ -30,8 +30,8 @@ .case-provenance dt { color: var(--pw-muted); }.case-provenance dd { margin: 0; } .case-reading-layout { display: grid; grid-template-columns: 185px minmax(0, 1fr); gap: 40px; align-items: start; } .case-outline { position: sticky; top: 16px; display: grid; gap: 2px; border-top: 1px solid var(--pw-line); padding-top: 12px; } -.case-outline button { display: flex; gap: 12px; text-align: start; padding: 9px 0; min-height: 44px; color: var(--pw-muted); background: none; border: 0; cursor: pointer; font: inherit; font-size: var(--text-body); line-height: 1.6; } -.case-outline button span { flex: 0 0 20px; white-space: nowrap; font-variant-numeric: tabular-nums; opacity: .65; }.case-outline button:hover { color: var(--pw-text); } +.case-outline a { display: flex; gap: 12px; text-align: start; padding: 9px 0; min-height: 44px; color: var(--pw-muted); text-decoration: none; font: inherit; font-size: var(--text-body); line-height: 1.6; } +.case-outline a span { flex: 0 0 20px; white-space: nowrap; font-variant-numeric: tabular-nums; opacity: .65; }.case-outline a:hover { color: var(--pw-text); } .case-sections { min-width: 0; }.case-section { border-top: 1px solid var(--pw-line); padding-block: 24px 40px; scroll-margin-top: 24px; } .case-section-number { color: var(--pw-muted); font-size: var(--text-body); margin-bottom: 6px; } .research-case-study .case-section h3 { font-size: 23px; font-weight: 600; line-height: 1.5; margin: 0 0 18px; } @@ -71,6 +71,6 @@ } } .case-limitations { color: var(--pw-muted); }.case-limitations li { margin-block: 10px; }.case-read-warning { border-left: 3px solid currentColor; padding-left: 14px; } -.research-case-reader :is(button,select,summary,[tabindex]):focus-visible, .case-return-reader:focus-visible { outline: 2px solid var(--pw-blue); outline-offset: 3px; } +.research-case-reader :is(a,button,select,summary,[tabindex]):focus-visible, .case-return-reader:focus-visible { outline: 2px solid var(--pw-blue); outline-offset: 3px; } @container (max-width: 760px) { .case-reading-layout { grid-template-columns: 1fr; gap: 18px; }.case-outline { position: static; grid-template-columns: repeat(2,minmax(0,1fr)); gap: 4px 20px; }.case-opening { margin-block: 24px; } } @media (max-width: 640px) { .case-reader-toolbar { align-items: start; }.case-reader-toolbar label { min-width: 0; width: 100%; }.case-reader-toolbar select { width: 100%; }.case-timeline li { grid-template-columns: 1fr; gap: 8px; }.case-timeline li > div { padding-left: 14px; }.research-case-study .case-opening h2 { font-size: 28px; }.case-provenance dl { grid-template-columns: 1fr; gap: 4px; }.case-provenance dd { margin-bottom: 10px; } } diff --git a/apps/presentation/dashboard/src/features/personal-workspace/research-case-study.tsx b/apps/presentation/dashboard/src/features/personal-workspace/research-case-study.tsx index 30ca9075f1..1352aa3286 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/research-case-study.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/research-case-study.tsx @@ -1,4 +1,4 @@ -import {useId} from 'react'; +import {useEffect, useRef} from 'react'; import type {TrajectoryCaseStudy} from '../../../../../../loopx/control_plane/capabilities/trajectory_case_study.js'; import type {AssessmentCitation} from '../../../../../../loopx/control_plane/capabilities/trajectory_assessment_types.js'; import {MarkdownText} from './markdown'; @@ -44,18 +44,52 @@ function CaseSeries({series, zh}: {series: Series; zh: boolean}) { ; } +export const caseChapterHashPrefix = '#case-section-'; + +function focusChapter(chapter: HTMLElement) { + chapter.focus({preventScroll: true}); + chapter.scrollIntoView({block: 'start'}); +} + /** A document's author controls the composition; the host owns navigation and evidence. */ -export function ResearchCaseStudy({study, zh, historical, scope, onCitation}: { +export function ResearchCaseStudy({study, zh, historical, scope, navigation, onCitation}: { study: TrajectoryCaseStudy; zh: boolean; historical: boolean; scope: {case_id: string; run_id: string; revision: string; window: string}; + navigation: {url: URL; goalId: string; packId: string; namespace: string | null; follow: (href: URL) => void}; onCitation: (c: AssessmentCitation, origin?: HTMLButtonElement) => void; }) { - const id = useId().replace(/:/g, ''); + const article = useRef(null); + const {url, goalId, packId, namespace, follow} = navigation; + const prefix = `${caseChapterHashPrefix.slice(1)}${namespace ? namespace + '-' : ''}${packId}-`; + useEffect(() => { + // The URL can arrive before the saved study is mounted, or while history + // switches to another Goal/version. Only restore a visible matching chapter. + let frame = 0; + const restore = () => { + cancelAnimationFrame(frame); + frame = requestAnimationFrame(() => { + const {searchParams, hash} = new URL(window.location.href); + if (searchParams.get('view') !== 'research' || searchParams.get('research_reading') !== 'case' + || searchParams.get('research_goal') !== goalId || searchParams.get('research_analysis_goal') !== goalId + || searchParams.get('research_pack') !== packId || !hash.startsWith(`#${prefix}`)) return; + const chapter = document.getElementById(hash.slice(1)); + if (chapter && article.current?.contains(chapter) && chapter.getClientRects().length) focusChapter(chapter); + }); + }; + restore(); + window.addEventListener('popstate', restore); + window.addEventListener('hashchange', restore); + return () => { + cancelAnimationFrame(frame); + window.removeEventListener('popstate', restore); + window.removeEventListener('hashchange', restore); + }; + }, [goalId, packId, prefix]); const sources = (citations: AssessmentCitation[]) =>
{zh ? '依据与原文' : 'Evidence and originals'} {citations.map((c, i) => )}
; - return
+ return

{scope.case_id} · {zh ? 'Case 深度解读' : 'Case study'}

{study.title}

@@ -69,10 +103,19 @@ export function ResearchCaseStudy({study, zh, historical, scope, onCitation}: {
- -
{study.sections.map((s, n) =>
+ +
{study.sections.map((s, n) =>
{String(n + 1).padStart(2, '0')}

{s.title}

{s.series ? : null}