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
@@ -1,4 +1,4 @@
import {useState} from "react";
import {useState, type ReactNode} from "react";
import {GoalResearchHistory} from "./goal-research-history";
import {GoalExploreResults} from "./goal-explore-results";
import {goalHierarchy} from "./goal-hierarchy";
Expand All @@ -9,9 +9,9 @@ import './research-case-study.css';
type ResearchGoal = ResearchChildGoal & Pick<WorkspaceGoal, 'parentGoalId' | 'sourceStrategy'>;

/** Parentage organizes explicit source views; it never combines evidence or execution scope. */
export function ResearchGoalNavigation({goalId, goals, zh, onSelect, directoryUrl, active, refreshRevision}: {
function ResearchGoalNavigation({goalId, goals, zh, onSelect, directoryUrl, active, showTiming, refreshRevision}: {
goalId: string; goals: ResearchGoal[]; zh: boolean; onSelect: (goalId: string) => void;
directoryUrl?: string; active: boolean; refreshRevision: number;
directoryUrl?: string; active: boolean; showTiming: boolean; refreshRevision: number;
}) {
const selected = goals.find(g => g.goalId === goalId);
if (!selected) return null;
Expand All @@ -24,25 +24,30 @@ export function ResearchGoalNavigation({goalId, goals, zh, onSelect, directoryUr
return <nav className="research-goal-navigation" aria-label={zh ? '研究 Goal 层级' : 'Research Goal hierarchy'}>
{parent ? <button type="button" className="research-parent-goal" onClick={() => onSelect(parent.goalId)}>← {parent.title}</button> : null}
{children.length ? <ResearchChildGoals goals={children} zh={zh} onSelect={onSelect}
directoryUrl={directoryUrl} active={active} refreshRevision={refreshRevision}/> : null}
directoryUrl={directoryUrl} active={active} showTiming={showTiming} refreshRevision={refreshRevision}/> : null}
</nav>;
}

/** One read surface for the selected original Goal; opening it never starts research. */
export function GoalResearchWorkbench(props: {goalId: string; active: boolean; zh: boolean; refreshRevision: number; readSourceUrl?: string; directoryReadSourceUrl?: string; goals?: ResearchGoal[]; onSelectGoal?: (goalId: string) => void; onOpenTasks?: () => void}) {
export function GoalResearchWorkbench(props: {goalId: string; active: boolean; zh: boolean; refreshRevision: number; readSourceUrl?: string; directoryReadSourceUrl?: string; goals?: ResearchGoal[]; onSelectGoal?: (goalId: string) => void; onOpenTasks?: () => void; statusNotice?: ReactNode}) {
const selected = props.goals?.find(goal => goal.goalId === props.goalId);
const source = selected?.sourceAccess;
const [evidenceOpen, setEvidenceOpen] = useState(false);
return <div className="goal-research-workbench">
{props.statusNotice}
{/* The directory supplies navigation while the selected snapshot loads.
A denied or stale snapshot still blocks evidence reads. */}
{props.goals && props.onSelectGoal ? <ResearchGoalNavigation goalId={props.goalId} goals={props.goals}
zh={props.zh} onSelect={props.onSelectGoal} directoryUrl={props.directoryReadSourceUrl}
active={props.active} refreshRevision={props.refreshRevision}/> : null}
<GoalResearchHistory {...props} originalGoalId={source?.original_goal_id}
preferHistory={Boolean(source)} sourceStrategy={selected?.sourceStrategy}
onOpenSourceTasks={source ? props.onOpenTasks : undefined}/>
<details className="research-saved-evidence" open={evidenceOpen} onToggle={event => setEvidenceOpen(event.currentTarget.open)}>
<summary>{props.zh ? "阅读已保存的结论" : "Read saved findings"}</summary>
{evidenceOpen ? <GoalExploreResults originalGoalId={source?.original_goal_id} goalId={props.goalId} zh={props.zh} active={props.active} readSourceUrl={props.readSourceUrl}/> : null}
</details>
active={props.active} showTiming={!props.statusNotice} refreshRevision={props.refreshRevision}/> : null}
{!props.statusNotice ? <>
<GoalResearchHistory {...props} originalGoalId={source?.original_goal_id}
preferHistory={Boolean(source)} sourceStrategy={selected?.sourceStrategy}
onOpenSourceTasks={source ? props.onOpenTasks : undefined}/>
<details className="research-saved-evidence" open={evidenceOpen} onToggle={event => setEvidenceOpen(event.currentTarget.open)}>
<summary>{props.zh ? "阅读已保存的结论" : "Read saved findings"}</summary>
{evidenceOpen ? <GoalExploreResults originalGoalId={source?.original_goal_id} goalId={props.goalId} zh={props.zh} active={props.active} readSourceUrl={props.readSourceUrl}/> : null}
</details>
</> : null}
</div>;
}
Original file line number Diff line number Diff line change
Expand Up @@ -2797,6 +2797,21 @@ export function PersonalWorkspacePage({
onOpenConversation={() => {setSelectedGoalTab("chat"); requestAnimationFrame(() => composerRef.current?.focus());}} />
</Suspense> : undefined}
/>) : null;
const goalStatusNotice = selectedGoal?.loadState ? (
<section className="personal-manager-greeting" role="status" data-testid="goal-status-loading">
<div><strong>{t(selectedGoal.loadState === "error" ? "startup.goalError" : "startup.goalLoading")}</strong>
<p>{t(selectedGoal.loadError ? `startup.error.${selectedGoal.loadError}` : "startup.independent")}</p>
{selectedGoal.loadState === "error" ? selectedGoal.loadError === "authority" && !readOnly ?
<button className="min-h-11 rounded-md border px-3 py-2 text-sm" type="button" onClick={() => openSettings({goalId: selectedGoal.goalId, kind: "settings", tab: "ownership"})}>{t("startup.storageSettings")}</button>
: <button className="min-h-11 rounded-md border px-3 py-2 text-sm" type="button" onClick={() => void callbacks.onRefresh?.("missing")}>{t("startup.retry")}</button> : null}</div>
</section>
) : null;
const researchPanel = selectedGoal && (!readOnly || researchReadSourceUrl)
? <GoalResearchWorkbench goalId={selectedGoal.goalId} goals={workspaceGoals} onSelectGoal={id => selectGoal(id, "research")}
onOpenTasks={() => setSelectedGoalTab("tasks")} active={!settingsOpen && selectedGoalTab === "research"}
zh={locale === "zh-CN"} refreshRevision={historyRefreshRevision} readSourceUrl={researchReadSourceUrl}
directoryReadSourceUrl={directoryReadSourceUrl} statusNotice={goalStatusNotice} />
: goalStatusNotice ?? <ContextHelp label={locale === "zh-CN" ? "来源无研究读取入口" : "Research source unavailable"} icon={CircleHelp}><p role="status">{locale === "zh-CN" ? "此来源尚未提供研究记录的读取入口。" : "This source does not provide a research history endpoint."}</p></ContextHelp>;
return (
<>
<div hidden={settingsOpen}>
Expand Down Expand Up @@ -2957,27 +2972,19 @@ export function PersonalWorkspacePage({
&& !selectedGoal.agentTodos.some(todo => todo.todoId === routedTodoId) ? <ContextHelp label={locale === "zh-CN" ? "链接任务未读到" : "Linked task unavailable"} icon={CircleHelp}><p className="goal-overview-source-note" role="status">
{locale === "zh-CN" ? "链接中的任务未出现在此来源的当前快照中;暂时无法查看其详情。" : "The linked task is not in this source’s current snapshot; its details are unavailable."}
</p></ContextHelp> : null}
{selectedGoal?.loadState ? (
<section className="personal-manager-greeting" role="status" data-testid="goal-status-loading">
<div><strong>{t(selectedGoal.loadState === "error" ? "startup.goalError" : "startup.goalLoading")}</strong>
<p>{t(selectedGoal.loadError ? `startup.error.${selectedGoal.loadError}` : "startup.independent")}</p>
{selectedGoal.loadState === "error" ? selectedGoal.loadError === "authority" && !readOnly ?
<button className="min-h-11 rounded-md border px-3 py-2 text-sm" type="button" onClick={() => openSettings({goalId: selectedGoal.goalId, kind: "settings", tab: "ownership"})}>{t("startup.storageSettings")}</button>
: <button className="min-h-11 rounded-md border px-3 py-2 text-sm" type="button" onClick={() => void callbacks.onRefresh?.("missing")}>{t("startup.retry")}</button> : null}</div>
</section>
) : selectedGoal ? (
{selectedGoal && (!goalStatusNotice || selectedGoalTab === "research") ? (
<GoalWorkBrowseProvider key={workBrowseKey} value={workBrowse} onChange={changeWorkBrowse}>
<GoalWorkspacePanels
activeTab={selectedGoalTab} scrollRef={channelScrollRef} panels={{
activeTab={selectedGoalTab} scrollRef={channelScrollRef} panels={goalStatusNotice ? {
overview: null, tasks: null, research: researchPanel, files: null, chat: null,
} : {
overview: <GoalOverview sinceYouLooked={sinceYouLooked?.goalId === selectedGoal.goalId ? sinceYouLooked.delta : null} onMarkSeen={markSeen} onOpenAgentConversation={callbacks.onSelectWorkAgent} railOpen={railScope === selectionScope} onCloseRail={() => setRailScope(null)} digest={workspaceDigest} review={workspaceReview} active={!settingsOpen && selectedGoalTab === "overview"} goal={selectedGoal} items={items} userTodos={model.userTodos} readOnly={readOnly}
onOpenDetails={() => setSelection({ kind: "goal", item: selectedGoal })} onSelect={setSelection} onView={setSelectedGoalTab}
selectedTodoId={drawerSelection?.kind === "todo" ? drawerSelection.item.todoId : null}
board={taskBoard(!settingsOpen && selectedGoalTab === "overview")}
historyRefreshRevision={historyRefreshRevision} proposals={proposals} onPreview={createPreview} />,
tasks: taskBoard(!settingsOpen && selectedGoalTab === "tasks"),
research: !readOnly || researchReadSourceUrl
? <GoalResearchWorkbench goalId={selectedGoal.goalId} goals={workspaceGoals} onSelectGoal={id => selectGoal(id, "research")} onOpenTasks={() => setSelectedGoalTab("tasks")} active={!settingsOpen && selectedGoalTab === "research"} zh={locale === "zh-CN"} refreshRevision={historyRefreshRevision} readSourceUrl={researchReadSourceUrl} directoryReadSourceUrl={directoryReadSourceUrl} />
: <ContextHelp label={locale === "zh-CN" ? "来源无研究读取入口" : "Research source unavailable"} icon={CircleHelp}><p role="status">{locale === "zh-CN" ? "此来源尚未提供研究记录的读取入口。" : "This source does not provide a research history endpoint."}</p></ContextHelp>,
research: researchPanel,
files: goalResults(selectedGoalTab === "files" && !settingsOpen),
chat: agentWorkActive ? <GoalAgentWorkPage key={`${selectionScope}:${selectedWorkAgentId}`} goal={selectedGoal} agentId={selectedWorkAgentId!} readOnly={readOnly}
sessionId={selectedWorkSessionId} onSession={(id, replace) => callbacks.onSelectWorkSession?.(id, replace)}
Expand Down Expand Up @@ -3005,7 +3012,7 @@ export function PersonalWorkspacePage({
</>),
}} />
</GoalWorkBrowseProvider>
) : !managerChatOpen ? (
) : goalStatusNotice ?? (!managerChatOpen ? (
<ManagerHomeBoard goals={workspaceGoals} attention={model.userTodos} onSelectAttention={openAttention}
onRetry={() => void callbacks.onRefresh?.("missing")} onRetrySource={() => void refreshWorkspace()} onSelectGoal={selectGoal} systemHealth={model.systemHealth}
onboarding={model.onboarding} onRequestGoalCreate={readOnly ? undefined : requestGoalCreate} onSuggestRequest={readOnly ? undefined : suggestReply}
Expand All @@ -3031,7 +3038,7 @@ export function PersonalWorkspacePage({
? (turnId) => callbacks.onInterruptConversationTurn!(conversationKey, turnId)
: undefined}
onOpenGoalEvidence={(goalId) => { selectGoal(goalId, "chat"); }} />
)}
))}
</> : null}
</div>
{!rootUnavailable ? <div className="personal-composer-wrap" hidden={agentWorkActive && !managedWorkActive}>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -85,20 +85,20 @@ function Timing({goal, timing, zh}: {goal: ResearchChildGoal; timing?: ResearchH
return untimed ? (zh ? "记录时间未知" : "Record time unknown") : (zh ? "暂无时间记录" : "No dated records");
}

export function ResearchChildGoals({goals, directoryUrl, refreshRevision, active, zh, onSelect}: {
goals: ResearchChildGoal[]; directoryUrl?: string; refreshRevision: number; active: boolean; zh: boolean;
export function ResearchChildGoals({goals, directoryUrl, refreshRevision, active, showTiming, zh, onSelect}: {
goals: ResearchChildGoal[]; directoryUrl?: string; refreshRevision: number; active: boolean; showTiming: boolean; zh: boolean;
onSelect: (goalId: string) => void;
}) {
const timing = useChildTiming(goals, directoryUrl, refreshRevision, active);
const timing = useChildTiming(goals, directoryUrl, refreshRevision, active && showTiming);
return <>
<div className="research-child-heading">
<h2>{zh ? "子 Goal 研究" : "Research in child Goals"}</h2>
<span>{zh ? "北京时间" : "Beijing time (UTC+8)"}</span>
{showTiming ? <span>{zh ? "北京时间" : "Beijing time (UTC+8)"}</span> : null}
</div>
<div className="research-child-goals">{goals.map(goal => <button type="button" key={goal.goalId} onClick={() => onSelect(goal.goalId)}>
<span className="research-child-body">
<span>{goal.title}</span>
<span className="research-child-timing"><Timing goal={goal} timing={timing?.get(goal.goalId)} zh={zh}/></span>
{showTiming ? <span className="research-child-timing"><Timing goal={goal} timing={timing?.get(goal.goalId)} zh={zh}/></span> : null}
</span>
<span aria-hidden="true">↗</span>
</button>)}</div>
Expand Down
58 changes: 57 additions & 1 deletion examples/workspace-progressive-loading-browser-smoke.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -248,8 +248,64 @@ try {
await archivePage.close();
}
}
const researchPage = await browser.newPage({ viewport: { width: 390, height: 844 } });
researchPage.on("pageerror", (error) => errors.push(error.message));
const researchDirectory = { ...directory, goals: ["parent", "case-a", "case-b", "other"].map((id) => ({
id, display_name: `${id} project`, registry_member: true, activation_state: "active",
...(id.startsWith("case-") ? { parent_goal_id: "parent" } : {}),
})) };
let releaseParent;
const parentGate = new Promise((done) => { releaseParent = done; });
let parentDenied = true;
const exploreReads = [];
try {
await researchPage.route("**/status.json*", async (route) => {
const query = new URL(route.request().url()).searchParams;
if (query.has("view")) return route.fulfill({ json: researchDirectory });
const id = query.get("goal_id");
const row = researchDirectory.goals.find((goal) => goal.id === id);
assert.ok(row);
if (id === "parent") {
await parentGate;
if (parentDenied) return route.fulfill({ status: 500, json: { ok: false, error_code: "workspace_status_access_denied" } });
}
const status = snapshot(id);
Object.assign(status.run_history.goals[0], row);
return route.fulfill({ json: status });
});
await researchPage.route("**/api/**", (route) => route.fulfill({ status: 503, json: { ok: false } }));
await researchPage.route("**/api/chat/explore-results?*", (route) => {
const query = new URL(route.request().url()).searchParams;
exploreReads.push({ goal: query.get("goal_id"), view: query.get("view"), method: route.request().method() });
return route.fulfill({ status: 503, json: { ok: false } });
});
await researchPage.goto(`${origin}/chat/?goalId=parent&view=research`);
const cards = researchPage.locator(".research-child-goals button");
const caseButton = cards.filter({ hasText: "case-a project" });
const notice = researchPage.getByTestId("goal-status-loading");
await caseButton.waitFor();
await notice.waitFor();
assert.equal(await cards.count(), 2, "directory navigation must stay in the selected Goal hierarchy");
assert.deepEqual(exploreReads, [], "the parent directory is enough to navigate, not to read evidence");
await caseButton.press("Enter");
await researchPage.waitForURL((url) => url.searchParams.get("goalId") === "case-a" && url.searchParams.get("view") === "research");
await researchPage.locator(".research-parent-goal").waitFor();
releaseParent();
await researchPage.locator(".research-parent-goal").click();
await notice.getByRole("button").waitFor();
await caseButton.waitFor();
assert.ok(exploreReads.every(({ goal, view, method }) => goal !== "parent" && view !== "history-timing" && method === "GET"));
parentDenied = false;
await notice.getByRole("button").click();
await notice.waitFor({ state: "hidden" });
await caseButton.locator(".research-child-timing").waitFor();
assert.equal(await cards.count(), 2);
} finally {
releaseParent();
await researchPage.close();
}
assert.deepEqual(errors, []);
console.log(JSON.stringify({ ok: true, directory_ms: directoryMs, peak_concurrent_requests: peak, checks: ["directory-before-slow-goal", "ready-peer-usable", "queue-progress", "isolated-failure", "registry-revision-fence", "retry", "service-restart-recovery", "lazy-stopped-goals", "access-no-auto-retry", "access-manual-recovery-preserves-peers", "full-refresh-observes-external-todo", "full-refresh-invalidates-completed-history", "failed-reread-replaces-stale-snapshot", "stopped-deep-link-loading-error-retry-reload", "absent-goal-after-complete-read", "read-only-bookmark-recovery", "bilingual-access", "mobile", "no-render-errors"] }));
console.log(JSON.stringify({ ok: true, directory_ms: directoryMs, peak_concurrent_requests: peak, checks: ["directory-before-slow-goal", "ready-peer-usable", "queue-progress", "isolated-failure", "registry-revision-fence", "retry", "service-restart-recovery", "lazy-stopped-goals", "access-no-auto-retry", "access-manual-recovery-preserves-peers", "full-refresh-observes-external-todo", "full-refresh-invalidates-completed-history", "failed-reread-replaces-stale-snapshot", "stopped-deep-link-loading-error-retry-reload", "absent-goal-after-complete-read", "read-only-bookmark-recovery", "research-navigation-before-parent-snapshot", "research-navigation-during-denial-and-recovery", "bilingual-access", "mobile", "no-render-errors"] }));
} finally {
releaseSlow();
await cleanupBrowserSmoke({ browser, server, fixturePaths: [] });
Expand Down
Loading