diff --git a/.github/workflows/package-smoke.yml b/.github/workflows/package-smoke.yml index 7e2d9399ef..4f9014e42f 100644 --- a/.github/workflows/package-smoke.yml +++ b/.github/workflows/package-smoke.yml @@ -4,6 +4,9 @@ on: pull_request: paths: - ".github/workflows/package-smoke.yml" + - "packages/loopx-core/**" + - "loopx/**" + - "tests/test_core_distribution.py" - "packages/loopx-repo-health/**" - "packages/loopx-obelisk/**" - "packages/loopx-codex-provider-routing/**" @@ -14,6 +17,9 @@ on: - main paths: - ".github/workflows/package-smoke.yml" + - "packages/loopx-core/**" + - "loopx/**" + - "tests/test_core_distribution.py" - "packages/loopx-repo-health/**" - "packages/loopx-obelisk/**" - "packages/loopx-codex-provider-routing/**" @@ -28,6 +34,33 @@ concurrency: cancel-in-progress: true jobs: + core: + name: loopx-core + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-python@v6 + with: + python-version: "3.11" + - uses: actions/setup-node@v6 + with: + node-version: "26.0.0" + - uses: astral-sh/setup-uv@v7 + - name: Prepare source validation environment + run: uv sync --extra test + - name: Build and separately install the standalone core + run: | + uv run --extra test python packages/loopx-core/prepare.py --output "$RUNNER_TEMP/core-source" + uv build "$RUNNER_TEMP/core-source" --out-dir "$RUNNER_TEMP/core-dist" + uv venv "$RUNNER_TEMP/core-env" --python .venv/bin/python + uv pip install --python "$RUNNER_TEMP/core-env/bin/python" "$RUNNER_TEMP"/core-dist/loopx_core-*.whl + echo "LOOPX_CORE_TEST_PYTHON=$RUNNER_TEMP/core-env/bin/python" >> "$GITHUB_ENV" + - name: Qualify installed execution, recovery, replay and refusal + env: + LOOPX_USAGE_PING: "0" + run: uv run --extra test python -m pytest -q tests/test_core_distribution.py + codex-provider-routing: name: loopx-codex-provider-routing runs-on: ubuntu-latest diff --git a/README.md b/README.md index 6aab04842a..eb190b352d 100644 --- a/README.md +++ b/README.md @@ -6,14 +6,14 @@ **Start with one goal. Keep long-running agent work verifiable.** -Tell the Agent you already use what you want done. LoopX keeps the goal, -work, approvals and evidence durable behind the scenes, so the next session can -continue without making you learn LoopX internals first.
-Work with Codex, Claude Code, DeepSeek Harness and other runtimes. Advanced docs still expose the local-first semantic kernel, ownership model and research workbench. +Tell the Agent you already use what you want done. LoopX keeps the goal, work, +needed decisions and verifiable results durable behind the scenes, so the next +session can continue without making you learn LoopX internals first.
+Work with Codex, Claude Code, DeepSeek Harness and other runtimes. Advanced docs still expose the local-first architecture, ownership model and research workbench. [![Release](https://img.shields.io/github/v/release/loopx-project/loopx?color=brightgreen)](https://github.com/loopx-project/loopx/releases/latest) [![License](https://img.shields.io/badge/license-Apache--2.0-blue.svg)](LICENSE) [![TypeScript core](https://img.shields.io/badge/core-TypeScript-3178C6?logo=typescript&logoColor=white)](docs/architecture/rfcs/typescript-control-plane-migration-v0.md) [![Python](https://img.shields.io/badge/python-3.11%2B-blue.svg)](pyproject.toml) -[![Local first](https://img.shields.io/badge/control--plane-local--first-brightgreen.svg)](docs/public-private-boundary.md) [![Loop Agents](https://img.shields.io/badge/status-loop%20agents%20supported-brightgreen.svg)](docs/product/release-readiness.md) [![GitHub contributors](https://img.shields.io/github/contributors-anon/loopx-project/loopx?color=blue)](https://github.com/loopx-project/loopx/graphs/contributors) [![Discord](https://img.shields.io/badge/Discord-Join-5865F2?logo=discord&logoColor=white)](https://discord.gg/XmGgQyCFZd) [![Listed on dsh-plugin.org](https://dsh-plugin.org/badges/listed.svg)](https://dsh-plugin.org/plugins/loopx-project/loopx) +[![Local-first state](https://img.shields.io/badge/local--first-state-brightgreen.svg)](docs/public-private-boundary.md) [![Loop Agents](https://img.shields.io/badge/status-loop%20agents%20supported-brightgreen.svg)](docs/product/release-readiness.md) [![GitHub contributors](https://img.shields.io/github/contributors-anon/loopx-project/loopx?color=blue)](https://github.com/loopx-project/loopx/graphs/contributors) [![Discord](https://img.shields.io/badge/Discord-Join-5865F2?logo=discord&logoColor=white)](https://discord.gg/XmGgQyCFZd) [![Listed on dsh-plugin.org](https://dsh-plugin.org/badges/listed.svg)](https://dsh-plugin.org/plugins/loopx-project/loopx) [Get started](#quick-start) · [Why LoopX](#why-loopx) · [See it in action](#see-loopx-in-action) · [Evidence](#evidence) · [Contribute](#contributing) · [Docs](https://loopx-project.github.io/loopx/docs/) · [简体中文](README.zh-CN.md) @@ -41,9 +41,10 @@ then send one natural-language goal: $loopx speed up this project's slowest workflow; compare approaches and verify the gains ``` -LoopX turns that into visible tasks, gates and evidence as needed. Claude Code -uses `/loopx `; see [host setup](#start-from-your-agent) for other -runtimes. Prefer a guided first run? [Choose a task below](#try-loopx). +LoopX turns that into a visible plan, decisions that need you, and verification +as needed. Claude Code uses `/loopx `; see +[host setup](#start-from-your-agent) for other runtimes. Prefer a guided first +run? [Choose a task below](#try-loopx). ## 📰 News @@ -54,10 +55,10 @@ runtimes. Prefer a guided first run? [Choose a task below](#try-loopx). dates (UTC). --> - **2026-10-25 · 🤝 [Long-horizon agent research: collaborators welcome](https://loopx-project.github.io/loopx/blog/edgebench-feedback-and-memory/)** — Exploring **breakthroughs in long-horizon outcomes under equal budgets**, through feedback, replanning and reproducible evaluation on EdgeBench and real-world tasks. Academic labs and industry teams are welcome to collaborate, comparing outcomes and total cost. [Discuss collaboration](https://github.com/loopx-project/loopx/discussions). -- **2026-10-20 · 🚀 [LoopX 2.0](https://github.com/loopx-project/loopx/releases/tag/v2.0.0)** — A shared TypeScript control plane and long-horizon research workbench. [Migration guide](docs/product/releases/v2.0.0.md) · [Engineering story](https://loopx-project.github.io/loopx/blog/typescript-control-plane-refactor/). +- **2026-10-20 · 🚀 [LoopX 2.0](https://github.com/loopx-project/loopx/releases/tag/v2.0.0)** — A shared TypeScript state layer and long-horizon research workbench. [Migration guide](docs/product/releases/v2.0.0.md) · [Engineering story](https://loopx-project.github.io/loopx/blog/typescript-control-plane-refactor/). - **2026-10-14 · 🤖 [Agentic software](https://loopx-project.github.io/loopx/blog/semantic-software-development/)** — Connecting goals, evidence and human feedback to engineering delivery. - **2026-10-02 · 🧠 [Long-horizon bootstrapping & RSI](https://loopx-project.github.io/loopx/blog/long-horizon-rsi/)** — Four months of developing LoopX with LoopX: public project history and a design perspective on system-level self-improvement. -- **2026-09-18 · 🧪 [LoopX × LHTB](https://loopx-project.github.io/loopx/benchmarks/lhtb/)** — A 46-task, same-model study provides empirical support for **trading more compute for better outcomes**: LoopX Heartbeat mean reward is 17.3% above Plain Codex and 10.6% above native Goal. +- **2026-09-18 · 🧪 [LoopX × LHTB](https://loopx-project.github.io/loopx/benchmarks/lhtb/)** — A 46-task, same-model study provides empirical support for **trading more compute for better outcomes**: LoopX 1.0.3 Heartbeat continued runs reach mean reward 17.3% above Plain Codex and 10.6% above native Goal. - **2026-09-06 · 🎉 [LoopX 1.0](https://github.com/loopx-project/loopx/releases/tag/v1.0.0)** — A Personal Agent Workspace to follow agent work, act on decisions, and inspect delivered results across sessions. [Workspace guide](https://github.com/loopx-project/loopx/blob/v1.0.0/docs/guides/personal-workspace-user-guide.md). - **2026-08-02 · ⏱️ [200+ hour trajectories shared on X](https://x.com/huangruiteng/status/2083904257494024425)** — Early owner-run examples spanning 200+ elapsed hours drew broad attention on X, showing work across execution, waits, human decisions and recovery. [Trajectories and limits](#open-source-issue-fix). @@ -78,12 +79,12 @@ hand work to peers. A timer can keep a loop running without making it useful. - **Continue without starting over.** Keep the Goal, accepted requirements, decisions and evidence across sessions and restarts. -- **[Coordinate without losing ownership.](https://loopx-project.github.io/loopx/blog/agent-facing-kanban/#walkthrough)** Connect delegated work, dependent +- **[Coordinate without dropping context.](https://loopx-project.github.io/loopx/blog/agent-facing-kanban/#walkthrough)** Connect delegated work, dependent artifacts, review and returned results while keeping responsibility explicit. - **Judge progress by what changed.** Inspect deliverables, supporting and refuting evidence, costs and the next decision—not just activity. -Your Agent runtime supplies the model and tools. LoopX’s semantic control plane +Your Agent runtime supplies the model and tools. LoopX’s durable state layer preserves goals, acceptance, decisions, ownership and evidence across sessions and runtimes for engineering, research and operations. Agents and people use those facts to reassess what to do next. Human authorization still governs @@ -97,25 +98,25 @@ protected actions. | Manage personal projects, schedules and decisions in one place | [Personal Agent Workspace](#meet-the-personal-agent-workspace) | | Let agents collaborate and deliver verifiable results | [Agent collaboration guide](docs/product/use-cases/cross-runtime/README.md) | -LoopX keeps the durable control state in one compact layer: +LoopX keeps the durable work state in one compact layer: Your harness runs the turn. LoopX carries the work. -Agent runtimes execute the work. LoopX governs the state that lets engineering, +Agent runtimes execute the work. LoopX keeps the state that lets engineering, research, discovery, and operations loops continue across runs. It is not another agent framework or a provider-specific orchestration runtime. +
+Advanced: how the control layer works and where it fits + - One turn is a governed state transition—not just a timer. + Advanced view of a governed state transition, not just a timer. -
-How the control plane works and where it fits - A useful mental model is an **[agent-native Kanban for long-running work](docs/development/control-plane-course/00-concept-primer.md)**. Cards carry identity, authority, evidence, and continuation. Moves are validated diff --git a/apps/presentation/dashboard/smoke/bound-conversation-smoke.ts b/apps/presentation/dashboard/smoke/bound-conversation-smoke.ts index 6b840f5af5..b7a61c4567 100644 --- a/apps/presentation/dashboard/smoke/bound-conversation-smoke.ts +++ b/apps/presentation/dashboard/smoke/bound-conversation-smoke.ts @@ -132,6 +132,11 @@ try { assert.equal(complete.messages.length, expectedRows); const recoveredOffsets: number[] = []; const failedOffsets = new Set(); + const interruptedHistoryBody = (status: number) => new Response(new ReadableStream({ + start(controller) { + controller.error(new Error("Synthetic interrupted history response body")); + }, + }), {status}); globalThis.fetch = async (input, init) => { const response = await routedRead(input, init); if (new URL(String(input), origin).pathname !== `/api/chat/sessions/${sessionId}`) return response; @@ -140,7 +145,8 @@ try { recoveredOffsets.push(offset); if (offset > 0 && !failedOffsets.has(offset)) { failedOffsets.add(offset); - throw new Error("Synthetic interrupted transport after a verified prefix"); + if (offset === 200) throw new Error("Synthetic interrupted transport after a verified prefix"); + return interruptedHistoryBody(response.status); } return response; }; @@ -150,6 +156,8 @@ try { assert.deepEqual(interrupted.snapshots, []); const interruptedAgain = await fetchChatHistory(options, interrupted); assert.deepEqual(interruptedAgain.messages, []); + assert.equal(interruptedAgain.pendingReads?.get(sessionId)?.messages.length, 400, + "A lost body after successful response headers keeps the verified prefix private"); const recovered = await fetchChatHistory(options, interruptedAgain); assert.deepEqual(recovered.unavailableSessionIds, []); assert.deepEqual(recovered.snapshots[0].messages, complete.messages); @@ -188,11 +196,12 @@ try { child.stdin.write("recover\n"); assert.equal((await next()).source_verified, true); // Permanent denial, a changed source or malformed prefix cannot be retained // as transport progress. The following read must validate page zero again. - for (const fault of ["denied", "source", "digest"]) { + for (const fault of ["denied", "denied-body", "source", "digest"]) { globalThis.fetch = async (input, init) => { const response = await routedRead(input, init); if (!new URL(String(input), origin).searchParams.has("message_cursor")) return response; if (fault === "denied") return new Response("{}", {status: 403}); + if (fault === "denied-body") return interruptedHistoryBody(403); const value = await response.json(); if (fault === "source") value.session.goal_id = "unrelated-goal"; else value.message_page.snapshot_sha256 = "0".repeat(64); diff --git a/apps/presentation/dashboard/smoke/conversation-reply-browser-smoke.mjs b/apps/presentation/dashboard/smoke/conversation-reply-browser-smoke.mjs index a47c2ca3b9..9192c131a2 100644 --- a/apps/presentation/dashboard/smoke/conversation-reply-browser-smoke.mjs +++ b/apps/presentation/dashboard/smoke/conversation-reply-browser-smoke.mjs @@ -29,6 +29,25 @@ function next() { if (queue.length) receive(queue.shift()); else waiting.push(receive); }); } +async function openWhileCheckingOriginalConversation(ready, page) { + const isOriginalSource = url => url.pathname === "/api/chat/sessions" + && url.searchParams.get("conversation_binding_id") === ready.bindingId; + const {promise: allowSourceRead, resolve: finishSourceRead} = Promise.withResolvers(); + const holdOriginalSource = async route => { + await allowSourceRead; + await route.continue(); + }; + await page.route(isOriginalSource, holdOriginalSource); + try { + await page.goto(ready.origin + "/chat/", {waitUntil: "domcontentloaded"}); + await page.getByText("正在核验原会话…", {exact: true}).waitFor({timeout: 4_000}); + assert.equal(await page.getByTestId("runtime-readiness").count(), 0, + "Checking the original conversation must not show machine runtime setup"); + } finally { + finishSourceRead(); + await page.unrouteAll({behavior: "wait"}); + } +} async function checkReturnedFiles(ready, page, errors, writes) { const snapshot = await (await page.request.get(`${ready.origin}/api/chat/sessions/${ready.sessionId}`)).json(); const answer = snapshot.messages.find(row => row.message_id === ready.fileMessageId); @@ -113,7 +132,11 @@ try { const snapshot = await (await page.request.get(`${ready.origin}/api/chat/sessions/${ready.sessionId}`)).json(); const source = snapshot.messages.find(row => row.role === "user" && row.turn_id); assert.ok(source.text.startsWith("Referenced message ("), "Canonical model input stays unchanged"); - await page.goto(ready.origin + "/chat/"); + if (process.env.LOOPX_REPLY_BEFORE === "1") { + await page.goto(ready.origin + "/chat/"); + } else { + await openWhileCheckingOriginalConversation(ready, page); + } await page.getByRole("navigation", {name: "管家视图"}).getByRole("button", {name: /^(Chat|对话)$/}).click(); const body = "请比较这次修改,并保留原话题中的限制。Compare this change against the original constraints."; const parent = "原要求:保留已有结果和失败记录。Original requirement: retain results and failed attempts."; diff --git a/apps/presentation/dashboard/src/data/chat.ts b/apps/presentation/dashboard/src/data/chat.ts index d7c740c9f6..95f19d780f 100644 --- a/apps/presentation/dashboard/src/data/chat.ts +++ b/apps/presentation/dashboard/src/data/chat.ts @@ -1297,8 +1297,10 @@ function sameHistoryRevision(current: ChatSessionSummary, read: ChatSessionSumma } function historyTransportUnavailable(error: unknown) { - return error instanceof ChatApiError && error.payload.error_code === "chat_api_unavailable" - && (error.payload.http_status === undefined || Number(error.payload.http_status) >= 500); + if (!(error instanceof ChatApiError) || error.payload.error_code !== "chat_api_unavailable") return false; + const status = error.payload.http_status; + // A response body can be interrupted after successful history headers. + return status === undefined || status === 200 || Number(status) >= 500; } export type ChatHistory = { diff --git a/apps/presentation/dashboard/src/features/personal-workspace/attached-agent-conversations.tsx b/apps/presentation/dashboard/src/features/personal-workspace/attached-agent-conversations.tsx index 16e16b06da..29de78c83c 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/attached-agent-conversations.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/attached-agent-conversations.tsx @@ -16,7 +16,12 @@ type LiveObservation = {kind: "records"; revision: number; records: Message[]} | {kind: "unavailable"; retry: boolean}; class ConversationRequestError extends Error { - constructor(message: string, readonly unsubmittedOperation?: string, readonly httpStatus?: number) {super(message);} + constructor( + message: string, + readonly unsubmittedOperation?: string, + readonly httpStatus?: number, + readonly interruptedSuccessfulResponse = false, + ) {super(message);} } export async function request(url: string, options?: RequestInit): Promise { @@ -30,11 +35,9 @@ export async function request(url: string, options?: RequestInit): Promise try { body = await response.text(); } catch { - // The headers arrived, so the status is already known even though the body - // did not. Drop the status here and a stalled 503 body looks like a plain - // transport failure while a truncated 403 body looks retryable; the caller - // must classify on this status, not on how the body read failed. - throw new ConversationRequestError("Conversation unavailable", undefined, response.status); + // Preserve rejected statuses even when their bodies drop. A successful + // response interrupted in transit is different from a complete invalid body. + throw new ConversationRequestError("Conversation unavailable", undefined, response.status, response.ok); } let value: Record | null = null; try {value = body ? JSON.parse(body) as Record : null;} catch {value = null;} @@ -51,7 +54,8 @@ function interruptedRead(failure: unknown): boolean { } export function retryableRead(failure: unknown): boolean { - return interruptedRead(failure) || (failure instanceof ConversationRequestError && (failure.httpStatus ?? 0) >= 500); + return interruptedRead(failure) || (failure instanceof ConversationRequestError && + (failure.interruptedSuccessfulResponse || (failure.httpStatus ?? 0) >= 500)); } export function readFailureMessage(failure: unknown, zh: boolean): string { diff --git a/apps/presentation/dashboard/src/features/personal-workspace/goal-research-analysis.tsx b/apps/presentation/dashboard/src/features/personal-workspace/goal-research-analysis.tsx index 1e55e0f8d5..f52ffbcfb3 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/goal-research-analysis.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/goal-research-analysis.tsx @@ -634,7 +634,7 @@ export function ResearchAnalysisPanel({analysis: a, onHistory}: {analysis: Contr return
{if (event.key === "Escape") {event.stopPropagation(); a.close();}}}>
{result?.synthesis ? <>

{zh ? "研究汇报" : "Research report"}

{scopeHelp}
{p?.title ?

{p.title}

: null}{scope}
- : <>

{zh ? "轨迹深度分析" : "Trajectory analysis"}

{p?.title ?

{p.title}

: null}}
{result?.synthesis && onHistory ? : null}{result ? : null}
+ : <>

{zh ? "轨迹深度分析" : "Trajectory analysis"}

{p?.title ?

{p.title}

: null}}
{result?.synthesis && onHistory ? : null}{result ? : null}
{a.error ?
{zh ? "分析请求未完成,已保存结果保留。" : "The analysis request did not complete; saved results are preserved."}

{a.error}

: null} {a.snapshot?.catalog.unreadable_pack_ids?.length && !p?.continuation_unavailable ?

{zh ? "部分历史分析暂不可读" : "Some historical analyses cannot be read"} · {a.snapshot.catalog.unreadable_pack_ids.length}

: null} 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 5876a80b50..2ecb9d2dbb 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 @@ -116,7 +116,9 @@ export function GoalResearchHistory({goalId, originalGoalId = goalId, preferHist const [recordsOpen, setRecordsOpen] = useState(false); const records = useRef(null); const [lens, setLens] = useState(() => viewFromUrl(goalId).lens); - const [phase, setPhase] = useState(() => viewFromUrl(goalId).phase); + const [requestedPhase, setPhase] = useState(() => viewFromUrl(goalId).phase); + const phase = read.complete ? requestedPhase : null; + const historyCoverage = useRef(null); const presentationId = useId().replace(/:/g, ""); const detail = useRef(null); const graph = useRef(null); @@ -127,16 +129,18 @@ export function GoalResearchHistory({goalId, originalGoalId = goalId, preferHist const copy = zh ? { title: "研究历程", loading: "正在读取完整记录", reading: "读取中", refresh: "刷新记录", all: "回到全图", scope: "Explore 已记录历史", coverage: "其他来源覆盖、尝试发生时序尚未核验", - partial: "历史尚未完整载入", failure: "无法读取此来源的研究记录", - time: "记录时间(本地)", unknown: "时间未记录", record: "条记录", records: "条记录", evidence: "已记录依据", + partialFailure: "只载入了部分研究记录", refreshFailure: "刷新失败,当前记录已保留", + failure: "无法读取此来源的研究记录", + time: "记录时间(本地)", partialTime: "已读记录(本地时间)", unknown: "时间未记录", record: "条记录", records: "条记录", evidence: "已记录依据", interpretation: "独立检查与实际采用尚未核验", versions: "来源与版本引用", updates: "历史记录", changes: "次记录", question: "个问题", questions: "个问题", links: "显式关联", missing: "部分关联的来源节点尚未记录", group: "来源分组", lost: "当前记录中找不到所选节点,其他历史仍可浏览。", } : { title: "Research history", loading: "Reading the full recorded history", reading: "Reading", refresh: "Refresh history", all: "Whole graph", scope: "Recorded Explore history", coverage: "Other-source coverage and attempt-time coverage are unverified", - partial: "History is not fully loaded", failure: "Could not read this source's research history", - time: "Recorded time (local)", unknown: "Time not recorded", record: "record", records: "records", evidence: "Recorded evidence", + partialFailure: "Only part of the history loaded", refreshFailure: "Refresh failed; current records are preserved", + failure: "Could not read this source's research history", + time: "Recorded time (local)", partialTime: "Loaded records (local time)", unknown: "Time not recorded", record: "record", records: "records", evidence: "Recorded evidence", interpretation: "Independent checking and actual adoption are unverified", versions: "Source and version references", updates: "History", changes: "records", question: "question", questions: "questions", links: "Explicit relationships", missing: "Some linked source nodes are not recorded", group: "Source grouping", lost: "The selected node is absent from this snapshot. Other history remains readable.", @@ -276,9 +280,9 @@ export function GoalResearchHistory({goalId, originalGoalId = goalId, preferHist 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); + if (next !== lens || nextPhase !== requestedPhase) window.history.pushState(null, "", url); }; - const chooseLens = (next: Lens) => chooseView(next, phase); + const chooseLens = (next: Lens) => chooseView(next, requestedPhase); const fullTime = (at: number, precise = false) => new Intl.DateTimeFormat(zh ? "zh-CN" : "en", { month: "numeric", day: "numeric", hour: "2-digit", minute: "2-digit", hour12: false, second: precise ? "2-digit" : undefined, fractionalSecondDigits: precise ? 3 : undefined, @@ -444,7 +448,7 @@ export function GoalResearchHistory({goalId, originalGoalId = goalId, preferHist : null; const historyView = !value ? null : lens === "timeline" && timeline ?
- select({nodes: [], day}, true, button)} phase={phase} onPhase={next => chooseView(lens, next)} onSelect={(id, finding, event) => choose(id, true, finding, undefined, event)} onSelectMany={(nodes, button) => select({nodes, day: null}, true, button)} timeLabel={fullTime} dayLabel={dayTime}/>
:
@@ -553,19 +557,22 @@ export function GoalResearchHistory({goalId, originalGoalId = goalId, preferHist
{!emptySourceHistory ?
-
{emptyWithAnalysis ? <>{emptyHistoryMessage} · {read.events.length} / {read.total} +
{emptyWithAnalysis ? <>{emptyHistoryMessage} · {read.events.length} / {read.total}

{zh ? "已保存的分析、原生测量与 Explore 历程分别读取。这个 Goal 没有已记录的 Explore 历程;已保存的结果仍可在下方阅读。" : "Saved analysis, native measurements and Explore history are read separately. This Goal has no recorded Explore history; its saved results remain readable below."}

-
: <>{copy.time}{value?.startAt ? <> · — : null} · {busy ? `${copy.reading} ${loaded}${read.total ? ` / ${read.total}` : ""}` + : <>{!read.complete && read.events.length ? copy.partialTime : copy.time}{value?.startAt ? <> · — : null} · {busy ? `${copy.reading} ${loaded}${read.total ? ` / ${read.total}` : ""}` : read.total === null ? (zh ? "来源尚未读到" : "Source has not been read") - : `${read.events.length} / ${read.total} ${recordUnit}${read.complete ? "" : ` · ${copy.partial}`}`}}
+ : `${read.events.length} / ${read.total} ${recordUnit}`}}
{!readingSelection ? displayControl : null}
: null} {readFailure ?
-
{read.events.length ? (zh ? "刷新失败,当前记录已保留" : "Refresh failed; current records are preserved") : copy.failure} +
{read.events.length ? (read.complete ? copy.refreshFailure : copy.partialFailure) : copy.failure}

{readFailure}

- +
: null} {emptySourceHistory ? <>
diff --git a/apps/presentation/dashboard/src/features/personal-workspace/goal-research-timeline.tsx b/apps/presentation/dashboard/src/features/personal-workspace/goal-research-timeline.tsx index 610115a35e..5dbf027af0 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/goal-research-timeline.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/goal-research-timeline.tsx @@ -11,7 +11,7 @@ import {ContextHelp} from "./context-help"; * Position means first record time only; it never encodes progress or cause. */ type Copy = Record; type Props = { - timeline: ExploreTimeline; zh: boolean; selectedIds: readonly string[]; selectedEventId?: string; phase: number | null; + timeline: ExploreTimeline; complete: boolean; zh: boolean; selectedIds: readonly string[]; selectedEventId?: string; phase: number | null; selectedDay: string | null; dayGroups: ReturnType["groups"]; onGroupSelect: (day: string, button: HTMLButtonElement) => void; onSelect: (id: string, finding?: string, event?: string) => void; onSelectMany: (ids: string[], button: HTMLButtonElement) => void; onPhase: (index: number | null) => void; timeLabel: (value: number, precise?: boolean) => string; dayLabel: (value: number) => string; @@ -37,7 +37,7 @@ function elapsedLabel(offset: number, zh: boolean) { return parts.join(" ") || (zh ? "0 秒" : "0s"); } -export function GoalResearchTimeline({timeline, zh, selectedIds, selectedEventId, selectedDay, dayGroups, onGroupSelect, phase, onSelect, onSelectMany, onPhase, timeLabel, dayLabel}: Props) { +export function GoalResearchTimeline({timeline, complete, zh, selectedIds, selectedEventId, selectedDay, dayGroups, onGroupSelect, phase, onSelect, onSelectMany, onPhase, timeLabel, dayLabel}: Props) { const plot = useRef(null); const [plotWidth, setPlotWidth] = useState(0); useLayoutEffect(() => { @@ -54,7 +54,8 @@ export function GoalResearchTimeline({timeline, zh, selectedIds, selectedEventId early: "前段", middle: "中段", late: "后段", item: "个有记录的研究项", items: "个有记录的研究项", question: "个问题", questions: "个问题", decisions: "显式决策", none: "未记录显式决策", changed: "变化", stopped: "停止", retained: "保留", unstructured: "决策未结构化", unstructuredHint: "结论未按 writeback 结构记录,无法拆出依据与决策。", - density: "记录密度", densityHint: "柱高为事件数;浅红为无记录空档", axis: "自首条记录起", + density: "记录密度", densityHint: "柱高为事件数;浅红为无记录空档", axis: "自首条记录起", gap: "无记录", + partialAxis: "自最早的已读记录起", partialDensityHint: "柱高为已读事件数", partialGap: "无已读记录", routes: "研究路线", routeHint: "仅由显式关联或父子分组相连", singles: "未关联研究项", trail: "决策轨迹", more: "显示更早记录", moreQuestions: "显示更多问题", record: "条决策记录", records: "条决策记录", unknownTime: "时间未知", all: "全部时段", span: "记录跨度", start: "起点", end: "末端", replan: "转向", continue: "继续", stop: "停止", wait: "等待", no_change: "无变化", ask_human: "请人决定", @@ -65,7 +66,8 @@ export function GoalResearchTimeline({timeline, zh, selectedIds, selectedEventId early: "Early", middle: "Middle", late: "Late", item: "research item with records", items: "research items with records", question: "question", questions: "questions", decisions: "Explicit decisions", none: "No explicit decision recorded", changed: "Changed", stopped: "Stopped", retained: "Retained", unstructured: "Unstructured decisions", unstructuredHint: "Findings were not recorded in writeback sections; evidence and decisions cannot be separated.", - density: "Recording density", densityHint: "Bar height is events; pale red is a recording gap", axis: "Since first record", + density: "Recording density", densityHint: "Bar height is events; pale red is a recording gap", axis: "Since first record", gap: "No records", + partialAxis: "Since earliest loaded record", partialDensityHint: "Bar height counts loaded events", partialGap: "No loaded records", routes: "Research routes", routeHint: "Connected only by recorded relationships or parent grouping", singles: "Unlinked research items", trail: "Decision history", more: "Show earlier records", moreQuestions: "Show more questions", record: "decision record", records: "decision records", unknownTime: "Unknown time", all: "Whole history", span: "Recorded span", start: "Start", end: "End", replan: "Replan", continue: "Continue", stop: "Stop", wait: "Wait", no_change: "No change", ask_human: "Ask human", @@ -75,6 +77,7 @@ export function GoalResearchTimeline({timeline, zh, selectedIds, selectedEventId }; const {start, end} = timeline; if (start === null || end === null) return

{copy.noTime}

; + const axisLabel = complete ? copy.axis : copy.partialAxis; const elapsed = end - start; const span = Math.max(1, elapsed); const pct = (at: number) => `${((at - start) / span) * 100}%`; @@ -131,24 +134,25 @@ export function GoalResearchTimeline({timeline, zh, selectedIds, selectedEventId }; return
-
{timeline.phases.map(phaseCard)}
+ {complete ?
{timeline.phases.map(phaseCard)}
: null}
{LABELS.map(key =>