From e73c257f10b5e828cef7699e572f33a969f08ae9 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Thu, 22 Oct 2026 16:50:49 +0800 Subject: [PATCH 1/5] feat(workspace): give research a dedicated readable tab Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- apps/presentation/dashboard/src/data/chat.ts | 5 +- .../personal-workspace/channel-header.tsx | 1 + .../personal-workspace/context-help.tsx | 8 +- .../goal-explore-results.css | 33 ++++++ .../goal-explore-results.tsx | 101 +++++++++++------- .../personal-workspace/goal-overview.css | 9 ++ .../personal-workspace/goal-overview.tsx | 8 +- .../goal-research-history.css | 52 +++++---- .../goal-research-history.tsx | 37 +++++-- .../goal-research-timeline.tsx | 15 +-- .../goal-research-workbench.tsx | 15 +++ .../src/features/personal-workspace/i18n.tsx | 2 + .../personal-workspace-model.ts | 2 +- .../personal-workspace-page.tsx | 17 +-- .../personal-workspace/personal-workspace.css | 4 +- apps/presentation/dashboard/src/router.tsx | 3 +- .../dashboard/src/views/dashboard-page.tsx | 2 +- docs/development/design.md | 25 +++++ .../research-workbench/after-desktop.png | Bin 0 -> 144216 bytes .../research-workbench/after-narrow.png | Bin 0 -> 52769 bytes .../research-workbench/before-desktop.png | Bin 0 -> 162540 bytes .../research-workbench/before-narrow.png | Bin 0 -> 55053 bytes .../research-workbench/overview-after.png | Bin 0 -> 95070 bytes .../research-workbench/reader-desktop.png | Bin 0 -> 157331 bytes .../research-workbench/reader-narrow.png | Bin 0 -> 55883 bytes .../research-history-backend.py | 11 +- .../research-history.mjs | 55 +++++++++- ...runtime-source-workspace-browser-smoke.mjs | 4 + 28 files changed, 303 insertions(+), 106 deletions(-) create mode 100644 apps/presentation/dashboard/src/features/personal-workspace/goal-explore-results.css create mode 100644 apps/presentation/dashboard/src/features/personal-workspace/goal-research-workbench.tsx create mode 100644 examples/personal-workspace-browser/evidence/research-workbench/after-desktop.png create mode 100644 examples/personal-workspace-browser/evidence/research-workbench/after-narrow.png create mode 100644 examples/personal-workspace-browser/evidence/research-workbench/before-desktop.png create mode 100644 examples/personal-workspace-browser/evidence/research-workbench/before-narrow.png create mode 100644 examples/personal-workspace-browser/evidence/research-workbench/overview-after.png create mode 100644 examples/personal-workspace-browser/evidence/research-workbench/reader-desktop.png create mode 100644 examples/personal-workspace-browser/evidence/research-workbench/reader-narrow.png diff --git a/apps/presentation/dashboard/src/data/chat.ts b/apps/presentation/dashboard/src/data/chat.ts index 238724e826..57b13fc2ff 100644 --- a/apps/presentation/dashboard/src/data/chat.ts +++ b/apps/presentation/dashboard/src/data/chat.ts @@ -1,3 +1,4 @@ +import {sourceReadApiUrl} from "./local-status-query"; import type {OracleEvidence} from "./goal-acceptance-observation"; import { HANDOFF_MODES, EXECUTION_HANDOFF_MODES, type ExecutionHandoffMode } from "../../../../../loopx/control_plane/coordination/handoff_mode_vocabulary.js"; import { normalizeGoalDraft } from "../../../../../loopx/control_plane/collaboration/goal_draft.js"; @@ -2673,9 +2674,9 @@ const exploreResultPageSchema = z.object({ })), }); export type ExploreResultPage = z.infer; -export async function fetchExploreResults(goalId: string, cursor?: string) { +export async function fetchExploreResults(goalId: string, cursor?: string, readSourceUrl?: string) { const query = new URLSearchParams({goal_id: goalId, ...(cursor ? {cursor} : {})}); - const page = exploreResultPageSchema.parse(await requestJson(`/api/chat/explore-results?${query}`)); + const page = exploreResultPageSchema.parse(await requestJson(sourceReadApiUrl(readSourceUrl, `/api/chat/explore-results?${query}`))); if (page.goal_id !== goalId) throw new Error("Explore result Goal mismatch"); return page; } diff --git a/apps/presentation/dashboard/src/features/personal-workspace/channel-header.tsx b/apps/presentation/dashboard/src/features/personal-workspace/channel-header.tsx index 635f44dae5..78ac909684 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/channel-header.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/channel-header.tsx @@ -282,6 +282,7 @@ export function ChannelHeader({ diff --git a/apps/presentation/dashboard/src/features/personal-workspace/context-help.tsx b/apps/presentation/dashboard/src/features/personal-workspace/context-help.tsx index b0ab105b1f..58c73076f3 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/context-help.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/context-help.tsx @@ -30,17 +30,21 @@ export function ContextHelp({label, children}: {label: string; children: ReactNo }; useEffect(() => () => clearTimeout(timer.current), []); useEffect(() => { - if (!open) return; + // toggle is queued by the browser. Escape can arrive while the visible + // popover has not yet updated React state; guard the actual DOM state. const escape = (event: KeyboardEvent) => { if (event.key === "Escape" && panel.current?.matches(":popover-open")) { event.preventDefault(); event.stopPropagation(); dismiss(); } }; window.addEventListener("keydown", escape, true); + return () => window.removeEventListener("keydown", escape, true); + }, []); + useEffect(() => { + if (!open) return; window.addEventListener("resize", place); window.addEventListener("scroll", place, true); return () => { - window.removeEventListener("keydown", escape, true); window.removeEventListener("resize", place); window.removeEventListener("scroll", place, true); }; diff --git a/apps/presentation/dashboard/src/features/personal-workspace/goal-explore-results.css b/apps/presentation/dashboard/src/features/personal-workspace/goal-explore-results.css new file mode 100644 index 0000000000..5218d721e6 --- /dev/null +++ b/apps/presentation/dashboard/src/features/personal-workspace/goal-explore-results.css @@ -0,0 +1,33 @@ +.goal-research-workbench { width: min(1280px, 100%); margin: 0 auto; } +.research-saved-evidence { margin: 24px 0; border-top: 1px solid var(--pw-line); } +.research-saved-evidence > summary { padding: 16px 0; min-height: 44px; cursor: pointer; } +.goal-explore-results { width: 100%; padding: 24px 0; border: 0; border-radius: 0; } +.explore-reader-heading { display: flex; align-items: center; gap: 8px; } +.goal-explore-results .goal-team-results-layout { grid-template-columns: minmax(200px, 280px) minmax(0, 1fr); } +.goal-explore-results .goal-team-result-list { max-height: 680px; overflow-y: auto; padding: 2px; align-self: start; } +.goal-explore-results .goal-team-result-list button { flex-shrink: 0; } +.goal-explore-results .goal-team-result-list strong { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; } +.goal-explore-results .goal-team-result-list small { font-family: inherit; } +.goal-explore-results .goal-team-result-reader { overflow-wrap: anywhere; } +.goal-explore-results .goal-team-result-reader > h4 { font-size: var(--text-lead); line-height: var(--leading-lead); font-weight: 600; margin: 0 0 12px; } +.explore-reader-status { color: var(--pw-muted); font-size: var(--text-caption); } +.goal-explore-results .goal-team-result-reader p, .goal-explore-results .goal-team-result-reader li { font-size: var(--text-lead); line-height: var(--leading-prose); color: var(--pw-text); } +.explore-finding-prose { max-width: 72ch; } +.explore-finding-prose section { margin: 24px 0; } +.explore-finding-prose h5, .explore-finding-prose h6 { font: inherit; font-weight: 600; margin: 0 0 8px; } +.explore-finding-prose p, .explore-finding-raw { white-space: pre-wrap; } +.explore-finding-prose ul, .explore-reader-details ul { padding-inline-start: 20px; list-style: disc; } +.explore-reader-details { border-top: 1px solid var(--pw-line); margin-top: 16px; } +.goal-explore-results .explore-reader-details > summary { font-size: var(--text-body); } +.explore-reader-details dl { display: grid; gap: 8px; } +.explore-reader-details dt { color: var(--pw-muted); font-size: var(--text-caption); } +.explore-reader-details dd { margin-bottom: 12px; } +.goal-explore-results .explore-linked-tasks { padding: 0; list-style: none; } +.explore-linked-tasks > li { border-bottom: 1px solid var(--pw-line); } +.explore-linked-tasks summary > span { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; } +.explore-linked-tasks summary small { display: block; margin-top: 8px; color: var(--pw-muted); } +@media (max-width: 640px) { + + .goal-explore-results .goal-team-results-layout { grid-template-columns: minmax(0, 1fr); } + .goal-explore-results .goal-team-result-list { max-height: none; } +} diff --git a/apps/presentation/dashboard/src/features/personal-workspace/goal-explore-results.tsx b/apps/presentation/dashboard/src/features/personal-workspace/goal-explore-results.tsx index 93bed53094..e55a22465f 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/goal-explore-results.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/goal-explore-results.tsx @@ -1,63 +1,88 @@ import {useEffect, useRef, useState} from "react"; import {RefreshCw} from "lucide-react"; import {fetchExploreResults, type ExploreResultPage} from "../../data/chat"; +import {parseExploreFindingSections} from "../../../../../../loopx/control_plane/presentation/explore_timeline.js"; import {ExploreMeasurements} from "./explore-measurements"; +import {ContextHelp} from "./context-help"; +import "./goal-explore-results.css"; -/** Read-only saved evidence, including history when Explore has been disabled. */ -export function GoalExploreResults({goalId, zh, hideEmpty = false}: {goalId: string; zh: boolean; hideEmpty?: boolean}) { +/** Saved evidence remains readable after Explore is disabled. No execution here. */ +export function GoalExploreResults({goalId, zh, active = true, readSourceUrl}: {goalId: string; zh: boolean; active?: boolean; readSourceUrl?: string}) { const [page, setPage] = useState(null); const [selectedId, setSelectedId] = useState(""); - const [busy, setBusy] = useState(false); + const [cursor, setCursor] = useState(); + const [revision, setRevision] = useState(0); + const [busy, setBusy] = useState(true); const [error, setError] = useState(false); - const generation = useRef(0); const reader = useRef(null); + const loadedQuery = useRef(""); useEffect(() => { - void load(); - return () => {generation.current++;}; - }, [goalId]); - async function load(cursor?: string) { - const current = ++generation.current; - setBusy(true); setError(false); setPage(null); setSelectedId(""); - try { - const next = await fetchExploreResults(goalId, cursor); - if (current !== generation.current) return; - setPage(next); setSelectedId(next.items[0]?.finding_id ?? ""); - } catch { - if (current === generation.current) setError(true); - } finally {if (current === generation.current) setBusy(false);} - } + if (!active) return; + const queryKey = JSON.stringify([goalId, readSourceUrl, cursor, revision]); + if (loadedQuery.current === queryKey) return; + let current = true; + setBusy(true); setError(false); setPage(null); + void fetchExploreResults(goalId, cursor, readSourceUrl).then(next => { + if (!current) return; + loadedQuery.current = queryKey; + setPage(next); setSelectedId(id => next.items.some(item => item.finding_id === id) ? id : next.items[0]?.finding_id ?? ""); + }, () => {if (current) setError(true);}).finally(() => {if (current) setBusy(false);}); + return () => {current = false;}; + }, [goalId, cursor, revision, active, readSourceUrl]); const selected = page?.items.find(item => item.finding_id === selectedId); - if (hideEmpty && page && !busy && !error && !page.items.length && page.total === 0 && !page.next_cursor) return null; + const sections = selected ? parseExploreFindingSections(selected.summary) : null; + const statuses: Record = zh + ? {tentative: "待检查", confirmed: "来源记为确认", refuted: "已反驳", open: "未完成", done: "已完成", blocked: "受阻", deferred: "已延后"} + : {tentative: "Tentative", confirmed: "Recorded confirmation", refuted: "Refuted", open: "Open", done: "Done", blocked: "Blocked", deferred: "Deferred"}; + const outcomes: Record = zh + ? {replan: "转向", continue: "继续", stop: "停止", wait: "等待", no_change: "无变化", ask_human: "请人决定"} + : {replan: "Replan", continue: "Continue", stop: "Stop", wait: "Wait", no_change: "No change", ask_human: "Ask human"}; + const time = (value: string) => Number.isFinite(Date.parse(value)) + ? new Intl.DateTimeFormat(zh ? "zh-CN" : "en", {month: "short", day: "numeric", hour: "2-digit", minute: "2-digit"}).format(new Date(value)) : value; return
-

{zh ? "Explore 证据" : "Explore evidence"}

-

{zh ? "已保存的观察与结论,包括历史记录。保存不代表已验证或被后续决策采用。" : "Saved observations and conclusions, including history. Saving does not establish validation or adoption by later decisions."}

-
+

{zh ? "已保存的结论" : "Saved findings"}

+ +

{zh ? "来源记录的观察与结论,保存不代表已经验证或被后续决策采用。关联任务是原记录的显式关联。" : "Source-recorded observations and conclusions. Saving does not establish validation or adoption. Linked tasks are explicit source relationships."}

+
+
{busy ?

{zh ? "正在读取证据…" : "Reading evidence…"}

: null} {error ?

{zh ? "无法核验此页证据;旧内容已清除。请刷新重试。" : "Cannot verify this evidence page; previous content was cleared. Refresh to retry."}

: null} {page && !page.items.length ?

{zh ? "尚无已保存的结论。" : "No saved findings yet."}

: null} {page && page.items.length > 0 ?
- {selected ?
+ {selected ?

{selected.question || selected.finding}

- {selected.scope ?

{zh ? "问题范围" : "Question scope"}: {selected.scope}

: null} + {statuses[selected.status] ?? selected.status} {selected.measurements ? : null} -

{selected.summary}

-

{zh ? "记录状态" : "Recorded status"}: {selected.status}

-

{zh ? "记录者" : "Recorded by"}: {selected.agent_id || "—"}

-

{zh ? "来源引用" : "Source references"}: {selected.evidence_refs.length ? selected.evidence_refs.join(" · ") : "—"}

-

{zh ? "关联任务" : "Linked tasks"}

-

{zh ? "来自当前及已完成任务的显式关联,不代表后续决策已采用此结论。" : "Explicit links from current and completed tasks; they do not establish adoption by later decisions."}

- {selected.linked_todos.length ?
    {selected.linked_todos.map(todo =>
  • -

    {todo.text}

    {todo.status} · {todo.claimed_by || "—"} -
  • )}
:

{zh ? "当前及已完成任务中未找到关联。" : "No links found in current or completed tasks."}

} -
{zh ? "记录标识" : "Record identifiers"} - {selected.finding_id} · {selected.node_id}
{selected.linked_todos.map(todo => todo.todo_id).join(" · ")}
+ {sections ?
+
{zh ? "观察" : "Observation"}

{sections.observation}

+ {sections.interpretation && !sections.decision ?
{zh ? "解读" : "Interpretation"}

{sections.interpretation}

: null} + {sections.decision ?
{zh ? "决策" : "Decision"} · {outcomes[sections.decision.outcome] ?? sections.decision.outcome}
+ {(["changed", "stopped", "retained"] as const).map(key => sections.decision![key].length ?
+
{({changed: zh ? "变化" : "Changed", stopped: zh ? "停止" : "Stopped", retained: zh ? "保留" : "Retained"})[key]}
+
    {sections.decision![key].map((text, index) =>
  • {text}
  • )}
+
: null)}
: null} +
:

{selected.summary}

} +
{zh ? "来源与范围" : "Sources and scope"} +
{zh ? "记录者" : "Recorded by"}
{selected.agent_id || "—"}
+ {selected.scope ? <>
{zh ? "问题范围" : "Question scope"}
{selected.scope}
: null} + {sections ? <>
{zh ? "适用范围" : "Applicability"}
{sections.applicability}
{zh ? "输入版本" : "Input revision"}
{sections.inputRevision}
: null} +
{zh ? "来源引用" : "Source references"}
{selected.evidence_refs.length ?
    {selected.evidence_refs.map((ref, index) =>
  • {ref}
  • )}
: "—"}
+
{zh ? "记录标识" : "Record identifiers"}
{selected.finding_id} · {selected.node_id}
+ {sections ?
{zh ? "完整原文" : "Full original text"}

{selected.summary}

: null} +
+
{zh ? "关联任务" : "Linked tasks"} · {selected.linked_todos.length} + {selected.linked_todos.length ?
    {selected.linked_todos.map(todo =>
  • +
    {todo.text}{statuses[todo.status] ?? todo.status} · {todo.claimed_by || "—"} +

    {todo.text}

    {todo.todo_id}
    +
  • )}
:

{zh ? "当前及已完成任务中未找到关联。" : "No links found in current or completed tasks."}

}
: null}
: null} diff --git a/apps/presentation/dashboard/src/features/personal-workspace/goal-overview.css b/apps/presentation/dashboard/src/features/personal-workspace/goal-overview.css index fc74ef6558..e7f575471a 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/goal-overview.css +++ b/apps/presentation/dashboard/src/features/personal-workspace/goal-overview.css @@ -212,3 +212,12 @@ .goal-overview-evidence[open] > summary::before { content: "▾"; } .goal-overview-evidence > summary small { color: var(--pw-muted); font-size: var(--text-caption); font-weight: 400; } .goal-overview-evidence > summary:focus-visible { outline: 2px solid var(--pw-blue); outline-offset: 3px; } + +.goal-overview .goal-overview-research-entry { + display: flex; align-items: center; justify-content: space-between; gap: 16px; + width: 100%; min-height: 56px; padding: 16px 0; margin: 16px 0; + text-align: start; color: var(--pw-text); border-block: 1px solid var(--pw-line); +} +.goal-overview .goal-overview-research-entry > span { display: flex; align-items: baseline; flex-wrap: wrap; gap: 8px 20px; } +.goal-overview .goal-overview-research-entry > span > span { color: var(--pw-muted); font-size: var(--text-body); } +.goal-overview .goal-overview-research-entry:hover { color: var(--pw-blue-ink); } diff --git a/apps/presentation/dashboard/src/features/personal-workspace/goal-overview.tsx b/apps/presentation/dashboard/src/features/personal-workspace/goal-overview.tsx index e7050c9a01..c1e8b71d09 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/goal-overview.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/goal-overview.tsx @@ -8,7 +8,6 @@ import { relativeTime } from "./goal-activity-view"; import { goalAcceptanceContractCopy } from "./delivery-review-copy"; import type {GoalWorkspaceDigest} from "../../../../../../loopx/control_plane/presentation/goal_workspace_digest.js"; import { GoalAcceptanceObservationCard } from "./goal-acceptance-observation-card"; -import { GoalResearchHistory } from "./goal-research-history"; import { GoalAcceptanceContractSection } from "./goal-acceptance-contract"; import type { WorkspaceActionPreview, WorkspaceActionPreviewRequest, WorkspaceDrawerSelection, WorkspaceGoal, WorkspaceGoalTab, WorkspaceModel, WorkspaceTimelineItem } from "./personal-workspace-model"; import { useWorkspaceI18n } from "./i18n"; @@ -29,8 +28,7 @@ function relativeLookLabel(iso: string, locale: string): string { return new Intl.DateTimeFormat(locale === "zh-CN" ? "zh-CN" : "en", { month: "short", day: "numeric" }).format(then); } -export function GoalOverview({ active, goal, items, userTodos, readOnly, onOpenDetails, onSelect, onView, selectedTodoId, board, results, proposals, onPreview, historyRefreshRevision, readSourceUrl, review, digest, railOpen, onCloseRail, onOpenAgentConversation, sinceYouLooked, onMarkSeen }: { - readSourceUrl?: string; +export function GoalOverview({ active, goal, items, userTodos, readOnly, onOpenDetails, onSelect, onView, selectedTodoId, board, results, proposals, onPreview, historyRefreshRevision, review, digest, railOpen, onCloseRail, onOpenAgentConversation, sinceYouLooked, onMarkSeen }: { onOpenAgentConversation?: (agentId: string) => void; /** Material changes since the owner's last acknowledged look; null when none. */ sinceYouLooked?: SinceYouLookedDelta | null; onMarkSeen?: () => void; @@ -110,7 +108,9 @@ export function GoalOverview({ active, goal, items, userTodos, readOnly, onOpenD {moreAttention ? : null}
: null} - {!readOnly || readSourceUrl ? : null} + {copy.remote}

: }/> diff --git a/apps/presentation/dashboard/src/features/personal-workspace/goal-research-history.css b/apps/presentation/dashboard/src/features/personal-workspace/goal-research-history.css index e01cd3432b..e459584e0f 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/goal-research-history.css +++ b/apps/presentation/dashboard/src/features/personal-workspace/goal-research-history.css @@ -1,4 +1,4 @@ -.research-history { margin-block: 0; border-block: 1px solid var(--pw-line); padding-block: 24px; min-width: 0; } +.research-history { margin-block: 0; padding-block: 8px 24px; min-width: 0; } .research-history-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; } .research-history-heading h2 { font-size: var(--text-heading); font-weight: 600; margin: 0 0 4px; } .research-history-heading > div:first-child { min-width: 0; } @@ -16,7 +16,7 @@ .research-history-edges path { fill: none; stroke: var(--pw-muted); stroke-width: 1.2; } .research-history-edges path.is-grouping { stroke: var(--pw-faint); stroke-dasharray: 3 4; } .research-history-edges path.is-refutation { stroke: var(--pw-red); } -.goal-overview .research-history-node { position: absolute; width: 206px; height: 112px; padding: 12px; display: flex; flex-direction: column; align-items: stretch; justify-content: flex-start; gap: 6px; color: var(--pw-text); background: var(--pw-bg); border: 1px solid var(--pw-line); border-radius: 8px; text-align: left; font: inherit; cursor: pointer; } +.research-history .research-history-node { position: absolute; width: 206px; height: 112px; padding: 12px; display: flex; flex-direction: column; align-items: stretch; justify-content: flex-start; gap: 6px; color: var(--pw-text); background: var(--pw-bg); border: 1px solid var(--pw-line); border-radius: 8px; text-align: left; font: inherit; cursor: pointer; } .research-history-node:hover { border-color: var(--pw-muted); } .research-history-node.is-selected { border-color: var(--pw-text); outline: 1px solid var(--pw-text); } .research-history-node.is-blocked { border-left: 3px solid var(--pw-caution); } @@ -59,10 +59,9 @@ .research-timeline-guide { display: flex; align-items: center; gap: 4px; color: var(--pw-muted); font-size: var(--text-caption); } .research-timeline-guide > .research-link { margin-inline-start: auto; } .research-phases { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; } -.goal-overview .research-phase { display: flex; flex-direction: column; align-items: stretch; gap: 8px; min-width: 0; padding: 14px; border: 1px solid var(--pw-line); border-radius: 10px; background: var(--pw-bg); color: var(--pw-text); font: inherit; text-align: left; cursor: pointer; } -.goal-overview .research-phase:hover { border-color: var(--pw-line-strong); } -.goal-overview .research-phase.is-late { background: var(--pw-blue-soft); border-color: var(--pw-blue); } -.goal-overview .research-phase.is-active { outline: 2px solid var(--pw-text); outline-offset: -1px; } +.research-history .research-phase { display: flex; flex-direction: column; align-items: stretch; gap: 8px; min-width: 0; padding: 14px; border: 1px solid var(--pw-line); border-radius: 10px; background: var(--pw-bg); color: var(--pw-text); font: inherit; text-align: left; cursor: pointer; } +.research-history .research-phase:hover { border-color: var(--pw-line-strong); } +.research-history .research-phase.is-active { outline: 2px solid var(--pw-text); outline-offset: -1px; } .research-phase-head { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; font-size: var(--text-body); } .research-phase-head time { color: var(--pw-muted); font-size: var(--text-caption); white-space: nowrap; } .research-phase-counts { display: flex; align-items: baseline; gap: 4px; font-size: var(--text-caption); color: var(--pw-muted); } @@ -74,8 +73,7 @@ .research-phase-legend > span { display: inline-flex; align-items: center; gap: 4px; } .research-phase-legend i { display: inline-block; width: 8px; height: 8px; border-radius: 2px; background: var(--research-tone); } .research-phase-decisions { font-size: var(--text-caption); color: var(--pw-text); } -.research-phase-line { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; font-size: var(--text-caption); color: var(--pw-muted); line-height: var(--leading-caption); } -.research-phase-line em, .research-trail-list em, .research-finding-sections dt { font-style: normal; font-weight: 600; color: var(--pw-text); margin-right: 6px; } +.research-trail-list em, .research-finding-sections dt { font-style: normal; font-weight: 600; color: var(--pw-text); margin-right: 6px; } .is-confirmed { --research-tone: var(--pw-green); } .is-refuted { --research-tone: var(--pw-red); } .is-tentative { --research-tone: var(--pw-amber); } @@ -109,25 +107,25 @@ .research-route-track { position: relative; height: 44px; } .research-route-track svg { position: absolute; inset: 0; width: 100%; height: 100%; overflow: visible; } .research-route-line { stroke: var(--pw-line-strong); stroke-width: 2; vector-effect: non-scaling-stroke; } -.goal-overview .research-timeline-marker { position: absolute; top: 10px; width: 44px; height: 44px; min-width: 44px; min-height: 44px; margin-left: -22px; padding: 0; border-radius: 6px; border: 0; background: transparent; cursor: pointer; } -.goal-overview .research-timeline-marker::after { content: ""; position: absolute; top: 50%; left: 50%; width: 14px; height: 14px; border: 2px solid var(--research-tone); border-radius: 50%; background: var(--research-tone); transform: translate(-50%, -50%); } -.goal-overview .research-timeline-marker.is-open::after { background: var(--pw-bg); } -.goal-overview .research-timeline-marker.is-small { opacity: .85; } -.goal-overview .research-timeline-marker.is-small::after { width: 9px; height: 9px; border-width: 1px; } -.goal-overview .research-timeline-marker.is-cluster::after { width: 24px; height: 24px; border: 2px solid var(--pw-text); background: var(--pw-bg); } +.research-history .research-timeline-marker { position: absolute; top: 10px; width: 44px; height: 44px; min-width: 44px; min-height: 44px; margin-left: -22px; padding: 0; border-radius: 6px; border: 0; background: transparent; cursor: pointer; } +.research-history .research-timeline-marker::after { content: ""; position: absolute; top: 50%; left: 50%; width: 14px; height: 14px; border: 2px solid var(--research-tone); border-radius: 50%; background: var(--research-tone); transform: translate(-50%, -50%); } +.research-history .research-timeline-marker.is-open::after { background: var(--pw-bg); } +.research-history .research-timeline-marker.is-small { opacity: .85; } +.research-history .research-timeline-marker.is-small::after { width: 9px; height: 9px; border-width: 1px; } +.research-history .research-timeline-marker.is-cluster::after { width: 24px; height: 24px; border: 2px solid var(--pw-text); background: var(--pw-bg); } .research-timeline-marker.is-cluster > span { position: absolute; inset: 0; display: grid; place-items: center; z-index: 1; color: var(--pw-text); font-size: var(--text-caption); } -.goal-overview .research-timeline-marker.is-selected::after { box-shadow: 0 0 0 3px var(--pw-bg), 0 0 0 5px var(--pw-text); } -.goal-overview .research-timeline-marker.is-selected { opacity: 1; z-index: 1; } -.goal-overview .research-timeline-marker.is-dimmed, .research-decision-ticks .is-dimmed { opacity: .2; } -.goal-overview .research-timeline-marker:hover::after { transform: translate(-50%, -50%) scale(1.25); } +.research-history .research-timeline-marker.is-selected::after { box-shadow: 0 0 0 3px var(--pw-bg), 0 0 0 5px var(--pw-text); } +.research-history .research-timeline-marker.is-selected { opacity: 1; z-index: 1; } +.research-history .research-timeline-marker.is-dimmed, .research-decision-ticks .is-dimmed { opacity: .2; } +.research-history .research-timeline-marker:hover::after { transform: translate(-50%, -50%) scale(1.25); } .research-swarm { position: relative; height: 64px; border-bottom: 1px solid var(--pw-line); } .research-trail { border-top: 1px solid var(--pw-line); padding-top: 12px; } .research-trail h3 { display: flex; align-items: baseline; gap: 10px; margin: 0 0 10px; font-size: var(--text-body); font-weight: 600; } .research-trail h3 small { color: var(--pw-muted); font-size: var(--text-caption); font-weight: 400; } .research-trail-list { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; margin: 0; padding: 0; list-style: none; } -.goal-overview .research-trail-list button { display: flex; flex-direction: column; align-items: stretch; gap: 4px; width: 100%; height: 100%; padding: 12px; border: 1px solid var(--pw-line); border-radius: 10px; background: var(--pw-bg); color: var(--pw-text); font: inherit; font-size: var(--text-caption); line-height: var(--leading-caption); text-align: left; cursor: pointer; } -.goal-overview .research-trail-list button:hover { border-color: var(--pw-line-strong); } -.goal-overview .research-trail-list button.is-selected { border-color: var(--pw-text); outline: 1px solid var(--pw-text); } +.research-history .research-trail-list button { display: flex; flex-direction: column; align-items: stretch; gap: 4px; width: 100%; height: 100%; padding: 12px; border: 1px solid var(--pw-line); border-radius: 10px; background: var(--pw-bg); color: var(--pw-text); font: inherit; font-size: var(--text-caption); line-height: var(--leading-caption); text-align: left; cursor: pointer; } +.research-history .research-trail-list button:hover { border-color: var(--pw-line-strong); } +.research-history .research-trail-list button.is-selected { border-color: var(--pw-text); outline: 1px solid var(--pw-text); } .research-trail-list strong { font-size: var(--text-body); line-height: var(--leading-body); font-weight: 600; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; } .research-trail-list span:not(.research-trail-meta) { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; } .research-trail-list .is-muted, .research-finding-sections .is-muted { color: var(--pw-muted); } @@ -137,18 +135,18 @@ .research-outcome.is-stop, .research-outcome.is-ask_human { background: var(--pw-amber-bg); color: var(--pw-text); } .research-link { padding: 0; border: 0; background: none; color: var(--pw-blue-ink); font: inherit; text-decoration: underline; cursor: pointer; } .research-trail > .research-link { margin-top: 8px; font-size: var(--text-caption); } -.research-finding-sections { display: grid; grid-template-columns: max-content minmax(0, 1fr); gap: 4px 10px; margin: 8px 0; font-size: var(--text-caption); line-height: var(--leading-caption); } +.research-finding-sections { display: grid; grid-template-columns: max-content minmax(0, 1fr); gap: 8px 16px; margin: 16px 0; font-size: var(--text-lead); line-height: var(--leading-prose); } .research-finding-sections dd { margin: 0; min-width: 0; } .research-finding-sections ul { margin: 0; padding-left: 16px; } .research-unlinked-days { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 8px; } -.goal-overview .research-unlinked-days button { display: flex; justify-content: space-between; align-items: center; gap: 8px; padding: 10px 12px; border: 1px solid var(--pw-line); border-radius: 6px; background: var(--pw-bg); color: var(--pw-text); text-align: left; font: inherit; font-size: var(--text-caption); } +.research-history .research-unlinked-days button { display: flex; justify-content: space-between; align-items: center; gap: 8px; padding: 10px 12px; border: 1px solid var(--pw-line); border-radius: 6px; background: var(--pw-bg); color: var(--pw-text); text-align: left; font: inherit; font-size: var(--text-caption); } .research-unlinked-days time { color: var(--pw-muted); } .research-unlinked-days strong { font-weight: 500; } -.goal-overview .research-unlinked-days .is-selected { outline: 2px solid var(--pw-text); } -.goal-overview .research-unlinked-days .is-dimmed { opacity: .4; } +.research-history .research-unlinked-days .is-selected { outline: 2px solid var(--pw-text); } +.research-history .research-unlinked-days .is-dimmed { opacity: .4; } .research-decision-ticks .is-stop, .research-decision-ticks .is-wait, .research-decision-ticks .is-ask_human, .research-tick-key.is-stop, .research-tick-key.is-wait, .research-tick-key.is-ask_human { background: var(--pw-amber); } .research-decision-ticks .is-no_change, .research-tick-key.is-no_change { background: var(--pw-muted); } @media (max-width: 640px) { .research-unlinked-days { grid-template-columns: repeat(2, minmax(0, 1fr)); } } -@media (max-width: 900px) { .research-phases, .research-trail-list { grid-template-columns: 1fr; } .research-timeline-grid { grid-template-columns: minmax(0, 1fr); } .research-row-label { flex-direction: row; flex-wrap: wrap; gap: 8px; } .research-row-plot { margin-bottom: 8px; } } -@media (prefers-reduced-motion: reduce) { .goal-overview .research-timeline-marker:hover::after { transform: translate(-50%, -50%); } } +@media (max-width: 900px) { .research-phases { grid-template-columns: repeat(3, minmax(260px, 1fr)); overflow-x: auto; padding: 2px; scroll-snap-type: x proximity; } .research-phase { scroll-snap-align: start; } .research-trail-list { grid-template-columns: 1fr; } .research-timeline-grid { grid-template-columns: minmax(0, 1fr); } .research-row-label { flex-direction: row; flex-wrap: wrap; gap: 8px; } .research-row-plot { margin-bottom: 8px; } } +@media (prefers-reduced-motion: reduce) { .research-history .research-timeline-marker:hover::after { transform: translate(-50%, -50%); } } 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 d24b548d5c..dc83ee32d4 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 @@ -26,6 +26,16 @@ const selectedFromUrl = (goal: string): Selection => { return {nodes: day === null ? [...new Set(nodes.filter(Boolean))] : [], day}; }; +const viewFromUrl = (goal: string) => { + const query = new URLSearchParams(window.location.search); + const sameGoal = !query.has("research_goal") || query.get("research_goal") === goal; + const phase = query.get("research_phase"); + return { + lens: sameGoal && query.get("research_lens") === "graph" ? "graph" as const : "timeline" as const, + phase: sameGoal && phase !== null && /^[0-2]$/.test(phase) ? Number(phase) : null, + }; +}; + /** An effect-free history consumer. Its source is Explore, not raw sessions or * a judge. All recorded updates survive; unsupported analysis stays unavailable. */ export function GoalResearchHistory({goalId, active, zh, refreshRevision = 0, readSourceUrl}: {goalId: string; active: boolean; zh: boolean; refreshRevision?: number; readSourceUrl?: string}) { @@ -38,9 +48,8 @@ export function GoalResearchHistory({goalId, active, zh, refreshRevision = 0, re const [selection, setSelection] = useState(() => selectedFromUrl(goalId)); const [recordsOpen, setRecordsOpen] = useState(false); const [recordLimit, setRecordLimit] = useState(50); - const [lens, setLens] = useState(() => new URLSearchParams(window.location.search).get("research_lens") === "graph" ? "graph" : "timeline"); - const [phase, setPhase] = useState(null); - useEffect(() => {setPhase(null);}, [goalId]); + const [lens, setLens] = useState(() => viewFromUrl(goalId).lens); + const [phase, setPhase] = useState(() => viewFromUrl(goalId).phase); const presentationId = useId().replace(/:/g, ""); const detail = useRef(null); const graph = useRef(null); @@ -103,7 +112,10 @@ export function GoalResearchHistory({goalId, active, zh, refreshRevision = 0, re const updateRecords = (events: readonly ExploreHistoryEvent[]) =>
    {events.map(event =>
  1. {stateLabel(event.status)} · {event.title}{event.summary ? ` — ${event.summary}` : ""}{event.measurements ? : null}{event.parent_id ? {copy.group}: {event.parent_id} : null}{event.evidence_refs.map(ref => {ref})}{event.run_id ? {event.run_id} : null}
  2. )}
; useEffect(() => { - const pop = () => setSelection(selectedFromUrl(goalId)); + const pop = () => { + setSelection(selectedFromUrl(goalId)); + const view = viewFromUrl(goalId); setLens(view.lens); setPhase(view.phase); + }; window.addEventListener("popstate", pop); return () => window.removeEventListener("popstate", pop); }, [goalId]); @@ -157,7 +169,16 @@ export function GoalResearchHistory({goalId, active, zh, refreshRevision = 0, re const grouped = useMemo(() => groupUnlinkedHistoryQuestions(value?.nodes ?? [], value?.edges ?? []), [value]); const layout = useMemo(() => layoutExploreHistory(grouped.nodes, value?.edges ?? []), [grouped, value]); const timeline = useMemo(() => value ? buildExploreTimeline(value.nodes, value.edges, value.events) : null, [value]); - const chooseLens = (next: Lens) => { setLens(next); const url = new URL(window.location.href); if (next === "graph") url.searchParams.set("research_lens", "graph"); else url.searchParams.delete("research_lens"); window.history.replaceState(window.history.state, "", url); }; + const chooseView = (next: Lens, nextPhase: number | null) => { + setLens(next); setPhase(nextPhase); + const url = new URL(window.location.href); + url.searchParams.set("research_goal", goalId); + url.searchParams.set("research_lens", next); + if (nextPhase === null) url.searchParams.delete("research_phase"); + else url.searchParams.set("research_phase", String(nextPhase)); + if (next !== lens || nextPhase !== phase) window.history.pushState(null, "", url); + }; + const chooseLens = (next: Lens) => chooseView(next, phase); const fullTime = (at: number) => new Intl.DateTimeFormat(zh ? "zh-CN" : "en", {month: "numeric", day: "numeric", hour: "2-digit", minute: "2-digit", hour12: false}).format(new Date(at)); const dayTime = (at: number) => new Intl.DateTimeFormat(zh ? "zh-CN" : "en", {month: "numeric", day: "numeric"}).format(new Date(at)); const positions = new Map(layout.map(item => [item.node.id, item])); @@ -177,7 +198,7 @@ export function GoalResearchHistory({goalId, active, zh, refreshRevision = 0, re else url.searchParams.set("research_node", next.nodes.length === 1 ? next.nodes[0]! : JSON.stringify(next.nodes)); url.searchParams.set("research_goal", goalId); } - else url.searchParams.delete("research_goal"); + else url.searchParams.set("research_goal", goalId); if (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 (focus && (next.nodes.length || next.day !== null)) window.requestAnimationFrame(() => { const panel = detail.current; @@ -185,7 +206,6 @@ export function GoalResearchHistory({goalId, active, zh, refreshRevision = 0, re }); }; const choose = (id: string, focus = true) => select({nodes: id ? [id] : [], day: null}, focus); - if (!busy && !error && !read.events.length && !analysis.pack && !analysis.snapshot?.catalog.packs.length) return null; return

{copy.title}

{copy.scope} · {copy.time}{zh ? "。" : ". "}{copy.coverage}{zh ? "。" : "."}

{copy.interpretation}{zh ? "。" : "."}

@@ -202,13 +222,14 @@ export function GoalResearchHistory({goalId, active, zh, refreshRevision = 0, re
{error || history.error ?

{copy.failure} {error || history.error}

: null} + {!busy && !error && read.total === 0 ?

{zh ? "这个 Goal 尚无已记录的研究历程。" : "This Goal has no recorded research history yet."}

: null} {busy && !read.events.length ?
{copy.loading}…
: null} {value?.nodes.length ?
{lens === "timeline" && timeline ?
- select({nodes: [], day})} phase={phase} onPhase={setPhase} onSelect={id => choose(id)} onSelectMany={nodes => select({nodes, day: null})} timeLabel={fullTime} dayLabel={dayTime}/> + select({nodes: [], day})} phase={phase} onPhase={next => chooseView(lens, next)} onSelect={id => choose(id)} onSelectMany={nodes => select({nodes, day: null})} timeLabel={fullTime} dayLabel={dayTime}/>
:
{[copy.early, copy.middle, copy.late][item.index]} {item.nodes} {copy.items}{item.findings} {copy.findings} `${label(key)} ${item.labels[key] ?? 0}`).join(" · ")}> {LABELS.map(key => item.labels[key] ? {item.labels[key]} : null)} - {LABELS.map(key =>