mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 17:10:43 -10:00
feat(studio): the inspector leads with what this part of the run is about
The rail drew the same ten panels in the same order whether the turn was running or long finished. While it runs the questions are "is it alive, what is it doing, how much room is left, where is it in the plan"; once it is over they are "what changed, what came of it, what did it cost" — and the answer to the second set sat below six answers to the first. Two postures, and deliberately only two. A rail that reorders itself on every change of fact is not lifecycle-aware, it is unstable, and a reader who has to find the same panel twice in one turn has lost more than the ordering gave. Working leads with context, runtime, agents and the plan; review leads with files, plan and jobs, and context drops — how full the window is matters while there is a turn to fit in it and is forensic once there is not. The plan moves up rather than staying with the after-the-fact group. While a turn runs it answers where it is and what is next, which is orientation, not an audit trail. Posture comes off the run and never off what the panels hold. It is derived beside runState because "halt" answers two different questions — a turn waiting on a decision has not finished, and a turn that ended badly has — so blocked is asked separately rather than read back out of the state that already collapsed them. The rail was reading `!running`, which called a turn waiting on your approval finished. One list of panels, keyed, reordered. Two branches of JSX would be the obvious way and the wrong one: the same panel under a different parent is a different element to React, so changing posture would tear every panel down and build it again — losing whatever was open inside and asking again for whatever it reads. Changing which reading the rail is for is not a change to any panel's lifetime, and there is a guard that fails if it becomes one. Priority is not presence. Whether a panel appears is still its own contract, in both directions: Plan draws nothing without steps, and Jobs reports zero on purpose so the order does not move under the reader every time the last background task finishes. Composition decides where something goes, never whether there is something. The diagnostics drawer this was going to grow is not here, and that is a finding rather than an omission. Mcp already speaks only when a server has failed or has never been answered for — a drawer would add an empty one to every healthy rail and shut it over the one thing worth seeing on a rail that is not. Ordering it last is the whole of what it needed. Three panels gained the id the others already had, so what the rail put where can be read off the document instead of guessed from the words inside it. Three sabotages: two conditional subtrees, an order that follows the data, and a wrapper that overrules a panel's own silence. Also recorded rather than fixed: there is no head card. Three comments in this rail describe one, and Context still carries a row=true form for it that nothing passes. That is panel API, which this change does not touch — but the comment that sent readers looking for it is gone. Seal A held, Seal B 98, and the action universe unchanged at 418 inputs and 250 unnamed. Reordering what is already there gives nobody a new thing to do, which is what this should have cost.
This commit is contained in:
1 parent
b0af1d1b04
commit
2ebfff638b
8 files changed
+231
-27
No files matched your search
@@ -1,4 +1,4 @@
|
||||
import { memo, useCallback, useState } from "react";
|
||||
import { memo, useCallback, useState, type ReactNode } from "react";
|
||||
import type { AgentPort, ContextBreakdown, JobEntry, McpEntry, WorkspaceChanges } from "../port/port";
|
||||
import type { ExtensionSurface } from "../port/wire";
|
||||
import type { Metrics as M, PlanStep } from "../state/session";
|
||||
@@ -16,6 +16,25 @@ import type { Rail } from "./panels/derive";
|
||||
import type { Wallet } from "./wallet";
|
||||
import { swapping } from "./swap";
|
||||
import { ChangePreview } from "./ChangePreview";
|
||||
import type { Posture } from "./decisions";
|
||||
|
||||
// What the rail can show. Presence is still each panel's own contract — a
|
||||
// panel with nothing to say draws nothing — so this decides where something
|
||||
// goes, never whether there is something.
|
||||
type Section = "cache" | "cost" | "context" | "agents" | "runtime" | "plan" | "files" | "jobs" | "mcp" | "extensions";
|
||||
|
||||
// Fixed per posture, and read off neither the panels' contents nor the size of
|
||||
// anything: an order that follows the data puts a panel somewhere new the
|
||||
// moment it has something, which is the instability this replaces.
|
||||
//
|
||||
// Working answers "is it alive, what is it doing, how much room is left, where
|
||||
// is it in the plan". Review answers "what changed, what came of it, what did
|
||||
// it cost" — and context drops, because how full the window is matters while
|
||||
// there is a turn to fit and is forensic once there is not.
|
||||
const ORDER: Record<Posture, readonly Section[]> = {
|
||||
working: ["context", "runtime", "agents", "plan", "cost", "cache", "files", "jobs", "mcp", "extensions"],
|
||||
review: ["files", "plan", "jobs", "cost", "cache", "context", "runtime", "agents", "mcp", "extensions"],
|
||||
};
|
||||
|
||||
interface Props extends Rail {
|
||||
port: AgentPort;
|
||||
@@ -24,6 +43,9 @@ interface Props extends Rail {
|
||||
mcp: McpEntry[];
|
||||
rate: number;
|
||||
done: boolean;
|
||||
/** Which reading this rail is for. It decides order and nothing else: what
|
||||
* each panel is, whether it appears at all, and what it reads are its own. */
|
||||
posture: Posture;
|
||||
plan: PlanStep[];
|
||||
wallet: Wallet;
|
||||
account: string;
|
||||
@@ -43,11 +65,15 @@ interface Props extends Rail {
|
||||
onMoveSurface?: (ext: ExtensionSurface, slot: string) => void;
|
||||
}
|
||||
|
||||
// Progress, duration, cost and context are the head card's; what is left here
|
||||
// is the composition behind them. Two sections answer "is this run healthy",
|
||||
// and three more speak only when they have something to report. Everything else is a diagnosis — true, and not
|
||||
// worth a permanent nine-panel wall that a glance has to skip six of. The walk
|
||||
// behind all of it happens once, in the pane, and arrives here derived.
|
||||
// The inspector. Most of these speak only when they have something to report,
|
||||
// which is what keeps it from being a permanent nine-panel wall a glance has
|
||||
// to skip six of; the order they speak in is the table above. The walk behind
|
||||
// all of it happens once, in the pane, and arrives here derived.
|
||||
//
|
||||
// There is no head card. Three comments in this rail still describe one —
|
||||
// "progress, duration, cost and context are the head card's" — and Context
|
||||
// still carries a row=true form for it that nothing passes. Whatever that was,
|
||||
// it is not on screen, so the figures here are the only ones there are.
|
||||
export const Metrics = memo(function Metrics({
|
||||
port,
|
||||
metrics,
|
||||
@@ -58,6 +84,7 @@ export const Metrics = memo(function Metrics({
|
||||
mcp,
|
||||
rate,
|
||||
done,
|
||||
posture,
|
||||
plan,
|
||||
wallet,
|
||||
account,
|
||||
@@ -80,24 +107,37 @@ export const Metrics = memo(function Metrics({
|
||||
// reads as the window flinching rather than as the panel leaving.
|
||||
const openPreview = useCallback((path: string) => swapping(() => setOpenPath(path), "cpv"), []);
|
||||
const closePreview = useCallback(() => swapping(() => setOpenPath(null), "cpv"), []);
|
||||
// One list of panels, built once, ordered by the table below. Two branches
|
||||
// of JSX would be the obvious way to reorder and the wrong one: the same
|
||||
// panel under a different parent is a different element to React, so it
|
||||
// would be torn down and rebuilt — losing whatever it had open, and asking
|
||||
// again for whatever it reads on mount. Changing which posture the rail is
|
||||
// in is not a change to any panel's lifetime.
|
||||
const panel: Record<Section, ReactNode> = {
|
||||
cache: <Cache key="cache" metrics={metrics} done={done} />,
|
||||
cost: <Cost key="cost" metrics={metrics} wallet={wallet} account={account} onRefreshWallet={onRefreshWallet} />,
|
||||
context: <Context key="context" ctx={ctx} row={false} legend port={port} onCtx={onCtx} />,
|
||||
agents: <Agents key="agents" tasks={tasks} />,
|
||||
runtime: <Runtime key="runtime" rate={rate} done={done} stats={stats} files={visible(changes, tree).length} />,
|
||||
plan: <Plan key="plan" steps={plan} />,
|
||||
files: (
|
||||
<Files key="files" changes={changes} yolo={yolo} tree={tree} open={openPath}
|
||||
onOpen={tree?.repo ? openPreview : undefined} />
|
||||
),
|
||||
jobs: <Jobs key="jobs" jobs={jobs} />,
|
||||
// Not folded behind a diagnostics drawer, which is where this was headed.
|
||||
// Mcp already speaks only when a server has failed or has never been
|
||||
// answered for, so the drawer would be empty on every rail that is fine
|
||||
// and closed over the one thing worth seeing on a rail that is not.
|
||||
mcp: <Mcp key="mcp" servers={mcp} onOpen={onSettings} />,
|
||||
extensions: (
|
||||
<Extensions key="extensions" panels={panels} views={views} onInvoke={onExtInvoke} onMove={onMoveSurface} />
|
||||
),
|
||||
};
|
||||
return (
|
||||
<>
|
||||
<div className="scroll">
|
||||
{/* 顺序照设计稿:花了多少、省下多少、窗口装了什么、动过哪些文件 ——
|
||||
四个“这一趟在付什么代价”的问题排在一起,子代理和运行详情接在后面。 */}
|
||||
<Cache metrics={metrics} done={done} />
|
||||
<Cost metrics={metrics} wallet={wallet} account={account} onRefreshWallet={onRefreshWallet} />
|
||||
<Context ctx={ctx} row={false} legend port={port} onCtx={onCtx} />
|
||||
<Agents tasks={tasks} />
|
||||
<Runtime rate={rate} done={done} stats={stats} files={visible(changes, tree).length} />
|
||||
<Mcp servers={mcp} onOpen={onSettings} />
|
||||
<Extensions panels={panels} views={views} onInvoke={onExtInvoke} onMove={onMoveSurface} />
|
||||
{/* 最后三块,和设计稿一样:前面那些回答“这一趟在怎么跑”,这三块回答
|
||||
“它在外面做了什么” —— 打算动的、已经动过的、还在后台跑的。这些是
|
||||
跑完了回头看的东西,不是盯着看的。 */}
|
||||
<Plan steps={plan} />
|
||||
<Files changes={changes} yolo={yolo} tree={tree} open={openPath} onOpen={tree?.repo ? openPreview : undefined} />
|
||||
<Jobs jobs={jobs} />
|
||||
{ORDER[posture].map((id) => panel[id])}
|
||||
</div>
|
||||
{openPath && <ChangePreview port={port} path={openPath} onClose={closePreview} />}
|
||||
</>
|
||||
|
||||
@@ -2,7 +2,7 @@ import { memo, useCallback, useEffect, useMemo, useReducer, useRef, useState, us
|
||||
import { money } from "../i18n/format";
|
||||
import { reason } from "../i18n/kernel";
|
||||
import { t } from "../i18n";
|
||||
import { hasPendingDecision, runState } from "./decisions";
|
||||
import { hasPendingDecision, posture, runState } from "./decisions";
|
||||
import { createPortal } from "react-dom";
|
||||
import { HttpError } from "../port/port";
|
||||
import type { AgentPort, ApprovalVerdict, Checkpoint, ContextBreakdown, JobEntry, McpEntry, Queue as QueueSnapshot, RewindScope, SessionStatus, WorkspaceChanges } from "../port/port";
|
||||
@@ -757,6 +757,7 @@ function PaneView({ port, rt, title, active, visible, sideHost, side, onFocus, o
|
||||
mcp={mcp}
|
||||
rate={tps}
|
||||
done={!s.running}
|
||||
posture={posture(run, blocked)}
|
||||
plan={s.plan}
|
||||
wallet={wallet}
|
||||
account={accountOf(status?.modelRef)}
|
||||
|
||||
@@ -34,3 +34,18 @@ export function runState(f: { blocked: boolean; running: boolean; hasItems: bool
|
||||
if (!f.hasItems) return "idle";
|
||||
return f.terminal?.kind === "completed" ? "done" : "halt";
|
||||
}
|
||||
|
||||
/** Which of the two readings the inspector is for.
|
||||
*
|
||||
* Two postures and no more: a rail that reorders itself on every change of
|
||||
* fact is not lifecycle-aware, it is unstable, and a reader who has to find
|
||||
* the same panel twice in one turn has lost more than the ordering gave.
|
||||
*
|
||||
* Derived from the run, never from what the panels happen to hold. "halt"
|
||||
* answers two different questions — a turn waiting on a decision has not
|
||||
* finished, and a turn that ended badly has — so blocked is asked separately
|
||||
* rather than read back out of the state it already collapsed. */
|
||||
export type Posture = "working" | "review";
|
||||
|
||||
export const posture = (run: RunState, blocked: boolean): Posture =>
|
||||
run === "running" || blocked ? "working" : "review";
|
||||
@@ -0,0 +1,145 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { cleanup, render } from "@testing-library/react";
|
||||
import type { ComponentProps } from "react";
|
||||
import "./testkit";
|
||||
import { Metrics } from "./Metrics";
|
||||
import { posture } from "./decisions";
|
||||
import { MockPort } from "../port/mock";
|
||||
import type { AgentPort, JobEntry } from "../port/port";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
const METRICS = {
|
||||
hit: 10, miss: 2, out: 100, bySource: {}, cost: 1.5, currency: "¥",
|
||||
prefixHash: "", prefixChanged: false, prefixReasons: [], bodyChanged: false,
|
||||
carriedMessages: 3, toolSchema: 1, estimated: false, alt: null, turn: 1, rounds: [],
|
||||
} as never;
|
||||
|
||||
const JOB = { id: "j1", name: "bg", state: "running" } as unknown as JobEntry;
|
||||
|
||||
function draw(at: "working" | "review", over: Record<string, unknown> = {}, port?: AgentPort) {
|
||||
const props = {
|
||||
port: port ?? (new MockPort() as unknown as AgentPort),
|
||||
metrics: METRICS,
|
||||
tasks: [], changes: [], stats: { tools: 3, external: 1, failed: 0, waiting: 0 },
|
||||
jobs: [JOB], mcp: [], rate: 12, done: at === "review", posture: at,
|
||||
plan: [{ text: "step one", done: false }],
|
||||
wallet: { kind: "absent" } as never, account: "", onRefreshWallet: () => {},
|
||||
tree: null,
|
||||
ctx: { used: 24800, window: 128000, system: 5200, tools: 7400, user: 1800, reply: 3100, output: 7300 }, onCtx: () => {}, yolo: false, onSettings: () => {},
|
||||
panels: [], views: [], onExtInvoke: () => {}, onMoveSurface: () => {},
|
||||
...over,
|
||||
} as unknown as ComponentProps<typeof Metrics>;
|
||||
const view = render(<Metrics {...props} />);
|
||||
const order = () => [...document.querySelectorAll("[data-b]")].map((el) => el.getAttribute("data-b"));
|
||||
return { view, order, props };
|
||||
}
|
||||
|
||||
const before = (list: (string | null)[], a: string, b: string) => list.indexOf(a) < list.indexOf(b);
|
||||
|
||||
describe("what the inspector puts first", () => {
|
||||
it("leads with what the turn is doing while it is still going", () => {
|
||||
const { order } = draw("working");
|
||||
const at = order();
|
||||
expect(at[0]).toBe("ctx");
|
||||
expect(before(at, "ctx", "files")).toBe(true);
|
||||
expect(before(at, "runtime", "files")).toBe(true);
|
||||
// A plan is where it is now and what is next, not only an audit trail.
|
||||
expect(before(at, "plan", "cost")).toBe(true);
|
||||
});
|
||||
|
||||
it("leads with what the turn left behind once it is over", () => {
|
||||
const at = draw("review").order();
|
||||
expect(at[0]).toBe("files");
|
||||
expect(before(at, "files", "ctx")).toBe(true);
|
||||
expect(before(at, "jobs", "ctx")).toBe(true);
|
||||
// How full the window is matters while there is a turn to fit in it, and
|
||||
// is forensic once there is not — so it drops below what the turn cost.
|
||||
expect(before(at, "cost", "ctx")).toBe(true);
|
||||
});
|
||||
|
||||
// Order is a table, not a race. A panel that has just acquired something
|
||||
// must not jump the queue while the reader is watching another one.
|
||||
it("does not let a panel with something to say move itself up", () => {
|
||||
const quiet = draw("working").order();
|
||||
cleanup();
|
||||
const busy = draw("working", { changes: [{ path: "a.ts", added: 1, removed: 0 }], jobs: [JOB, JOB] }).order();
|
||||
expect(busy.indexOf("ctx")).toBe(0);
|
||||
expect(before(busy, "ctx", "files")).toBe(true);
|
||||
expect(quiet.filter((id) => busy.includes(id))).toEqual(quiet.filter((id) => busy.includes(id)));
|
||||
});
|
||||
|
||||
// Presence is still each panel's own contract: composing an order must not
|
||||
// conjure a block to fill a slot in it.
|
||||
it("draws no block for a panel with nothing to say", () => {
|
||||
const at = draw("review", { jobs: [], plan: [] }).order();
|
||||
expect(at).not.toContain("plan");
|
||||
// Jobs is the deliberate other case and stays: it reports zero on purpose,
|
||||
// so that the rail's order does not move under the reader every time the
|
||||
// last background task finishes. Whether a panel speaks when it is empty
|
||||
// is its own contract either way, and composing an order does not get to
|
||||
// overrule it in either direction.
|
||||
expect(at).toContain("jobs");
|
||||
expect(at).toContain("files");
|
||||
});
|
||||
|
||||
// The one that matters most: changing posture is not a change to any
|
||||
// panel's lifetime. Two branches of JSX would tear these down and rebuild
|
||||
// them, losing whatever was open inside and asking again for what they read.
|
||||
it("moves panels without rebuilding them", () => {
|
||||
const { view, props } = draw("working");
|
||||
const node = document.querySelector('[data-b="cache"]') as HTMLElement;
|
||||
expect(node).toBeTruthy();
|
||||
node.dataset.probe = "same-element";
|
||||
view.rerender(<Metrics {...props} posture="review" done />);
|
||||
const after = document.querySelector('[data-b="cache"]') as HTMLElement;
|
||||
expect(after.dataset.probe, "the panel was torn down and built again").toBe("same-element");
|
||||
expect(after).toBe(node);
|
||||
});
|
||||
|
||||
it("asks the kernel for nothing when it reorders", () => {
|
||||
const calls: string[] = [];
|
||||
const real = new MockPort();
|
||||
const watched = new Proxy(real, {
|
||||
get: (own, key: string) => {
|
||||
const v = (own as unknown as Record<string, unknown>)[key];
|
||||
if (typeof v !== "function") return v;
|
||||
return (...a: unknown[]) => { calls.push(key); return (v as (...x: unknown[]) => unknown).apply(own, a); };
|
||||
},
|
||||
}) as unknown as AgentPort;
|
||||
const { view, props } = draw("working", {}, watched);
|
||||
const spent = calls.length;
|
||||
view.rerender(<Metrics {...props} posture="review" done />);
|
||||
expect(calls.length).toBe(spent);
|
||||
});
|
||||
|
||||
// Mcp speaks only when a server has failed or has never been answered for,
|
||||
// which is already "diagnose when there is something to diagnose". Ordering
|
||||
// it last is all this cut does with it: putting it behind a drawer would add
|
||||
// an empty one to every healthy rail and shut the one that is not.
|
||||
it("leaves the machine's own facts to their own contract, last", () => {
|
||||
const quiet = draw("working").order();
|
||||
expect(quiet).not.toContain("mcp");
|
||||
cleanup();
|
||||
const loud = draw("working", { mcp: [{ name: "codegraph", state: "failed" } as never] }).order();
|
||||
expect(loud).toContain("mcp");
|
||||
expect(loud.indexOf("mcp")).toBe(loud.length - 1);
|
||||
expect(document.querySelector("details.diag")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// Posture comes off the run, not off what the panels hold — and "halt" answers
|
||||
// two questions, so it cannot be mapped on its own.
|
||||
describe("which reading the rail is for", () => {
|
||||
it("stays on working while a turn is open", () => {
|
||||
expect(posture("running", false)).toBe("working");
|
||||
expect(posture("halt", true)).toBe("working");
|
||||
});
|
||||
|
||||
it("turns to review once there is no turn to follow", () => {
|
||||
expect(posture("done", false)).toBe("review");
|
||||
expect(posture("halt", false)).toBe("review");
|
||||
expect(posture("idle", false)).toBe("review");
|
||||
});
|
||||
});
|
||||
@@ -17,7 +17,7 @@ export function Agents({ tasks }: { tasks: Task[] }) {
|
||||
const shown = tasks.length > SHOWN ? tasks.slice(-SHOWN) : tasks;
|
||||
|
||||
return (
|
||||
<Grp name={t("子代理")} aside={live ? t("{n} 在跑", { n: live }) : undefined}>
|
||||
<Grp id="agents" name={t("子代理")} aside={live ? t("{n} 在跑", { n: live }) : undefined}>
|
||||
<Row
|
||||
k={t("子代理数量")}
|
||||
v={live ? <span className="lk">{t("{n} 并行", { n: live })}</span> : agentsIn(tasks)}
|
||||
|
||||
@@ -28,7 +28,7 @@ export function Cost({ metrics, wallet, account, onRefreshWallet }: Props) {
|
||||
const peak = metrics.rounds.length ? Math.max(...metrics.rounds) : 0;
|
||||
const avg = metrics.rounds.length ? Math.round(metrics.rounds.reduce((a, b) => a + b, 0) / metrics.rounds.length) : 0;
|
||||
return (
|
||||
<Grp name={t("成本")} aside={t("本会话")}>
|
||||
<Grp id="cost" name={t("成本")} aside={t("本会话")}>
|
||||
<div className="mrow">
|
||||
<span className="amt">{money(metrics.cost, metrics.currency)}</span>
|
||||
{/* 一个按公布价计出来的数和一个拿兑底表估的数是两种断言。 */}
|
||||
|
||||
@@ -20,7 +20,7 @@ export function Runtime({ rate, done, stats, files }: Props) {
|
||||
const trail = useTrail(rate, !done);
|
||||
|
||||
return (
|
||||
<Grp name={t("运行详情")}>
|
||||
<Grp id="runtime" name={t("运行详情")}>
|
||||
<Row
|
||||
k={t("吞吐(输出)")}
|
||||
v={
|
||||
|
||||
@@ -4,9 +4,12 @@ import type { ReactNode } from "react";
|
||||
* label the same size, no answer to "which of these belong together". A group
|
||||
* states the question its rows answer, which is what lets a glance skip six
|
||||
* of them. */
|
||||
export function Grp({ name, aside, children }: { name: string; aside?: ReactNode; children: ReactNode }) {
|
||||
// id names the block the way the panels that build their own frame already do,
|
||||
// so what the rail put where can be read off the document rather than guessed
|
||||
// from the words inside it.
|
||||
export function Grp({ id, name, aside, children }: { id: string; name: string; aside?: ReactNode; children: ReactNode }) {
|
||||
return (
|
||||
<section className="dgrp">
|
||||
<section className="dgrp" data-b={id}>
|
||||
<h3 className="dgrp-h">
|
||||
{name}
|
||||
{aside != null && <span className="a">{aside}</span>}
|
||||
|
||||
Reference in new issue
Block a user