feat: compose continuous authoring workspace
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { findBeat, findScene } from "../storyboard.js";
|
||||
import { AgentHandoffScene } from "./AgentHandoffScene.js";
|
||||
|
||||
@@ -11,6 +11,8 @@ const renderBeat = (beatId: "request" | "handoff") => {
|
||||
};
|
||||
|
||||
describe("AgentHandoffScene", () => {
|
||||
afterEach(cleanup);
|
||||
|
||||
it("renders a log region named prepared authoring conversation", () => {
|
||||
renderBeat("request");
|
||||
expect(screen.getByRole("log", { name: "prepared authoring conversation" })).toBeInTheDocument();
|
||||
@@ -32,6 +34,13 @@ describe("AgentHandoffScene", () => {
|
||||
expect(assistantMessages.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
it("interleaves prepared workflow tool groups with the handoff conversation", () => {
|
||||
renderBeat("handoff");
|
||||
expect(screen.getAllByText(/runWorkflowCommand/i).length).toBeGreaterThan(0);
|
||||
expect(screen.getByRole("button", { name: /deployment.*2 tool calls/i }))
|
||||
.toHaveAttribute("aria-expanded", "true");
|
||||
});
|
||||
|
||||
it("does not render prepared workflow lifecycle content", () => {
|
||||
renderBeat("request");
|
||||
expect(screen.queryByText("prepared workflow lifecycle")).not.toBeInTheDocument();
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
import { useMemo } from "react";
|
||||
import { agentTextMessage } from "../../demo/agent/events.js";
|
||||
import { AssistantOperatorThread } from "../chat/AssistantOperatorThread.js";
|
||||
import { projectPreparedAuthoring } from "./authoring-recording.js";
|
||||
import { AuthoringConversation } from "./AuthoringConversation.js";
|
||||
import type { SceneBeatDefinition, SceneDefinition } from "../storyboard.js";
|
||||
|
||||
type AgentHandoffSceneProps = {
|
||||
@@ -9,11 +6,6 @@ type AgentHandoffSceneProps = {
|
||||
readonly beat: SceneBeatDefinition;
|
||||
};
|
||||
|
||||
const requestMessages = [
|
||||
agentTextMessage("handoff-user-1", "user", "We need to prepare a report workflow for the lda_report scenario. Use the available CLI tools to inspect, author, and deploy it."),
|
||||
agentTextMessage("handoff-assistant-1", "assistant", "Let me inspect the available sources, capabilities, and schemas first."),
|
||||
];
|
||||
|
||||
/**
|
||||
* Full-screen prepared-authoring conversation for Scene 8.
|
||||
*
|
||||
@@ -23,28 +15,13 @@ const requestMessages = [
|
||||
* prepared recording, not a live agent interaction.
|
||||
*/
|
||||
export const AgentHandoffScene = ({ beat }: AgentHandoffSceneProps) => {
|
||||
const messages = useMemo(() => {
|
||||
if (beat.id === "handoff") {
|
||||
const recording = projectPreparedAuthoring();
|
||||
const result: ReturnType<typeof agentTextMessage>[] = [];
|
||||
let index = 0;
|
||||
for (const phase of recording) {
|
||||
for (const turn of phase.conversation) {
|
||||
result.push(
|
||||
agentTextMessage(`msg-${index++}`, turn.role, turn.text),
|
||||
);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
return requestMessages;
|
||||
}, [beat.id]);
|
||||
const phase = beat.id === "handoff" ? "deployment" : "discover";
|
||||
|
||||
return (
|
||||
<AssistantOperatorThread
|
||||
mode="full"
|
||||
messages={messages}
|
||||
ariaLabel="prepared authoring conversation"
|
||||
<AuthoringConversation
|
||||
throughPhase={phase}
|
||||
activePhase={phase}
|
||||
surface="stage"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,92 +0,0 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -1,119 +0,0 @@
|
||||
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>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
+25
-17
@@ -1,6 +1,5 @@
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { findBeat, findScene } from "../storyboard.js";
|
||||
import { PreparedAuthoringLifecycleScene } from "./PreparedAuthoringLifecycleScene.js";
|
||||
|
||||
@@ -35,30 +34,39 @@ describe("PreparedAuthoringLifecycleScene", () => {
|
||||
it("artifact shows immutable ID and version", () => {
|
||||
renderBeat("artifact");
|
||||
expect(screen.getByText("Artifact")).toBeInTheDocument();
|
||||
expect(screen.getByText(/art_x9y8z7/i)).toBeInTheDocument();
|
||||
expect(screen.getAllByText(/lda_report_case_study/i).length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it("deployment shows bindings and validation", () => {
|
||||
renderBeat("deployment");
|
||||
expect(screen.getByText("Deployment")).toBeInTheDocument();
|
||||
expect(screen.getByText(/dep_m4n5p6/i)).toBeInTheDocument();
|
||||
expect(screen.getAllByText("Deployment").length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getAllByText(/lda_report_case_study\.default/i).length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it("renders an Agent trace trigger", () => {
|
||||
renderBeat("discover");
|
||||
expect(screen.getByRole("button", { name: "Agent trace" })).toBeInTheDocument();
|
||||
it.each([
|
||||
["discover", "discovery evidence"],
|
||||
["draft", "draft graph evidence"],
|
||||
["validate", "validation repair evidence"],
|
||||
["artifact", "artifact evidence"],
|
||||
["deployment", "deployment binding evidence"],
|
||||
] as const)("renders %s as the primary phase visual", (beatId, label) => {
|
||||
renderBeat(beatId);
|
||||
expect(screen.getByRole("region", { name: label })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("trace is initially closed", () => {
|
||||
renderBeat("discover");
|
||||
expect(screen.queryByRole("dialog", { name: "Authoring trace" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("opens the trace panel when Agent trace is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
it("keeps the same conversation as a synchronized bottom dock", () => {
|
||||
renderBeat("draft");
|
||||
await user.click(screen.getByRole("button", { name: "Agent trace" }));
|
||||
expect(screen.getByRole("dialog", { name: "Authoring trace" })).toBeInTheDocument();
|
||||
const chat = screen.getByRole("log", { name: "prepared authoring conversation" });
|
||||
expect(chat).toHaveAttribute("data-surface", "dock");
|
||||
expect(screen.getByRole("button", { name: /draft.*3 tool calls/i }))
|
||||
.toHaveAttribute("aria-expanded", "true");
|
||||
});
|
||||
|
||||
it("does not render the obsolete trace modal or receipt", () => {
|
||||
renderBeat("validate");
|
||||
expect(screen.queryByRole("button", { name: "Agent trace" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("dialog", { name: "Authoring trace" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByLabelText("prepared authoring receipt")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders a compact orientation rail", () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState } from "react";
|
||||
import { projectPreparedAuthoringPhase } from "./authoring-projection.js";
|
||||
import { AuthoringTracePanel } from "./AuthoringTracePanel.js";
|
||||
import { AuthoringConversation } from "./AuthoringConversation.js";
|
||||
import { AuthoringPhaseVisual } from "./AuthoringPhaseVisual.js";
|
||||
import type { AuthoringPhaseId } from "./authoring-recording.js";
|
||||
import type { SceneBeatDefinition, SceneDefinition } from "../storyboard.js";
|
||||
import { StageCaption } from "../StageCaption.js";
|
||||
@@ -22,15 +22,11 @@ const phases: readonly { readonly id: AuthoringPhaseId; readonly label: string }
|
||||
* Scene 9 — Prepared workflow authoring lifecycle.
|
||||
*
|
||||
* Each beat shows a compact orientation rail and one dominant phase projection
|
||||
* sourced from the prepared authoring recording. A persistent "Agent trace"
|
||||
* trigger opens the AuthoringTracePanel overlay.
|
||||
*
|
||||
* The receipt bridges Scene 8's full conversation and Scene 9's separate
|
||||
* trace panel without morphing runtime-owned components.
|
||||
* sourced from the prepared authoring recording. The same Scene 8 conversation
|
||||
* remains below the canvas as a beat-synchronized assistant dock.
|
||||
*/
|
||||
export const PreparedAuthoringLifecycleScene = ({ scene, beat }: PreparedAuthoringLifecycleSceneProps) => {
|
||||
const beatId = beat.id as AuthoringPhaseId;
|
||||
const [traceOpen, setTraceOpen] = useState(false);
|
||||
const projection = projectPreparedAuthoringPhase(beatId);
|
||||
|
||||
return (
|
||||
@@ -51,30 +47,17 @@ export const PreparedAuthoringLifecycleScene = ({ scene, beat }: PreparedAuthori
|
||||
))}
|
||||
</ol>
|
||||
|
||||
<article className="prepared-lifecycle-scene__projection">
|
||||
<div className="prepared-lifecycle-scene__summary">
|
||||
<p>{projection.summary}</p>
|
||||
</div>
|
||||
<div className="prepared-lifecycle-scene__commands">
|
||||
{projection.commands.map((cmd, i) => (
|
||||
<div key={i} className="prepared-lifecycle-scene__command">
|
||||
<code>$ {cmd.command}</code>
|
||||
<span className="prepared-lifecycle-scene__command-result" data-result={cmd.result}>
|
||||
{cmd.result === "success" ? "✓" : "⚡"} {cmd.result}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<article className="prepared-lifecycle-scene__projection" key={beatId}>
|
||||
<AuthoringPhaseVisual projection={projection} />
|
||||
</article>
|
||||
<div className="prepared-lifecycle-scene__dock">
|
||||
<AuthoringConversation
|
||||
throughPhase={beatId}
|
||||
activePhase={beatId}
|
||||
surface="dock"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
<div className="prepared-lifecycle-scene__receipt">
|
||||
<AuthoringTracePanel
|
||||
phase={beatId}
|
||||
open={traceOpen}
|
||||
onOpen={() => setTraceOpen(true)}
|
||||
onClose={() => setTraceOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user