feat: add prepared authoring trace panel

This commit is contained in:
lda
2026-07-11 05:21:33 +07:00 Verified
parent 2513d51402
commit f33dc88b59
3 changed files with 356 additions and 0 deletions
@@ -0,0 +1,92 @@
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, it, vi } from "vitest";
import { AuthoringTracePanel } from "./AuthoringTracePanel.js";
import { type AuthoringPhaseId } from "./authoring-recording.js";
afterEach(() => cleanup());
const renderPanel = (
phase: AuthoringPhaseId,
open = true,
onOpen = vi.fn(),
onClose = vi.fn(),
) => render(
<AuthoringTracePanel phase={phase} open={open} onOpen={onOpen} onClose={onClose} />,
);
describe("AuthoringTracePanel", () => {
it("renders an Agent trace trigger", () => {
renderPanel("discover", false);
expect(screen.getByRole("button", { name: "Agent trace" })).toBeInTheDocument();
});
it("calls onOpen when trigger is clicked", async () => {
const user = userEvent.setup();
const onOpen = vi.fn();
renderPanel("discover", false, onOpen);
await user.click(screen.getByRole("button", { name: "Agent trace" }));
expect(onOpen).toHaveBeenCalledTimes(1);
});
it("shows the dialog overlay when open is true", () => {
renderPanel("discover", true);
expect(screen.getByRole("dialog", { name: "Authoring trace" })).toBeInTheDocument();
});
it("hides the dialog overlay when open is false", () => {
renderPanel("discover", false);
expect(screen.queryByRole("dialog", { name: "Authoring trace" })).not.toBeInTheDocument();
});
it("renders the selected phase expanded in the dialog", () => {
renderPanel("draft", true);
expect(screen.getByText("Draft")).toBeInTheDocument();
});
it("renders all five phases in the panel", () => {
renderPanel("discover", true);
expect(screen.getByText("Discover")).toBeInTheDocument();
expect(screen.getByText("Draft")).toBeInTheDocument();
expect(screen.getByText("Validate")).toBeInTheDocument();
expect(screen.getByText("Artifact")).toBeInTheDocument();
expect(screen.getByText("Deployment")).toBeInTheDocument();
});
it("renders command blocks in the selected phase", () => {
renderPanel("validate", true);
const commands = screen.getAllByText(/wf bind|wf validate/i);
expect(commands.length).toBeGreaterThanOrEqual(1);
});
it("closes the dialog when Escape is pressed", async () => {
const user = userEvent.setup();
const onClose = vi.fn();
render(<AuthoringTracePanel phase="discover" open={true} onOpen={vi.fn()} onClose={onClose} />);
await user.keyboard("{Escape}");
expect(onClose).toHaveBeenCalledTimes(1);
});
it("closes the dialog when backdrop is clicked", async () => {
const user = userEvent.setup();
const onClose = vi.fn();
render(<AuthoringTracePanel phase="discover" open={true} onOpen={vi.fn()} onClose={onClose} />);
const backdrop = document.querySelector(".authoring-trace-panel__backdrop");
expect(backdrop).toBeInTheDocument();
await user.click(backdrop!);
expect(onClose).toHaveBeenCalledTimes(1);
});
it("restores focus to the trigger after closing", async () => {
const user = userEvent.setup();
const onClose = vi.fn();
const { rerender } = render(
<AuthoringTracePanel phase="discover" open={true} onOpen={vi.fn()} onClose={onClose} />,
);
await user.keyboard("{Escape}");
rerender(
<AuthoringTracePanel phase="discover" open={false} onOpen={vi.fn()} onClose={onClose} />,
);
expect(screen.getByRole("button", { name: "Agent trace" })).toHaveFocus();
});
});
@@ -0,0 +1,119 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { projectPreparedAuthoringPhase } from "./authoring-projection.js";
import type { AuthoringPhaseId } from "./authoring-recording.js";
type AuthoringTracePanelProps = {
readonly phase: AuthoringPhaseId;
readonly open: boolean;
readonly onOpen: () => void;
readonly onClose: () => void;
};
const allPhases: readonly AuthoringPhaseId[] = [
"discover",
"draft",
"validate",
"artifact",
"deployment",
];
/**
* Dialog-style overlay showing the prepared authoring trace.
*
* The trigger button is always rendered so it can serve as the focus anchor
* after the dialog closes (the DOM node persists across open/close cycles;
* removing it would lose the focus target).
*/
export const AuthoringTracePanel = ({ phase, open, onOpen, onClose }: AuthoringTracePanelProps) => {
const triggerRef = useRef<HTMLButtonElement>(null);
const [expandedPhase, setExpandedPhase] = useState<AuthoringPhaseId>(phase);
useEffect(() => {
if (!open) {
triggerRef.current?.focus();
return;
}
const handleKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
document.addEventListener("keydown", handleKey);
return () => document.removeEventListener("keydown", handleKey);
}, [open, onClose]);
const togglePhase = useCallback((p: AuthoringPhaseId) => {
setExpandedPhase((prev) => (prev === p ? prev : p));
}, []);
return (
<>
<button
ref={triggerRef}
type="button"
className="authoring-trace-panel__trigger"
onClick={onOpen}
aria-haspopup="dialog"
aria-expanded={open}
>
Agent trace
</button>
{open && (
<>
<div className="authoring-trace-panel__backdrop" onClick={onClose} />
<div
className="authoring-trace-panel__dialog"
role="dialog"
aria-label="Authoring trace"
aria-modal="true"
>
<div className="authoring-trace-panel__header">
<strong>Authoring trace</strong>
<button type="button" onClick={onClose} aria-label="Close trace">×</button>
</div>
<div className="authoring-trace-panel__phases">
{allPhases.map((p) => {
const projection = projectPreparedAuthoringPhase(p);
const isExpanded = p === expandedPhase;
return (
<div key={p} className="authoring-trace-panel__phase" data-expanded={isExpanded}>
<button
type="button"
className="authoring-trace-panel__phase-header"
onClick={() => togglePhase(p)}
aria-expanded={isExpanded}
>
<span>{projection.label}</span>
<small>{projection.commands.length} commands</small>
</button>
{isExpanded && (
<div className="authoring-trace-panel__commands">
{projection.commands.map((cmd, i) => (
<div key={`${p}-${i}`} className="authoring-trace-panel__command">
<div className="authoring-trace-panel__command-line">
<code>$ {cmd.command}</code>
</div>
<p className="authoring-trace-panel__command-summary">{cmd.summary}</p>
<span
className="authoring-trace-panel__command-result"
data-result={cmd.result}
>
{cmd.result === "success" ? "✓" : "⚡"} {cmd.result}
</span>
{cmd.detail && (
<pre className="authoring-trace-panel__command-detail">
{cmd.detail}
</pre>
)}
</div>
))}
</div>
)}
</div>
);
})}
</div>
</div>
</>
)}
</>
);
};