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
5 changes: 3 additions & 2 deletions apps/presentation/dashboard/src/data/chat.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -2673,9 +2674,9 @@ const exploreResultPageSchema = z.object({
})),
});
export type ExploreResultPage = z.infer<typeof exploreResultPageSchema>;
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<unknown>(`/api/chat/explore-results?${query}`));
const page = exploreResultPageSchema.parse(await requestJson<unknown>(sourceReadApiUrl(readSourceUrl, `/api/chat/explore-results?${query}`)));
if (page.goal_id !== goalId) throw new Error("Explore result Goal mismatch");
return page;
}
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@ export function ChannelHeader({
workspaceGrantLabel,
originalConversationTitle,
requestInventory,
researchControl,
composerVisible = true,
}: {
agents: WorkspaceAgentOption[];
agentWorkActive?: boolean;
Expand All @@ -56,6 +58,8 @@ export function ChannelHeader({
workspaceGrantLabel?: string | null;
originalConversationTitle?: string;
requestInventory?: ReactNode;
researchControl?: ReactNode;
composerVisible?: boolean;
managerChannelBinding?: ManagerChannelBinding | null;
managerChatOpen?: boolean;
managerRuntime?: ManagerRuntimeSessionReadback | null;
Expand Down Expand Up @@ -164,7 +168,7 @@ export function ChannelHeader({

const runtimeControl = scopeUnavailable || agentWorkActive ? null : readOnlySourceLabel ? (
<span className="personal-read-only-source" title={t("header.readOnlySourceDescription", { source: readOnlySourceLabel })}><Eye size={15} />{selectedGoal?.title === readOnlySourceLabel ? null : readOnlySourceLabel}<small>{t("common.readOnly")}</small></span>
) : <>
) : selectedGoal && !composerVisible ? null : <>
{ordinaryConversation ? <WorkspaceSelect
ariaLabel={t("header.selectChatRuntime")}
className="personal-agent-select"
Expand Down Expand Up @@ -282,9 +286,11 @@ export function ChannelHeader({
<nav aria-label={t("header.goalView")} className="personal-goal-tabs">
<button aria-current={selectedGoalTab === "overview" ? "page" : undefined} onClick={() => onSelectGoalTab("overview")} type="button">{t("header.overview")}</button>
<button aria-current={selectedGoalTab === "tasks" ? "page" : undefined} onClick={() => onSelectGoalTab("tasks")} type="button">{t("header.tasks")}</button>
<button aria-current={selectedGoalTab === "research" ? "page" : undefined} onClick={() => onSelectGoalTab("research")} type="button">{t("header.research")}</button>
<button aria-current={selectedGoalTab === "chat" ? "page" : undefined} onClick={() => onSelectGoalTab("chat")} type="button">{t("header.chat")}</button>
<button aria-current={selectedGoalTab === "files" ? "page" : undefined} onClick={() => onSelectGoalTab("files")} type="button">{t("header.files")}</button>
</nav>
{researchControl}
{runtimeControl}
</div>
) : !scopeUnavailable ? (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
};
Expand Down
Original file line number Diff line number Diff line change
@@ -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; }
}
Original file line number Diff line number Diff line change
@@ -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<ExploreResultPage | null>(null);
const [selectedId, setSelectedId] = useState("");
const [busy, setBusy] = useState(false);
const [cursor, setCursor] = useState<string>();
const [revision, setRevision] = useState(0);
const [busy, setBusy] = useState(true);
const [error, setError] = useState(false);
const generation = useRef(0);
const reader = useRef<HTMLDivElement>(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<string, string> = 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<string, string> = 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 <section className="goal-team-results goal-explore-results" aria-label={zh ? "Explore 证据" : "Explore evidence"} aria-busy={busy}>
<header><div><h3>{zh ? "Explore 证据" : "Explore evidence"}</h3>
<p>{zh ? "已保存的观察与结论,包括历史记录。保存不代表已验证或被后续决策采用。" : "Saved observations and conclusions, including history. Saving does not establish validation or adoption by later decisions."}</p></div>
<button style={{flexShrink: 0, whiteSpace: "nowrap"}} type="button" disabled={busy} onClick={() => void load()}><RefreshCw size={14} aria-hidden="true"/>{zh ? "刷新" : "Refresh"}</button></header>
<header><div className="explore-reader-heading"><h3>{zh ? "已保存的结论" : "Saved findings"}</h3>
<ContextHelp label={zh ? "结论与关联说明" : "About findings and links"}>
<p>{zh ? "来源记录的观察与结论,保存不代表已经验证或被后续决策采用。关联任务是原记录的显式关联。" : "Source-recorded observations and conclusions. Saving does not establish validation or adoption. Linked tasks are explicit source relationships."}</p>
</ContextHelp></div>
<button type="button" disabled={busy} aria-label={zh ? "刷新证据" : "Refresh evidence"} onClick={() => setRevision(value => value + 1)}><RefreshCw size={16} aria-hidden/></button></header>
{busy ? <p role="status">{zh ? "正在读取证据…" : "Reading evidence…"}</p> : null}
{error ? <p role="alert">{zh ? "无法核验此页证据;旧内容已清除。请刷新重试。" : "Cannot verify this evidence page; previous content was cleared. Refresh to retry."}</p> : null}
{page && !page.items.length ? <p>{zh ? "尚无已保存的结论。" : "No saved findings yet."}</p> : null}
{page && page.items.length > 0 ? <div className="goal-team-results-layout">
<nav className="goal-team-result-list" aria-label={zh ? "选择结论" : "Choose a finding"}>
{page.items.map(item => <button type="button" key={item.finding_id} aria-pressed={selectedId === item.finding_id}
onClick={() => {setSelectedId(item.finding_id); window.requestAnimationFrame(() => reader.current?.focus());}}>
<span><strong>{item.finding}</strong><small>{item.status} · {item.last_updated_at}</small></span>
onClick={() => {setSelectedId(item.finding_id); window.requestAnimationFrame(() => {reader.current?.focus({preventScroll: true}); reader.current?.scrollIntoView({block: "nearest"});});}}>
<span><strong>{item.finding}</strong><small>{statuses[item.status] ?? item.status} · <time dateTime={item.last_updated_at} title={item.last_updated_at}>{time(item.last_updated_at)}</time></small></span>
</button>)}
{page.next_cursor ? <button type="button" onClick={() => void load(page.next_cursor!)}>{zh ? "下一页" : "Next page"}</button> : null}
{cursor ? <button type="button" onClick={() => setCursor(undefined)}>{zh ? "回到首页" : "First page"}</button> : null}
{page.next_cursor ? <button type="button" onClick={() => setCursor(page.next_cursor!)}>{zh ? "下一页" : "Next page"}</button> : null}
</nav>
{selected ? <div ref={reader} tabIndex={-1} className="goal-team-result-reader" style={{overflowWrap: "anywhere"}}>
{selected ? <div ref={reader} tabIndex={-1} className="goal-team-result-reader" key={selected.finding_id}>
<h4>{selected.question || selected.finding}</h4>
{selected.scope ? <p>{zh ? "问题范围" : "Question scope"}: {selected.scope}</p> : null}
<span className="explore-reader-status">{statuses[selected.status] ?? selected.status}</span>
{selected.measurements ? <ExploreMeasurements measurements={selected.measurements} zh={zh}/> : null}
<p style={{whiteSpace: "pre-wrap", overflowWrap: "anywhere"}}>{selected.summary}</p>
<p>{zh ? "记录状态" : "Recorded status"}: {selected.status}</p>
<p>{zh ? "记录者" : "Recorded by"}: {selected.agent_id || "—"}</p>
<p>{zh ? "来源引用" : "Source references"}: {selected.evidence_refs.length ? selected.evidence_refs.join(" · ") : "—"}</p>
<h4>{zh ? "关联任务" : "Linked tasks"}</h4>
<p>{zh ? "来自当前及已完成任务的显式关联,不代表后续决策已采用此结论。" : "Explicit links from current and completed tasks; they do not establish adoption by later decisions."}</p>
{selected.linked_todos.length ? <ul>{selected.linked_todos.map(todo => <li key={todo.todo_id}>
<p>{todo.text}</p><small>{todo.status} · {todo.claimed_by || "—"}</small>
</li>)}</ul> : <p>{zh ? "当前及已完成任务中未找到关联。" : "No links found in current or completed tasks."}</p>}
<details><summary>{zh ? "记录标识" : "Record identifiers"}</summary>
<small>{selected.finding_id} · {selected.node_id}<br/>{selected.linked_todos.map(todo => todo.todo_id).join(" · ")}</small>
{sections ? <div className="explore-finding-prose">
<section><h5>{zh ? "观察" : "Observation"}</h5><p>{sections.observation}</p></section>
{sections.interpretation && !sections.decision ? <section><h5>{zh ? "解读" : "Interpretation"}</h5><p>{sections.interpretation}</p></section> : null}
{sections.decision ? <section><h5>{zh ? "决策" : "Decision"} · {outcomes[sections.decision.outcome] ?? sections.decision.outcome}</h5>
{(["changed", "stopped", "retained"] as const).map(key => sections.decision![key].length ? <div key={key}>
<h6>{({changed: zh ? "变化" : "Changed", stopped: zh ? "停止" : "Stopped", retained: zh ? "保留" : "Retained"})[key]}</h6>
<ul>{sections.decision![key].map((text, index) => <li key={index}>{text}</li>)}</ul>
</div> : null)}</section> : null}
</div> : <p className="explore-finding-raw">{selected.summary}</p>}
<details className="explore-reader-details"><summary>{zh ? "来源与范围" : "Sources and scope"}</summary>
<dl><dt>{zh ? "记录者" : "Recorded by"}</dt><dd>{selected.agent_id || "—"}</dd>
{selected.scope ? <><dt>{zh ? "问题范围" : "Question scope"}</dt><dd>{selected.scope}</dd></> : null}
{sections ? <><dt>{zh ? "适用范围" : "Applicability"}</dt><dd>{sections.applicability}</dd><dt>{zh ? "输入版本" : "Input revision"}</dt><dd>{sections.inputRevision}</dd></> : null}
<dt>{zh ? "来源引用" : "Source references"}</dt><dd>{selected.evidence_refs.length ? <ul>{selected.evidence_refs.map((ref, index) => <li key={index}><code>{ref}</code></li>)}</ul> : "—"}</dd>
<dt>{zh ? "记录标识" : "Record identifiers"}</dt><dd><code>{selected.finding_id} · {selected.node_id}</code></dd></dl>
{sections ? <details><summary>{zh ? "完整原文" : "Full original text"}</summary><p className="explore-finding-raw">{selected.summary}</p></details> : null}
</details>
<details className="explore-reader-details"><summary>{zh ? "关联任务" : "Linked tasks"} · {selected.linked_todos.length}</summary>
{selected.linked_todos.length ? <ul className="explore-linked-tasks">{selected.linked_todos.map(todo => <li key={todo.todo_id}>
<details><summary><span>{todo.text}</span><small>{statuses[todo.status] ?? todo.status} · {todo.claimed_by || "—"}</small></summary>
<p>{todo.text}</p><code>{todo.todo_id}</code></details>
</li>)}</ul> : <p>{zh ? "当前及已完成任务中未找到关联。" : "No links found in current or completed tasks."}</p>}
</details>
</div> : null}
</div> : null}
Expand Down
Loading
Loading