Merge commit 'refs/pr12804' into takeover/12804

This commit is contained in:
yhh committed 2026-10-12 05:41:29 -07:00
commit 9a26301d83
5 files changed
+181 -16

No files matched your search

@@ -24,8 +24,10 @@ export const EN_TRAJECTORY: Record<string, string> = {
"系统": "System",
"信号轨": "Signal rail",
"与下方共用时间轴": "Shares the timeline below",
"没有时长、也不是工具调用的运行记录(最多前 8 条),按发生的时刻摆在时间轴上,所以位置就是时间,无法拖动;点一个看它的记录。":
"Records of the run that have no duration and are not tool calls (the first 8 at most), placed on the timeline at the moment they happened. Their position is their time, so they cannot be dragged; click one to see its record.",
"时间轴缩放": "Timeline zoom",
"重置缩放": "Reset zoom",
"没有时长、也不是工具调用的运行记录,位置就是时间,无法拖动;缩放后可横向滚动,重叠信号分行显示。点一个看它的记录。":
"Records with no duration that are not tool calls. Their position is their time, so they cannot be dragged. Zoom and scroll horizontally; overlapping signals use separate lanes. Select one to inspect its record.",
"活动回合": "Activity rounds",
"点击一行查看记录": "Select a row to inspect it",
"模型回合": "Model round",
@@ -0,0 +1,9 @@
import { expect, it } from "vitest";
const CSS = import.meta.glob("./studio.css", { query: "?raw", import: "default", eager: true }) as Record<string, string>;
it("keeps the activity visibility floor in fixed pixels rather than timeline percentages", () => {
const rule = Object.values(CSS)[0].match(/\.run-round-track\s*>\s*i\s*\{([^}]+)\}/)?.[1];
expect(rule).toBeDefined();
expect(rule).toMatch(/min-width:\s*3px\s*;/);
});
+13 -5
View File
@@ -3454,6 +3454,7 @@ body { font-size: 14px; line-height: 1.75; }
events and the execution graph stay in Runtime details; this surface answers
the common question first: where did the turn spend its time? */
.run-analysis {
--ra-columns: 34px minmax(180px, 1fr) 180px 116px;
--ra-model: color-mix(in srgb, var(--accent) 82%, var(--text));
--ra-tool: color-mix(in srgb, var(--net) 78%, var(--text));
--ra-recovery: color-mix(in srgb, var(--err) 76%, var(--text));
@@ -3536,7 +3537,14 @@ body { font-size: 14px; line-height: 1.75; }
.run-legend { display: flex; flex-wrap: wrap; gap: 12px; margin-top: 7px; }
.run-legend span { display: inline-flex; align-items: center; gap: 6px; color: var(--muted); font: var(--w-reg) 10.5px var(--ui); }
.run-legend i { width: 8px; height: 8px; border-radius: 2px; background: var(--ra-color); }
.run-signal-line { position: relative; height: 34px; border-block: 1px dashed var(--border); background: color-mix(in srgb, var(--surface) 56%, transparent); }
.run-timeline-controls { display: flex; align-items: center; flex-wrap: wrap; gap: 10px; margin-top: 14px; font: var(--w-reg) 11px var(--ui); }
.run-timeline-controls label { display: flex; align-items: center; gap: 10px; }
.run-timeline-controls input { width: 130px; accent-color: var(--accent); }
.run-timeline-scroll { overflow-x: auto; container-type: inline-size; }
.run-timeline-scroll:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; }
.run-timeline-canvas .run-inspect { position: sticky; left: 16px; max-width: calc(100cqw - 76px); }
.run-signal-track { display: grid; grid-template-columns: var(--ra-columns); gap: 12px; padding: 5px 10px; }
.run-signal-line { grid-column: 2; position: relative; height: 34px; border-block: 1px dashed var(--border); background: color-mix(in srgb, var(--surface) 56%, transparent); }
.run-signal-line button { position: absolute; top: 50%; width: 20px; height: 30px; translate: -50% -50%; border: 0; background: transparent; cursor: pointer; }
.run-signal-line button::after { content: ""; position: absolute; left: 50%; top: 3px; bottom: 3px; width: 1px; background: var(--border); }
.run-signal-line button i { position: absolute; z-index: 1; left: 50%; top: 50%; width: 9px; height: 9px; translate: -50% -50%; rotate: 45deg; border: 2px solid var(--raised); background: var(--ra-color); box-shadow: 0 0 0 1px color-mix(in srgb, var(--ra-color) 62%, var(--border)); }
@@ -3553,11 +3561,11 @@ body { font-size: 14px; line-height: 1.75; }
.run-rounds li[data-on] .run-round-id,
.run-rounds li[data-on] .run-round-copy b { color: var(--text); }
.run-rounds button {
width: 100%; min-height: 38px; display: grid; grid-template-columns: 34px minmax(180px, 1fr) minmax(150px, .72fr) 116px;
width: 100%; min-height: 38px; display: grid; grid-template-columns: var(--ra-columns);
align-items: center; gap: 12px; padding: 5px 10px; border: 0; color: inherit; background: transparent; text-align: left; cursor: pointer;
}
.run-round-id { color: var(--muted); font: var(--w-reg) 10.5px var(--mono); }
.run-round-track { position: relative; height: 14px; overflow: hidden; border-radius: 3px; border-left: 1px solid var(--border); background-color: var(--surface); background-image: linear-gradient(90deg, var(--hair) 1px, transparent 1px); background-size: 25% 100%; }
.run-round-track { position: relative; height: 14px; overflow: hidden; border-radius: 3px; background-color: var(--surface); background-image: linear-gradient(90deg, var(--hair) 1px, transparent 1px); background-size: 25% 100%; }
.run-round-track > i { position: absolute; top: 3px; height: 8px; min-width: 3px; border-radius: 2px; background: var(--ra-color, var(--ra-tool)); }
.run-round-track > i[data-c="round"] { --ra-color: var(--ra-model); }
.run-round-track > i[data-c="read"] { --ra-color: var(--ghost); }
@@ -3582,7 +3590,7 @@ body { font-size: 14px; line-height: 1.75; }
.run-kpis { grid-template-columns: repeat(3, 1fr); }
.run-kpis > div:nth-child(3) { border-right: 0; }
.run-kpis > div:nth-child(-n+3) { border-bottom: 1px solid var(--hair); }
.run-rounds button { grid-template-columns: 30px minmax(120px, 1fr) minmax(120px, .8fr); }
.run-analysis { --ra-columns: 30px minmax(120px, 1fr) 140px; }
.run-round-kind { display: none; }
}
@media (max-width: 580px) {
@@ -3591,7 +3599,7 @@ body { font-size: 14px; line-height: 1.75; }
.run-kpis > div:nth-child(2n) { border-right: 0; }
.run-kpis > div:nth-child(3) { border-right: 1px solid var(--hair); }
.run-kpis > div:nth-child(-n+4) { border-bottom: 1px solid var(--hair); }
.run-rounds button { grid-template-columns: 26px minmax(100px, 1fr); }
.run-analysis { --ra-columns: 26px minmax(100px, 1fr); }
.run-round-copy { display: none; }
}
:root[data-theme="light"] .grp,
@@ -1,11 +1,11 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it, vi } from "vitest";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { act, cleanup, fireEvent, render, screen } from "@testing-library/react";
import "./testkit";
import type { TrajRow } from "../state/trajectory";
import { RunAnalysis } from "./RunAnalysis";
afterEach(cleanup);
afterEach(() => { cleanup(); vi.restoreAllMocks(); vi.unstubAllGlobals(); });
const rows: TrajRow[] = [
{ seq: 1, at: 0, dur: 4, kind: "model_round", payload: [{ t: "model_round" }], subs: [] },
@@ -84,4 +84,117 @@ describe("RunAnalysis", () => {
fireEvent.click(container.querySelector<HTMLButtonElement>("[data-action='analysis.signal'][data-target='9']")!);
expect(container.querySelector(".run-signals .run-inspect")).not.toBeNull();
});
it("zooms one shared timeline, preserves selection and resets the scroll position", () => {
const { container } = render(<RunAnalysis rows={rows} onSave={onSave} />);
const slider = screen.getByRole("slider", { name: "时间轴缩放" });
const scroller = screen.getByRole("region", { name: "时间轴" });
const canvas = container.querySelector<HTMLElement>(".run-timeline-canvas")!;
const signal = container.querySelector<HTMLButtonElement>("[data-action='analysis.signal']")!;
fireEvent.click(signal);
fireEvent.change(slider, { target: { value: "8" } });
expect(canvas.style.width).toBe("800%");
expect(canvas.contains(container.querySelector(".run-round-track"))).toBe(true);
expect(canvas.contains(signal)).toBe(true);
expect(signal.getAttribute("aria-expanded")).toBe("true");
scroller.scrollLeft = 300;
fireEvent.click(screen.getByRole("button", { name: "重置缩放" }));
expect(canvas.style.width).toBe("100%");
expect(scroller.scrollLeft).toBe(0);
expect(signal.getAttribute("aria-expanded")).toBe("true");
});
it("keeps every signal inspectable, including simultaneous and late records", () => {
const moments: TrajRow[] = Array.from({ length: 12 }, (_, seq) => ({
seq, at: 0, kind: "checkpoint", payload: [{ t: `signal ${seq}` }], subs: [],
}));
const { container } = render(<RunAnalysis rows={moments} onSave={onSave} />);
const buttons = [...container.querySelectorAll<HTMLButtonElement>("[data-action='analysis.signal']")];
expect(buttons).toHaveLength(12);
expect(new Set(buttons.map((b) => b.style.top)).size).toBe(12);
for (const [index, button] of buttons.entries()) {
expect(button.getAttribute("aria-label")).toContain(`signal ${index}`);
fireEvent.click(button);
expect(container.querySelector(".run-inspect p")?.textContent).toBe(`signal ${index}`);
}
fireEvent.click(buttons[11]);
expect(container.querySelector(".run-inspect")).toBeNull();
});
it("handles a single zero-time signal and newly arriving records without resetting zoom", () => {
const moment: TrajRow = { seq: 1, at: 0, kind: "note", payload: [{ t: "start" }], subs: [] };
const { container, rerender } = render(<RunAnalysis rows={[moment]} onSave={onSave} />);
fireEvent.change(screen.getByRole("slider"), { target: { value: "16" } });
rerender(<RunAnalysis rows={[moment, { ...moment, seq: 2, at: 1 }]} onSave={onSave} />);
expect((screen.getByRole("slider") as HTMLInputElement).value).toBe("16");
expect(container.querySelector("[data-target='1']")?.getAttribute("style")).not.toMatch(/NaN|Infinity/);
expect(container.querySelectorAll("[data-action='analysis.signal']")).toHaveLength(2);
});
it("reflows crowded lanes as the measured timeline widens without changing timestamps", () => {
let measure = () => {};
let width = 100;
vi.stubGlobal("ResizeObserver", class {
constructor(fn: () => void) { measure = fn; }
observe() {}
disconnect() {}
});
vi.spyOn(HTMLElement.prototype, "clientWidth", "get").mockImplementation(() => width);
const moments: TrajRow[] = [1, 1.1, 10].map((at, seq) => ({
seq, at, kind: "checkpoint", payload: [{ t: `signal ${seq}` }], subs: [],
}));
const { container } = render(<RunAnalysis rows={moments} onSave={onSave} />);
const signals = [...container.querySelectorAll<HTMLButtonElement>("[data-action='analysis.signal']")];
expect(signals[0].style.top).not.toBe(signals[1].style.top);
const positions = signals.map((signal) => signal.style.left);
act(() => { width = 3000; measure(); });
expect(signals[0].style.top).toBe(signals[1].style.top);
expect(signals.map((signal) => signal.style.left)).toEqual(positions);
});
it("keeps hit targets apart when interface zoom scales the rail's visual width", () => {
vi.spyOn(HTMLElement.prototype, "clientWidth", "get").mockReturnValue(100);
vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockReturnValue({ width: 180 } as DOMRect);
const moments: TrajRow[] = [0, 13, 100].map((at, seq) => ({
seq, at, kind: "checkpoint", payload: [{ t: `signal ${seq}` }], subs: [],
}));
const { container } = render(<RunAnalysis rows={moments} onSave={onSave} />);
const signals = [...container.querySelectorAll<HTMLButtonElement>("[data-action='analysis.signal']")];
expect(signals[0].style.top).not.toBe(signals[1].style.top);
});
it.each([1, 16])("keeps activity duration percentages exact at %s× zoom and after reset", (zoom) => {
const timed: TrajRow[] = [
{ seq: 1, at: 10, dur: .1, kind: "tool", tool: "bash", payload: [{ t: "short" }], subs: [] },
{ seq: 2, at: 20, dur: 0, kind: "protocol_recovery", payload: [{ t: "retry zero" }], subs: [] },
{ seq: 3, at: 30, kind: "protocol_recovery", payload: [{ t: "retry unknown" }], subs: [] },
{ seq: 4, at: 40, dur: 10, kind: "model_round", payload: [{ t: "long" }], subs: [] },
{ seq: 5, at: 100, kind: "event", payload: [], subs: [] },
];
const { container } = render(<RunAnalysis rows={timed} onSave={onSave} />);
const bars = [...container.querySelectorAll<HTMLElement>(".run-round-track > i")];
fireEvent.click(container.querySelector("[data-action='analysis.row'][data-target='1']")!);
fireEvent.change(screen.getByRole("slider"), { target: { value: String(zoom) } });
expect(bars.map((bar) => bar.style.left)).toEqual(["10%", "20%", "30%", "40%"]);
expect(bars.map((bar) => bar.style.width)).toEqual(["0.1%", "0%", "0%", "10%"]);
expect(container.querySelector<HTMLElement>(".run-timeline-canvas")?.style.width).toBe(`${zoom * 100}%`);
expect(container.querySelector(".run-inspect p")?.textContent).toBe("short");
fireEvent.click(screen.getByRole("button", { name: "重置缩放" }));
expect(bars.map((bar) => bar.style.width)).toEqual(["0.1%", "0%", "0%", "10%"]);
expect(container.querySelector(".run-inspect p")?.textContent).toBe("short");
});
it("keeps zero-span recovery markers finite and duration-free at maximum zoom", () => {
const recovery: TrajRow = { seq: 1, at: 0, dur: 0, kind: "protocol_recovery", payload: [{ t: "retry" }], subs: [] };
const { container } = render(<RunAnalysis rows={[recovery]} onSave={onSave} />);
fireEvent.change(screen.getByRole("slider"), { target: { value: "16" } });
const bar = container.querySelector<HTMLElement>(".run-round-track > i")!;
expect(bar.style.left).toBe("0%");
expect(bar.style.width).toBe("0%");
fireEvent.click(container.querySelector("[data-action='analysis.row']")!);
expect(container.querySelector(".run-inspect p")?.textContent).toBe("retry");
});
});
+40 -7
View File
@@ -71,9 +71,31 @@ export function RunAnalysis({ rows, availability, onSave }: {
const rounds = activities.filter((r) => r.kind === "model_round" && r.dur);
const tools = activities.filter((r) => r.tool);
const recoveries = rows.filter(isRecovery);
const signals = rows.filter((r) => !r.dur && !r.tool && r.kind !== "event").slice(0, 8);
const signals = rows.filter((r) => !r.dur && !r.tool && r.kind !== "event");
const first = activities.length ? Math.min(...activities.map((r) => r.at)) : 0;
const [pick, setPick] = useState<{ seq: number; from: "row" | "signal" } | null>(null);
const [zoom, setZoom] = useState(1);
const [signalWidth, setSignalWidth] = useState(0);
const timeline = useRef<HTMLDivElement>(null);
const signalLine = useRef<HTMLDivElement>(null);
const lanes: number[] = [];
const placedSignals = [...signals].sort((a, b) => a.at - b.at).map((row) => {
const left = row.at / Math.max(span, 1);
const x = left * signalWidth;
const free = lanes.findIndex((end) => x - end >= 22);
const lane = free < 0 ? lanes.length : free;
lanes[lane] = x;
return { row, left, lane };
});
useLayoutEffect(() => {
const line = signalLine.current;
if (!line) return;
const measure = () => setSignalWidth(line.clientWidth);
measure();
const observer = new ResizeObserver(measure);
observer.observe(line);
return () => observer.disconnect();
}, [signals.length > 0]);
const [saved, setSaved] = useState<string | null>(null);
const picked = rows.find((r) => r.seq === pick?.seq) ?? null;
const selected = pick?.from === "row" ? pick.seq : null;
@@ -151,17 +173,26 @@ export function RunAnalysis({ rows, availability, onSave }: {
</div>
</section>
<div className="run-timeline-controls">
<label>{t("时间轴缩放")}<input type="range" min="1" max="16" step="1" value={zoom}
aria-label={t("时间轴缩放")} aria-valuetext={`${zoom}×`} onChange={(e) => setZoom(Number(e.target.value))} /></label>
<output>{zoom}×</output>
<button className="run-export" onClick={() => { setZoom(1); if (timeline.current) timeline.current.scrollLeft = 0; }}>{t("重置缩放")}</button>
</div>
<div className="run-timeline-scroll" ref={timeline} role="region" aria-label={t("时间轴")} tabIndex={0}>
<div className="run-timeline-canvas" style={{ width: `${zoom * 100}%` }}>
{signals.length > 0 && (
<section className="run-signals">
<div className="run-section-head"><b>{t("信号轨")}</b><span>{t("与下方共用时间轴")}</span></div>
<p className="run-section-note">{t("没有时长、也不是工具调用的运行记录(最多前 8 条),按发生的时刻摆在时间轴上,所以位置就是时间,无法拖动;点一个看它的记录。")}</p>
<div className="run-signal-line">
{signals.map((row) => (
<button data-action="analysis.signal" data-target={String(row.seq)} key={row.seq} style={{ left: `${(row.at / Math.max(span, 1)) * 100}%` }} onClick={(e) => toggle(row.seq, "signal", e.currentTarget)} title={spanText(row.payload)}>
<p className="run-section-note">{t("没有时长、也不是工具调用的运行记录,位置就是时间,无法拖动;缩放后可横向滚动,重叠信号分行显示。点一个看它的记录。")}</p>
<div className="run-signal-track"><div className="run-signal-line" ref={signalLine} style={{ height: `${lanes.length * 30 + 4}px` }}>
{placedSignals.map(({ row, left, lane }) => (
<button data-action="analysis.signal" data-target={String(row.seq)} key={row.seq} style={{ left: `${left * 100}%`, top: `${lane * 30 + 17}px` }} onClick={(e) => toggle(row.seq, "signal", e.currentTarget)} title={spanText(row.payload)}
aria-label={`${labelOf(row)} · +${decimals(row.at, 2)}s`} aria-expanded={pick?.from === "signal" && picked?.seq === row.seq}>
<i data-c={bucketOf(row)} />
</button>
))}
</div>
</div></div>
{picked && pick?.from === "signal" && <Inspect row={picked} index={activities.findIndex((r) => r.seq === picked.seq)} />}
</section>
)}
@@ -171,7 +202,7 @@ export function RunAnalysis({ rows, availability, onSave }: {
<ol>
{activities.map((row, index) => {
const start = (row.at / Math.max(span, 1)) * 100;
const width = Math.max(1.2, ((row.dur ?? 0) / Math.max(span, 1)) * 100);
const width = ((row.dur ?? 0) / Math.max(span, 1)) * 100;
const bucket = bucketOf(row);
const cat = row.tool ? categoryOf(row.tool) : bucket;
return (
@@ -188,6 +219,8 @@ export function RunAnalysis({ rows, availability, onSave }: {
})}
</ol>
</section>
</div>
</div>
</div>
);
}