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
27 changes: 27 additions & 0 deletions apps/presentation/dashboard/src/data/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3026,3 +3026,30 @@ export async function fetchExploreResults(goalId: string, cursor?: string, readS
});
return page;
}

const researchHistoryTimingSchema = z.object({
ok: z.literal(true),
goal_id: z.string(),
source_namespace: z.string().nullable(),
first_recorded_at: z.iso.datetime().nullable(),
last_recorded_at: z.iso.datetime().nullable(),
untimed_record_count: z.number().int().nonnegative(),
strategy_recorded_at: z.iso.datetime().nullable(),
}).refine(value => value.first_recorded_at === null
? value.last_recorded_at === null
: value.last_recorded_at !== null && Date.parse(value.first_recorded_at) <= Date.parse(value.last_recorded_at));
export type ResearchHistoryTiming = z.infer<typeof researchHistoryTimingSchema>;

export async function fetchResearchHistoryTiming(
goalId: string, originalGoalId: string, namespace: string | null, directoryUrl: string | undefined, signal: AbortSignal,
): Promise<ResearchHistoryTiming> {
const statusUrl = namespace ? `/status.json?source_namespace=${namespace}` : directoryUrl;
const query = new URLSearchParams({goal_id: goalId, view: "history-timing"});
const timing = researchHistoryTimingSchema.parse(await requestJson<unknown>(
sourceReadApiUrl(statusUrl, `/api/chat/explore-results?${query}`), {signal},
));
if (timing.goal_id !== originalGoalId || timing.source_namespace !== namespace) {
throw new Error("Research timing source mismatch");
}
return timing;
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import assert from "node:assert/strict";
import { goalExecution, goalObservationGaps, goalWorkKind, presentGoalActivity, taskSessionStatus } from "../../../node_modules/.cache/loopx-goal-activity/goal-activity.js";
import { goalExecution, goalObservationGaps, goalWorkKind, observedLiveTurnClock, presentGoalActivity, taskSessionStatus } from "../../../node_modules/.cache/loopx-goal-activity/goal-activity.js";

// One Goal's session facts, mixed by mode. `updated_at` stands for the time the
// owner last recorded anything for that session.
Expand Down Expand Up @@ -184,3 +184,15 @@ for (const [scenario, activeTurnId, status, done, hasResult, expected] of [
}

console.log("Goal and task execution read-model invariants passed");

const liveHost = {completeness: "complete", threads: [{
hostSurface: "codex-app", state: "turn_open", lastEventAt: recent, turnStartedAt: minutesAgo(8),
}]};
const liveGoal = {activationState: "active", state: "已安排", hostThreadActivity: liveHost,
execution: goalExecution([], "goal-a", liveHost, now)};
assert.deepEqual(observedLiveTurnClock(liveGoal, now), {lastActivityAt: recent, startedAt: minutesAgo(8)});
assert.equal(observedLiveTurnClock(liveGoal, now + 16 * 60_000), null, "An old open observation cannot keep a card live");
assert.equal(observedLiveTurnClock({...liveGoal, activationState: "stopped"}, now), null);
assert.equal(observedLiveTurnClock({...liveGoal, execution: claim}, now), null, "A claim does not begin execution");
assert.deepEqual(observedLiveTurnClock({...liveGoal, hostThreadActivity: undefined}, now),
{lastActivityAt: recent, startedAt: null}, "A session age cannot substitute for a current turn start");
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ export type GoalHostThread = {
/** Host-owned read failure, distinct from the cadence window's reason. */
reason?: string | null;
lastEventAt: string | null;
turnStartedAt?: string | null;
deliveryWindow?: {
state: "fresh" | "stale" | "missing" | "unknown";
reason?: string | null;
Expand Down Expand Up @@ -244,6 +245,21 @@ export function presentGoalActivity(goal: GoalActivityInput): GoalActivity {
const identityHues = [212, 262, 330, 24, 150, 190, 44, 290] as const;

/** Stable, readable identity for scanning; never a status signal. */
/** The most recent live fact, with a start only if tied to that observed host turn. */
export function observedLiveTurnClock(goal: GoalActivityInput, now = Date.now()) {
if (!presentGoalActivity(goal).live || goal.execution?.kind !== "running") return null;
const parseClock = (value: string | null | undefined) => value && /(?:Z|[+-]\d{2}:?\d{2})$/i.test(value)
? Date.parse(value) : Number.NaN;
const lastActivityAt = goal.execution.lastActivityAt;
const last = parseClock(lastActivityAt);
if (!lastActivityAt || !Number.isFinite(last) || last > now || now - last > quietTurnMinutes * 60_000) return null;
const matchingThreads = openHostTurns(goal.hostThreadActivity?.threads ?? [], now)
.filter(thread => thread.lastEventAt === lastActivityAt);
const start = matchingThreads.length === 1 ? matchingThreads[0]?.turnStartedAt : null;
const startMs = parseClock(start);
return {lastActivityAt, startedAt: Number.isFinite(startMs) && startMs <= last ? start ?? null : null};
}

export function goalIdentity(goalId: string, title: string): { glyph: string; hue: number } {
let hash = 2_166_136_261;
for (const character of goalId) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,15 @@ import {GoalResearchHistory} from "./goal-research-history";
import {GoalExploreResults} from "./goal-explore-results";
import {goalHierarchy} from "./goal-hierarchy";
import type {WorkspaceGoal} from "./personal-workspace-model";
import {ResearchChildGoals, type ResearchChildGoal} from "./research-child-goals";
import './research-case-study.css';

type ResearchGoal = Pick<WorkspaceGoal, 'goalId' | 'parentGoalId' | 'title' | 'sourceAccess' | 'sourceStrategy'>;
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}: {
export function ResearchGoalNavigation({goalId, goals, zh, onSelect, directoryUrl, active, refreshRevision}: {
goalId: string; goals: ResearchGoal[]; zh: boolean; onSelect: (goalId: string) => void;
directoryUrl?: string; active: boolean; refreshRevision: number;
}) {
const selected = goals.find(g => g.goalId === goalId);
if (!selected) return null;
Expand All @@ -21,18 +23,20 @@ export function ResearchGoalNavigation({goalId, goals, zh, onSelect}: {
if (!parent && !children.length) return null;
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 ? <><h2>{zh ? '子 Goal 研究' : 'Research in child Goals'}</h2>
<div className="research-child-goals">{children.map(child => <button type="button" key={child.goalId} onClick={() => onSelect(child.goalId)}>{child.title}<span aria-hidden="true">↗</span></button>)}</div></> : null}
{children.length ? <ResearchChildGoals goals={children} zh={zh} onSelect={onSelect}
directoryUrl={directoryUrl} active={active} 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; 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}) {
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.goals && props.onSelectGoal ? <ResearchGoalNavigation goalId={props.goalId} goals={props.goals} zh={props.zh} onSelect={props.onSelectGoal}/> : null}
{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}/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -915,7 +915,11 @@ export function PersonalWorkspacePage({
const { locale, t } = useWorkspaceI18n();
const [localGoalId, setLocalGoalId] = useState<string | null>(controlledGoalId ?? null);
const sourceGoal = model.goals.find(goal => goal.goalId === (controlledGoalId === undefined ? localGoalId : controlledGoalId));
const researchReadSourceUrl = sourceGoal?.sourceAccess ? `/status.json?source_namespace=${sourceGoal.sourceAccess.namespace}` : statusSourceControl?.activeSource.kind === "runtime" && statusSourceControl.connectionState !== "loading" && statusSourceControl.connectionState !== "error" ? statusSourceControl.activeSource.statusUrl : undefined;
const directoryReadSourceUrl = statusSourceControl?.activeSource.kind === "runtime"
&& statusSourceControl.connectionState !== "loading" && statusSourceControl.connectionState !== "error"
? statusSourceControl.activeSource.statusUrl : undefined;
const researchReadSourceUrl = sourceGoal?.sourceAccess
? `/status.json?source_namespace=${sourceGoal.sourceAccess.namespace}` : directoryReadSourceUrl;
const readOnly = sourceReadOnly || sourceGoal?.sourceAccess?.readOnly === true;
const hostReadOnly = statusSourceControl?.activeSource.readOnly ?? sourceReadOnly;
const [localAgentId, setLocalAgentId] = useState(controlledAgentId ?? agents.find((agent) => agent.available)?.agentId ?? "codex");
Expand Down Expand Up @@ -2972,7 +2976,7 @@ export function PersonalWorkspacePage({
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} />
? <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>,
files: goalResults(selectedGoalTab === "files" && !settingsOpen),
chat: agentWorkActive ? <GoalAgentWorkPage key={`${selectionScope}:${selectedWorkAgentId}`} goal={selectedGoal} agentId={selectedWorkAgentId!} readOnly={readOnly}
Expand Down

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import {useEffect, useState} from "react";
import {fetchResearchHistoryTiming, type ResearchHistoryTiming} from "../../data/chat";
import {observedLiveTurnClock} from "./goal-activity";
import type {WorkspaceGoal} from "./personal-workspace-model";

export type ResearchChildGoal = Pick<WorkspaceGoal, "goalId" | "title" | "sourceAccess" |
"activationState" | "state" | "execution" | "hostThreadActivity">;

type SourceTarget = [goalId: string, originalGoalId: string, namespace: string | null];
type TimingRead = {targets: SourceTarget[]; directoryUrl?: string; refreshRevision: number};

function useChildTiming(goals: ResearchChildGoal[], directoryUrl: string | undefined, refreshRevision: number, active: boolean) {
const request: TimingRead = {
targets: goals.map(({goalId, sourceAccess}) =>
[goalId, sourceAccess?.original_goal_id ?? goalId, sourceAccess?.namespace ?? null]),
directoryUrl, refreshRevision,
};
// Status polling can reconstruct the same directory. Only identity or a
// deliberate refresh should restart these reads.
const key = JSON.stringify(request);
const [snapshot, setSnapshot] = useState<{key: string; byGoal: Map<string, ResearchHistoryTiming | null>}>();
useEffect(() => {
if (!active) return;
const {targets, directoryUrl} = JSON.parse(key) as TimingRead;
const controller = new AbortController();
const pending = targets.values();
async function read() {
for (const [goalId, originalGoalId, namespace] of pending) {
if (controller.signal.aborted) return;
const timing = await fetchResearchHistoryTiming(goalId, originalGoalId, namespace, directoryUrl, controller.signal)
.catch(() => null);
if (controller.signal.aborted) return;
setSnapshot(previous => ({
key,
byGoal: new Map(previous?.key === key ? previous.byGoal : undefined).set(goalId, timing),
}));
}
}
// Keep a populated directory responsive without opening every source at once.
for (let worker = 0; worker < Math.min(targets.length, 3); worker++) void read();
return () => controller.abort();
}, [active, key]);
return snapshot?.key === key ? snapshot.byGoal : undefined;
}

const beijingClock = new Intl.DateTimeFormat("sv-SE", {
dateStyle: "short", timeStyle: "short", timeZone: "Asia/Shanghai",
});

function Clock({value}: {value: string}) {
return <time dateTime={value}>{beijingClock.format(new Date(value))}</time>;
}

function spanText(ms: number, zh: boolean) {
const minutes = Math.floor(ms / 60_000);
if (minutes === 0) return zh ? "不足 1 分钟" : "under 1 min";
const hours = Math.floor(minutes / 60);
const remainder = minutes % 60;
return [hours > 0 && (zh ? `${hours} 小时` : `${hours} hr`),
remainder > 0 && (zh ? `${remainder} 分钟` : `${remainder} min`)].filter(Boolean).join(" ");
}

function Timing({goal, timing, zh}: {goal: ResearchChildGoal; timing?: ResearchHistoryTiming | null; zh: boolean}) {
const now = Date.now();
const live = observedLiveTurnClock(goal, now);
if (live?.startedAt) {
return <><span>{zh ? "回合开始" : "Turn started"} <Clock value={live.startedAt}/></span>
<span>{zh ? "进行中" : "In progress"} · {spanText(now - Date.parse(live.startedAt), zh)}</span></>;
}
if (live) {
return <><span>{zh ? "最近活动" : "Last activity"} <Clock value={live.lastActivityAt}/></span>
<span>{zh ? "进行中" : "In progress"}</span></>;
}
if (timing === undefined) return zh ? "读取时间…" : "Reading time…";
if (timing === null) return zh ? "无法读取时间" : "Time unavailable";
const {first_recorded_at: start, last_recorded_at: end, untimed_record_count: untimed, strategy_recorded_at: strategy} = timing;
if (start && end) {
const label = untimed ? (zh ? "已知记录" : "Dated records") : (zh ? "研究记录" : "Research records");
if (start === end) return <span>{label} <Clock value={start}/></span>;
return <><span>{label} <Clock value={start}/></span>
<span>{zh ? "至" : "to"} <Clock value={end}/></span>
<span>{zh ? "记录跨度" : "Recorded span"} {spanText(Date.parse(end) - Date.parse(start), zh)}</span></>;
}
if (strategy) return <span>{zh ? "策略记录" : "Saved strategy"} <Clock value={strategy}/></span>;
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;
onSelect: (goalId: string) => void;
}) {
const timing = useChildTiming(goals, directoryUrl, refreshRevision, active);
return <>
<div className="research-child-heading">
<h2>{zh ? "子 Goal 研究" : "Research in child Goals"}</h2>
<span>{zh ? "北京时间" : "Beijing time (UTC+8)"}</span>
</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>
</span>
<span aria-hidden="true">↗</span>
</button>)}</div>
</>;
}
1 change: 1 addition & 0 deletions apps/presentation/dashboard/src/views/dashboard-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1129,6 +1129,7 @@ function buildPersonalHomeModel(
agentId: thread.agent_id ?? null,
hostSurface: thread.host_surface,
lastEventAt: thread.last_event_at ?? null,
turnStartedAt: thread.turn_started_at ?? null,
state: thread.state,
reason: thread.reason ?? null,
deliveryWindow: thread.delivery_window ? {
Expand Down
Loading
Loading