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 @@ -17,8 +17,12 @@ import {GoalResearchWorkbench} from "../src/features/personal-workspace/goal-res
// Navigation must not turn organization into shared evidence or execution.
const caseFixture = JSON.parse(await readFile(resolve('src/data/fixtures/case-study-child.json'), 'utf8'));
const study = caseFixture.analyses[0].case_study;
const studyNavigation = packId => ({url: new URL('http://127.0.0.1/chat/'), goalId: 'child', packId, namespace: null,
follow: () => {throw Error('read navigated');}});
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: studyNavigation('saved-pack'),
onCitation: () => {throw Error('read executed a citation');},
}));
assert.ok(renderedStudy.includes(study.title));
assert.ok(renderedStudy.includes('Showing the saved Chinese original.'));
Expand All @@ -31,7 +35,7 @@ const casePack = {pack_id: "one", title: "First study", scope: caseFixture.analy
case_study: study, case_study_summary: {title: study.title}, applicability: "current"};
const secondCase = {...casePack, pack_id: "two", title: "Second study"};
const renderCase = packs => renderToStaticMarkup(createElement(ResearchCaseReading, {
analysis: {zh: false, pack: casePack, snapshot: {catalog: {packs}}, location: {open: false}},
analysis: {zh: false, pack: casePack, snapshot: {catalog: {packs}}, location: {open: false}, studyNavigation},
}));
assert.ok(!renderCase([casePack]).includes("<select"), "A single saved version reads directly");
const alternatives = renderCase([casePack, secondCase]);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -213,15 +213,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"});});
Expand Down Expand Up @@ -275,7 +292,7 @@ export function useResearchAnalysis(goalId: string, active: boolean, zh: boolean
requestAnimationFrame(() => {if (closedAt === citeGeneration.current) origin?.focus();});
};
return {readOnly: currentSnapshot?.execution_enabled === false || (Boolean(readSourceUrl) && currentSnapshot?.execution_enabled !== true), sourceScope, readUsageComparison, citation, citationBusy, citationError, sourcePanel, readCitation, readUsageTurn, retryCitation, closeCitation, goalId, zh, location, snapshot: currentSnapshot, pack: readingPack, requestPack: pack, savedOriginal, readError, error, loading, pending, cancelPending, recordedRunning, panel, trigger, read, show, execute, cancel,
close: () => {invalidateCitation(); navigate(false); requestAnimationFrame(() => trigger.current?.focus());}, select: (id: string) => navigate(location.open, id, true)};
studyNavigation, close: () => {invalidateCitation(); navigate(false); requestAnimationFrame(() => trigger.current?.focus());}, select: (id: string) => navigate(location.open, id, true)};
}
type Controller = ReturnType<typeof useResearchAnalysis>;

Expand Down Expand Up @@ -394,7 +411,7 @@ export function ResearchCaseReading({analysis: a, onHistory}: {analysis: Control
</select></label> : null}<button type="button" onClick={onHistory}>{a.zh ? "研究记录与分析" : "Research records and analysis"} ↗</button></header>
<AnalysisReadState analysis={a}/>
{p?.case_study ? <ResearchCaseStudy key={p.pack_id} study={p.case_study} zh={a.zh} historical={p.applicability !== "current"}
scope={p.scope} onCitation={a.readCitation}/> : !a.loading && !a.readError ? <p>{a.zh ? "选择一份解读,阅读问题背景、策略差异、关键转折与证据。" : "Choose a study to read its background, approaches, turning points and evidence."}</p> : null}
scope={p.scope} navigation={a.studyNavigation(p.pack_id)} onCitation={a.readCitation}/> : !a.loading && !a.readError ? <p>{a.zh ? "选择一份解读,阅读问题背景、策略差异、关键转折与证据。" : "Choose a study to read its background, approaches, turning points and evidence."}</p> : null}
<ResearchCitationPanel analysis={a}/>
</section>;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
Expand Down Expand Up @@ -73,6 +73,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; } }
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -44,19 +44,53 @@ function CaseSeries({series, zh}: {series: Series; zh: boolean}) {
</figure>;
}

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<HTMLElement>(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 citationLinks = (citations: AssessmentCitation[]) => <ul className="case-citation-links" role="list">
{citations.map((c, i) => <li key={i}><button type="button" onClick={e => onCitation(c, e.currentTarget)}>
{c.source_id} · {zh ? '行' : 'lines'} {c.first_line}–{c.last_line} <span aria-hidden="true">↗</span>
</button></li>)}
</ul>;
return <article className="research-case-study" lang={study.language === 'zh' ? 'zh-CN' : 'en'}>
return <article ref={article} className="research-case-study" lang={study.language === 'zh' ? 'zh-CN' : 'en'}>
<header className="case-opening">
<p className="case-eyebrow">{scope.case_id} <span>· {zh ? 'Case 深度解读' : 'Case study'}</span></p>
<h2>{study.title}</h2>
Expand All @@ -70,10 +104,19 @@ export function ResearchCaseStudy({study, zh, historical, scope, onCitation}: {
</details>
</header>
<div className="case-reading-layout">
<nav className="case-outline" aria-label={zh ? '解读目录' : 'Study contents'}>{study.sections.map((s, n) =>
<button type="button" key={s.id} onClick={() => {const target = document.getElementById(`${id}-${s.id}`); target?.focus({preventScroll: true}); target?.scrollIntoView({block: 'start'});}}>
<span>{String(n + 1).padStart(2, '0')}</span>{s.title}</button>)}</nav>
<div className="case-sections">{study.sections.map((s, n) => <section className="case-section" key={s.id} id={`${id}-${s.id}`} tabIndex={-1}>
<nav className="case-outline" aria-label={zh ? '解读目录' : 'Study contents'}>{study.sections.map((s, n) => {
const id = prefix + s.id;
const href = new URL(url);
href.hash = id;
return <a key={s.id} href={href.href} onClick={e => {
if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.altKey || e.shiftKey) return;
e.preventDefault();
follow(href);
const chapter = document.getElementById(id);
if (chapter && article.current?.contains(chapter)) focusChapter(chapter);
}}><span>{String(n + 1).padStart(2, '0')}</span>{s.title}</a>;
})}</nav>
<div className="case-sections">{study.sections.map((s, n) => <section className="case-section" key={s.id} id={prefix + s.id} tabIndex={-1}>
<div className="case-section-number">{String(n + 1).padStart(2, '0')}</div><h3>{s.title}</h3>
<MarkdownText text={s.text}/>
{s.series ? <CaseSeries series={s.series} zh={zh}/> : null}
Expand Down
Loading