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 @@ -20,7 +20,7 @@ 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,
study, zh: false, historical: true, active: true, scope: caseFixture.analyses[0].input.scope,
navigation: studyNavigation('saved-pack'),
onCitation: () => {throw Error('read executed a citation');},
}));
Expand All @@ -35,6 +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, {
active: true,
analysis: {zh: false, pack: casePack, snapshot: {catalog: {packs}}, location: {open: false}, studyNavigation},
}));
assert.ok(!renderCase([casePack]).includes("<select"), "A single saved version reads directly");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,46 @@ try {
await (await late).finished();
await page.evaluate(() => new Promise(resolvePaint => requestAnimationFrame(() => requestAnimationFrame(resolvePaint))));
assert.equal(await citation.count(), 0, "Late original response cannot reopen the previous evidence");
console.log("research evidence version: saved and late original excerpts isolated");

// Open a saved chapter through the same workspace router used by the tabs
// and parent links. Browser Back must keep the previous visit's position.
localizedReady = false;
const child = JSON.parse(await readFile(resolve(import.meta.dirname, "../src/data/fixtures/case-study-child.json"), "utf8"));
const parent = {goal_id: child.parent_goal_id, display_name: "Benchmark", events: [], analyses: []};
const study = await start("study", child, parent);
const studyPack = (await (await fetch(new URL(analysisPath + "?" + new URLSearchParams({goal_id: child.goal_id}), study))).json()).pack;
const chapter = studyPack.case_study.sections[1];
const address = new URL("/chat/", study);
address.search = new URLSearchParams({goalId: child.goal_id, view: "research", research_goal: child.goal_id,
research_reading: "case", research_analysis_goal: child.goal_id, research_pack: studyPack.pack_id});
address.hash = "case-section-" + studyPack.pack_id + "-" + chapter.id;
const atChapter = () => page.waitForFunction(id => document.activeElement?.id === id, address.hash.slice(1));
await page.goto(address.href);
await atChapter();
const tabs = page.locator(".personal-goal-tabs");
await tabs.getByRole("button", {name: "任务", exact: true}).click();
await page.waitForURL(url => url.searchParams.get("view") === "tasks");
assert.equal(new URL(page.url()).hash, address.hash);
await tabs.getByRole("button", {name: "研究", exact: true}).click();
await atChapter();
await page.locator("button.research-parent-goal").click();
await page.waitForURL(url => url.searchParams.get("goalId") === parent.goal_id);
function assertParentAddress() {
const url = new URL(page.url());
assert.equal(url.hash, "");
assert.equal([...url.searchParams.keys()].some(key => key.startsWith("research_")), false,
"The parent's address belongs to its own research");
}
assertParentAddress();
await page.goBack();
await atChapter();
assert.equal(new URL(page.url()).searchParams.get("research_pack"), studyPack.pack_id);
await page.reload();
await atChapter();
await page.goForward();
await page.waitForURL(url => url.searchParams.get("goalId") === parent.goal_id);
assertParentAddress();
console.log("research evidence version: saved excerpts isolated and chapter navigation restored");
} finally {
releaseOriginal?.();
await browser?.close();
Expand All @@ -110,12 +149,15 @@ try {
await rm(root, {recursive: true, force: true});
}

async function start(name, capture) {
const fixture = resolve(root, name + ".json");
await writeFile(fixture, JSON.stringify(capture));
async function start(name, ...captures) {
const fixtures = await Promise.all(captures.map(async (capture, index) => {
const path = resolve(root, name + "-" + index + ".json");
await writeFile(path, JSON.stringify(capture));
return path;
}));
const child = spawn(resolveTestPython({repoRoot: repo}), [
"-u", "examples/personal-workspace-browser/research-history-backend.py",
"--fixture", fixture, "--workspace-directory", "--assets-dir", assets,
...fixtures.flatMap(path => ["--fixture", path]), "--workspace-directory", "--assets-dir", assets,
], {cwd: repo, env: {...process.env, PYTHONPATH: repo}, stdio: ["ignore", "pipe", "pipe"]});
servers.push(child);
let error = "";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -396,7 +396,7 @@ export function ResearchReportSummary({analysis: a}: {analysis: Controller}) {
onExpand={origin => {a.trigger.current = origin; a.show();}} onCitation={a.readCitation}/></div> : null;
}

export function ResearchCaseReading({analysis: a, onHistory}: {analysis: Controller; onHistory: () => void}) {
export function ResearchCaseReading({analysis: a, active, onHistory}: {analysis: Controller; active: boolean; onHistory: () => void}) {
const packs = (a.snapshot?.catalog.packs ?? []).filter(p => p.case_study_summary);
const p = a.pack;
const firstStudy = packs[0]?.pack_id;
Expand All @@ -410,7 +410,7 @@ export function ResearchCaseReading({analysis: a, onHistory}: {analysis: Control
{packs.map(pack => <option key={pack.pack_id} value={pack.pack_id}>{pack.title}</option>)}
</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"}
{p?.case_study ? <ResearchCaseStudy key={p.pack_id} study={p.case_study} zh={a.zh} historical={p.applicability !== "current"} active={active}
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 @@ -514,7 +514,7 @@ export function GoalResearchHistory({goalId, originalGoalId = goalId, preferHist
onSelect={strategies.choose} onClose={() => strategies.choose(null)}/>;
}
if (caseReading && (hasCases || awaitingRequestedCase)) {
return <ResearchCaseReading analysis={analysis} onHistory={() => setCaseMode(false)}/>;
return <ResearchCaseReading analysis={analysis} active={active} onHistory={() => setCaseMode(false)}/>;
}
return <section className="research-history" aria-label={copy.title} aria-busy={busy}>
{hasCases ? <button type="button" className="case-return-reader" onClick={() => setCaseMode(true)}>{zh ? "← Case 深度解读" : "← Case studies"}</button> : null}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,8 +52,8 @@ function focusChapter(chapter: HTMLElement) {
}

/** A document's author controls the composition; the host owns navigation and evidence. */
export function ResearchCaseStudy({study, zh, historical, scope, navigation, onCitation}: {
study: TrajectoryCaseStudy; zh: boolean; historical: boolean;
export function ResearchCaseStudy({study, zh, historical, active, scope, navigation, onCitation}: {
study: TrajectoryCaseStudy; zh: boolean; historical: boolean; active: 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;
Expand All @@ -62,6 +62,7 @@ export function ResearchCaseStudy({study, zh, historical, scope, navigation, onC
const {url, goalId, packId, namespace, follow} = navigation;
const prefix = `${caseChapterHashPrefix.slice(1)}${namespace ? namespace + '-' : ''}${packId}-`;
useEffect(() => {
if (!active) return;
// 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;
Expand All @@ -84,7 +85,7 @@ export function ResearchCaseStudy({study, zh, historical, scope, navigation, onC
window.removeEventListener('popstate', restore);
window.removeEventListener('hashchange', restore);
};
}, [goalId, packId, prefix]);
}, [active, 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>
Expand Down
31 changes: 22 additions & 9 deletions apps/presentation/dashboard/src/router.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,26 +22,39 @@ function RouteLoading() {
return <main className="p-8" role="status">加载中… / Loading…</main>;
}

const searchSchema = z.object({
goalId: z.string().optional().default(""),
// Absence alone opens all Goals. Preserve present decoded values, including
// empty strings/arrays, so malformed scope cannot become an unscoped visit.
// Numeric opaque IDs still survive the router's JSON decoding.
rootGoalId: z.unknown().transform(value => typeof value === "string" && value
? value : JSON.stringify(value)).optional(),
// Preserve the existing history/analysis owners' query state. They validate
// it from the URL themselves; router decoding can produce arrays or numbers.
// The research readers validate their bookmarks against the selected Goal and
// source. Keep their existing URL fields together for workspace navigation.
const researchSearchShape = {
research_lens: z.unknown().optional(),
research_phase: z.unknown().optional(),
research_goal: z.unknown().optional(),
research_reading: z.unknown().optional(),
research_node: z.unknown().optional(),
research_finding: z.unknown().optional(),
research_event: z.unknown().optional(),
research_state: z.unknown().optional(),
research_group: z.unknown().optional(),
research_parent_node: z.unknown().optional(),
research_parent_group: z.unknown().optional(),
research_question: z.unknown().optional(),
research_return: z.unknown().optional(),
research_pack: z.unknown().optional(),
research_compare: z.unknown().optional(),
research_analysis: z.unknown().optional(),
research_analysis_goal: z.unknown().optional(),
};
export const clearedResearchSearch = Object.fromEntries(
Object.keys(researchSearchShape).map(key => [key, undefined]),
) as Record<keyof typeof researchSearchShape, undefined>;

const searchSchema = z.object({
goalId: z.string().optional().default(""),
// Absence alone opens all Goals. Preserve present decoded values, including
// empty strings/arrays, so malformed scope cannot become an unscoped visit.
// Numeric opaque IDs still survive the router's JSON decoding.
rootGoalId: z.unknown().transform(value => typeof value === "string" && value
? value : JSON.stringify(value)).optional(),
...researchSearchShape,
// Settings view state is validated by its local reader, just like research
// focus. Preserve it across workspace navigation without granting a target.
settings_scope: z.unknown().optional(),
Expand Down
Loading
Loading