fix: address presentation review findings

This commit is contained in:
lda
2026-07-11 05:17:35 +07:00 Verified
parent 531f934288
commit 371377716c
18 changed files with 140 additions and 61 deletions
+6 -6
View File
@@ -177,7 +177,7 @@ The final presentation beats frame the evaluation as bounded evidence, make
claim boundaries and future work explicit, and end on the canonical defense
discussion index rather than a benchmark or generic conclusion.
Scenes 8 through 10 use the canonical replay as their only execution evidence.
Scenes 8 through 12 use the canonical replay as their only execution evidence.
The handoff expands an interpreted run operation into the center stage, then
keeps one workflow graph mounted while execution reaches the typed interrupt
and approval boundary. Raw protocol payloads are available through the evidence
@@ -196,7 +196,7 @@ The key defense states are directly addressable:
- `/present#scene/workflow-demo/interrupt`
- `/present#scene/interrupt-evidence/approval`
Scene 10 is factual by design. It projects the reviewed/live run into visible
Scene 12 is factual by design. It projects the reviewed/live run into visible
workflow input, interrupt payload, resume decision, output, and trace facts.
Empty trace frame objects are shown as captured empty objects; absent fields are
called out as not captured rather than replaced by generic placeholders.
@@ -229,8 +229,8 @@ chat to `rail`.
#### Stage Themes
Stage themes change by act: `paper` for scenes 1-3 and 11-12, `night` for
scenes 4-10. Presenter controls allow overriding stage and chat themes
Stage themes change by act: `paper` for scenes 1-3 and 13-14, `night` for
scenes 4-12. Presenter controls allow overriding stage and chat themes
independently during rehearsal.
#### Discussion Branches
@@ -307,9 +307,9 @@ The current prepared recipe can:
This is intentionally not a general autonomous planner. A future server-side
Vercel AI SDK driver can feed the same message-part interface.
### Demo Climax (Scenes 910)
### Demo Climax (Scenes 912)
Scenes 9 and 10 are the demo climax. They keep a continuity rail visible while
Scenes 9 through 12 are the demo climax. They keep a continuity rail visible while
the prepared replay moves from agent handoff, to persisted workflow run, to
typed human interrupt, to resume/output/evidence. The rail and outcome panel are
presentation-only projections over the committed replay; they do not add live
@@ -91,7 +91,7 @@ export const useTimelineAgent = (
const canRun = demo.canStart && !demo.inFlight && demo.state.phase !== "running";
const runPreparedWorkflow = useCallback(async () => {
if (!demo.canStart || demo.inFlight) return;
if (!demo.canStart || demo.inFlight || demo.state.phase === "running") return;
demo.start(modeLabel);
setMessages((current) => appendToolMessage(
current,
@@ -41,6 +41,7 @@ export const DemoOutcomePanel = ({
<span>{lens.eyebrow}</span>
<strong>Same persisted run</strong>
<dl>
<div><dt>Run</dt><dd><code>{runId}</code></dd></div>
<div><dt>Operation</dt><dd><code>{operation?.operation ?? lens.proofLabel}</code></dd></div>
<div><dt>Status</dt><dd>{operation?.status ?? "completed"}</dd></div>
</dl>
@@ -65,7 +65,6 @@ export const DiscussionPanel = ({ branchId, onClose }: DiscussionPanelProps) =>
<span className="discussion-panel__badge">{branch.claimClass}</span>
<h2>{branch.title}</h2>
</div>
<p>{branch.summary}</p>
</header>
<main className="discussion-panel__body" aria-label="discussion body">
@@ -72,8 +72,8 @@ export const GuidedProductMoment = ({
<InterruptDecisionForm
interrupt={facts.interrupt}
runId={demo.state.events.find((e) => e.stage === "run_start")?.resultingIds.runId ?? "unknown"}
onSubmit={(ids, comment) => approvalActions?.submit(ids, comment)}
onCancel={() => approvalActions?.cancel()}
onSubmit={approvalActions?.canSubmit ? (ids, comment) => approvalActions.submit(ids, comment) : undefined}
onCancel={approvalActions?.canCancel ? () => approvalActions.cancel() : undefined}
terminalOutcome={approvalActions?.state === "submitted" ? "submitted" :
approvalActions?.state === "cancelled" ? "cancelled" : undefined}
/>
@@ -4,8 +4,8 @@ import type { RunFactsInterrupt } from "./demo-run-facts.js";
type InterruptDecisionFormProps = {
readonly interrupt: RunFactsInterrupt;
readonly runId: string;
readonly onSubmit: (selectedIssueIds: ReadonlyArray<string>, comment: string) => void;
readonly onCancel: () => void;
readonly onSubmit?: ((selectedIssueIds: ReadonlyArray<string>, comment: string) => void) | undefined;
readonly onCancel?: (() => void) | undefined;
readonly terminalOutcome?: "submitted" | "cancelled" | undefined;
};
@@ -33,7 +33,7 @@ export const InterruptDecisionForm = ({
const handleSubmit = useCallback(
(e: React.FormEvent) => {
e.preventDefault();
onSubmit([...selectedIds], comment);
onSubmit?.([...selectedIds], comment);
},
[selectedIds, comment, onSubmit],
);
@@ -103,13 +103,14 @@ export const InterruptDecisionForm = ({
</label>
<div className="interrupt-decision-form__actions">
<button type="submit" className="interrupt-decision-form__submit">
<button type="submit" className="interrupt-decision-form__submit" disabled={!onSubmit}>
Submit
</button>
<button
type="button"
className="interrupt-decision-form__cancel"
onClick={onCancel}
onClick={() => onCancel?.()}
disabled={!onCancel}
>
Cancel
</button>
@@ -36,8 +36,12 @@ export const OperatorChat = ({ state, messages, timelineAgent, onApprove, onDeny
: timelineAgent && timelineAgent.messages.length > 0
? timelineAgent.messages
: fallbackMessages(state);
const submit = timelineAgent?.submitSelectedIssues ?? onApprove;
const cancel = timelineAgent?.cancelReview ?? onDeny;
const submit = timelineAgent
? () => { timelineAgent.submitSelectedIssues().catch(console.error); }
: onApprove;
const cancel = timelineAgent
? () => { timelineAgent.cancelReview().catch(console.error); }
: onDeny;
const composition = compositionForState(state);
const presentationSurface = composition.chatTheme === "light" ? "editorial" : "night";
return (
@@ -34,7 +34,7 @@ const formatValue = (value: unknown): string | null => {
if (value === undefined) return null;
if (typeof value === "string") return value;
if (typeof value === "number" || typeof value === "boolean" || value === null) return String(value);
return JSON.stringify(value);
return JSON.stringify(value) ?? null;
};
const fieldKind = (propertySchema: unknown): SchemaApprovalFieldKind => {
@@ -0,0 +1,44 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { findBeat, findScene } from "../storyboard.js";
import { AgentHandoffScene } from "./AgentHandoffScene.js";
const renderBeat = (beatId: "request" | "handoff") => {
const scene = findScene("agent-handoff");
const beat = findBeat("agent-handoff", beatId);
if (!scene || !beat) throw new Error(`missing agent-handoff/${beatId}`);
return render(<AgentHandoffScene scene={scene} beat={beat} />);
};
describe("AgentHandoffScene", () => {
it("renders a log region named prepared authoring conversation", () => {
renderBeat("request");
expect(screen.getByRole("log", { name: "prepared authoring conversation" })).toBeInTheDocument();
});
it("renders separated user and assistant turns on the request beat", () => {
renderBeat("request");
const userMessages = screen.getAllByText(/report|workflow|prepare/i);
expect(userMessages.length).toBeGreaterThanOrEqual(1);
const assistantMessages = screen.getAllByText(/inspect|capabilities|sources|schemas|let me/i);
expect(assistantMessages.length).toBeGreaterThanOrEqual(1);
});
it("renders the full conversation on the handoff beat", () => {
renderBeat("handoff");
const userMessages = screen.getAllByText(/report|workflow|prepare|save/i);
expect(userMessages.length).toBeGreaterThanOrEqual(1);
const assistantMessages = screen.getAllByText(/inspect|sources|capabilities|compile|deployment|artifact/i);
expect(assistantMessages.length).toBeGreaterThanOrEqual(2);
});
it("does not render prepared workflow lifecycle content", () => {
renderBeat("request");
expect(screen.queryByText("prepared workflow lifecycle")).not.toBeInTheDocument();
});
it("does not include prepared workflow lifecycle content on handoff", () => {
renderBeat("handoff");
expect(screen.queryByText("prepared workflow lifecycle")).not.toBeInTheDocument();
});
});
@@ -0,0 +1,50 @@
import { useMemo } from "react";
import { agentTextMessage } from "../../demo/agent/events.js";
import { AssistantOperatorThread } from "../chat/AssistantOperatorThread.js";
import { projectPreparedAuthoring } from "./authoring-recording.js";
import type { SceneBeatDefinition, SceneDefinition } from "../storyboard.js";
type AgentHandoffSceneProps = {
readonly scene: SceneDefinition;
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.
*
* The request beat shows the operator asking the agent to prepare a report;
* the handoff beat reveals the full completed conversation with all phases.
* Neither run actions nor approval actions are passed because this is a
* 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]);
return (
<AssistantOperatorThread
mode="full"
messages={messages}
ariaLabel="prepared authoring conversation"
/>
);
};
@@ -130,7 +130,7 @@ describe("assistantRuntimeProjection", () => {
expect(projected[0]?.content).toEqual([
{
type: "tool-call",
toolCallId: "presentation-selectWorkflowNode",
toolCallId: "presentation-mixed-0-selectWorkflowNode",
toolName: "presentation.selectWorkflowNode",
args: { type: "selectWorkflowNode", nodeId: "review_issues" },
},
@@ -30,7 +30,7 @@ export type AssistantProjectedMessage = {
readonly content: ReadonlyArray<AssistantContentPart>;
};
const projectPart = (part: AgentMessagePart): AssistantContentPart[] => {
const projectPart = (part: AgentMessagePart, messageId: string, partIndex: number): AssistantContentPart[] => {
switch (part.type) {
case "text":
return [{ type: "text", text: part.text }];
@@ -52,7 +52,7 @@ const projectPart = (part: AgentMessagePart): AssistantContentPart[] => {
case "presentation-action":
return [{
type: "tool-call",
toolCallId: `presentation-${part.action.type}`,
toolCallId: `presentation-${messageId}-${partIndex}-${part.action.type}`,
toolName: `presentation.${part.action.type}`,
args: part.action,
}];
@@ -82,7 +82,7 @@ export const projectAgentMessagesForAssistant = (
messages: ReadonlyArray<AgentMessage>,
): AssistantProjectedMessage[] =>
messages.map((message) => {
const content = message.parts.flatMap(projectPart);
const content = message.parts.flatMap((part, index) => projectPart(part, message.id, index));
return {
id: message.id,
role: messageRoleFor(message),
@@ -201,7 +201,7 @@ const readOutputFacts = (
};
};
const formatRecord = (record: Record<string, unknown>, absentLabel: string): string =>
const formatRecord = (record: Record<string, unknown> | undefined, absentLabel: string): string =>
formatFactValue(record, absentLabel);
const readTraceFacts = (
@@ -225,11 +225,11 @@ const readTraceFacts = (
stepType: stringField(frame, "stepType") ?? "unknown",
outcome: stringField(frame, "outcome") ?? "unknown",
resolvedInputLabel: formatRecord(
recordField(frame, "resolvedInput") ?? {},
recordField(frame, "resolvedInput"),
"not captured in this recording",
),
outputLabel: formatRecord(recordField(frame, "output") ?? {}, "not captured in this recording"),
stateChangesLabel: formatRecord(recordField(frame, "stateChanges") ?? {}, "not captured in this recording"),
outputLabel: formatRecord(recordField(frame, "output"), "not captured in this recording"),
stateChangesLabel: formatRecord(recordField(frame, "stateChanges"), "not captured in this recording"),
}];
}),
};
@@ -89,7 +89,10 @@ const renderFigure = (overrides: Partial<React.ComponentProps<typeof Interactive
};
};
afterEach(() => cleanup());
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
describe("InteractiveFigure", () => {
it("renders conceptual labels and hides evidence pointers by default", () => {
@@ -241,8 +244,5 @@ describe("InteractiveFigure", () => {
renderFigure({ focusPath: [], size: "stage" });
expect(requestAnimationFrameSpy).toHaveBeenCalled();
requestAnimationFrameSpy.mockRestore();
cancelAnimationFrameSpy.mockRestore();
});
});
@@ -163,7 +163,8 @@ export const demoSurfaceForBeat = (
if (sceneId === "resume-output-evidence") {
if (beatId === "resume") return { primarySurface: "resume-decision", supportSurface: "output-summary" };
if (beatId === "output") return { primarySurface: "workflow-output", supportSurface: "none" };
return { primarySurface: "trace-evidence", supportSurface: "output-summary" };
if (beatId === "trace") return { primarySurface: "trace-evidence", supportSurface: "output-summary" };
throw new Error(`Unknown beat ${beatId} for scene resume-output-evidence`);
}
return { primarySurface: "none", supportSurface: "none" };
};
@@ -2,7 +2,7 @@ import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
const css = readFileSync(join(import.meta.dirname, "presentation.css"), "utf8");
const css = readFileSync(join(import.meta.dirname, "presentation.css"), "utf8").replace(/\r\n/g, "\n");
describe("presentation.css", () => {
it("allows hidden primary-region scrolling for browser zoom overflow", () => {
@@ -8,8 +8,12 @@ export type PresentationTargetHealth =
export type TargetProbeState = "none" | "checking" | "ready" | "failed";
const shortTarget = (target: string): string => {
const url = new URL(target);
return `${url.hostname}:${url.port || (url.protocol === "https:" ? "443" : "80")}`;
try {
const url = new URL(target);
return `${url.hostname}:${url.port || (url.protocol === "https:" ? "443" : "80")}`;
} catch {
return target;
}
};
export const presentationTargetHealth = ({
@@ -2417,17 +2417,7 @@
letter-spacing: 0.04em;
}
/* Factual Scene 10 layout grids */
.guided-product-moment__approval-grid {
display: grid;
grid-template-columns: minmax(13rem, 0.56fr) minmax(0, 1.44fr);
gap: 0.75rem;
align-items: stretch;
min-height: 0;
max-height: 100%;
overflow: hidden;
}
/* Child min-height for all fact grids */
.guided-product-moment__approval-grid > *,
.guided-product-moment__resume-grid > *,
.guided-product-moment[data-moment="output"] > .guided-product-moment__primary > * {
@@ -2557,16 +2547,6 @@
font-size: 0.78rem;
}
.guided-product-moment__resume-grid {
display: grid;
grid-template-columns: 1.4fr 1fr;
gap: 1rem;
align-items: stretch;
min-height: 0;
max-height: 100%;
overflow: hidden;
}
.guided-product-moment[data-moment="resume"] .run-facts-card,
.guided-product-moment[data-moment="output"] .run-facts-card {
max-height: 100%;
@@ -2595,9 +2575,4 @@
display: none;
}
@media (max-width: 900px) {
.guided-product-moment__approval-grid,
.guided-product-moment__resume-grid {
grid-template-columns: 1fr;
}
}
/* Responsive grid behavior lives in demo-workflow.css container query */