feat: add prepared authoring trace panel
This commit is contained in:
@@ -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>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -2593,4 +2593,149 @@
|
|||||||
.presentation-stage[data-scene-view="agent"] .assistant-operator-thread {
|
.presentation-stage[data-scene-view="agent"] .assistant-operator-thread {
|
||||||
width: min(100%, 44rem);
|
width: min(100%, 44rem);
|
||||||
max-height: 100%;
|
max-height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
Authoring Trace Panel — dialog overlay with backdrop.
|
||||||
|
The backdrop fills the viewport behind the dialog and closes on click.
|
||||||
|
The trigger persists in the DOM as a focus anchor.
|
||||||
|
*/
|
||||||
|
.authoring-trace-panel__trigger {
|
||||||
|
border: 1px solid var(--accent-cyan);
|
||||||
|
border-radius: 0.45rem;
|
||||||
|
background: color-mix(in oklch, var(--accent-cyan) 11%, transparent);
|
||||||
|
color: var(--accent-cyan);
|
||||||
|
font: 600 0.7rem/1 var(--font-interface);
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
padding: 0.3rem 0.55rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.authoring-trace-panel__backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 100;
|
||||||
|
background: color-mix(in oklch, #000 52%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.authoring-trace-panel__dialog {
|
||||||
|
position: fixed;
|
||||||
|
inset: 10%;
|
||||||
|
z-index: 101;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: var(--stage-surface);
|
||||||
|
border: 1px solid var(--stage-line);
|
||||||
|
border-radius: 0.65rem;
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.authoring-trace-panel__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0.65rem 0.85rem;
|
||||||
|
border-bottom: 1px solid var(--stage-line);
|
||||||
|
font: 700 0.85rem/1 var(--font-interface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.authoring-trace-panel__header button {
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 1.1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.authoring-trace-panel__phases {
|
||||||
|
flex: 1;
|
||||||
|
overflow: auto;
|
||||||
|
padding: 0.5rem;
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.authoring-trace-panel__phase {
|
||||||
|
border: 1px solid color-mix(in oklch, var(--stage-line) 70%, transparent);
|
||||||
|
border-radius: 0.45rem;
|
||||||
|
margin-bottom: 0.35rem;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.authoring-trace-panel__phase-header {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0.5rem 0.65rem;
|
||||||
|
border: none;
|
||||||
|
background: color-mix(in oklch, var(--stage-surface) 65%, var(--stage-inset));
|
||||||
|
color: var(--text-primary);
|
||||||
|
font: 600 0.78rem/1 var(--font-interface);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.authoring-trace-panel__phase-header small {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.65rem;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.authoring-trace-panel__commands {
|
||||||
|
padding: 0.45rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.authoring-trace-panel__command {
|
||||||
|
padding: 0.4rem 0.55rem;
|
||||||
|
background: color-mix(in oklch, var(--stage-inset) 70%, transparent);
|
||||||
|
border-radius: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.authoring-trace-panel__command-line code {
|
||||||
|
display: block;
|
||||||
|
font-family: var(--font-evidence);
|
||||||
|
font-size: 0.7rem;
|
||||||
|
line-height: 1.4;
|
||||||
|
color: var(--accent-cyan);
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.authoring-trace-panel__command-summary {
|
||||||
|
margin: 0.2rem 0;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
line-height: 1.3;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.authoring-trace-panel__command-result {
|
||||||
|
font-size: 0.65rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.authoring-trace-panel__command-result[data-result="success"] {
|
||||||
|
color: var(--accent-green);
|
||||||
|
}
|
||||||
|
|
||||||
|
.authoring-trace-panel__command-result[data-result="diagnostic"] {
|
||||||
|
color: var(--accent-yellow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.authoring-trace-panel__command-detail {
|
||||||
|
margin: 0.25rem 0 0;
|
||||||
|
padding: 0.3rem;
|
||||||
|
background: color-mix(in oklch, #000 25%, transparent);
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
font-family: var(--font-evidence);
|
||||||
|
font-size: 0.65rem;
|
||||||
|
line-height: 1.3;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
overflow-x: auto;
|
||||||
|
color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user