30 KiB
Presentation Chat Timeline Bridge Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Make presentation chat operate the existing prepared workflow timeline so /present can start, approve/cancel, resume, and inspect the demo through the same live/replay execution path as the product console.
Architecture: useDemoTimeline remains the only owner of workflow execution, RPC calls, replay progression, run IDs, interrupt payloads, output, trace, and evidence recording. The agent/chat layer becomes an adapter that emits messages around timeline commands instead of executing a parallel replay driver. /present resolves a live RPC target when possible, falls back to replay when unavailable, and passes one timeline controller to both the scene renderer and chat bridge.
Tech Stack: React 19, TypeScript, Vitest, Testing Library, existing callOperation/useDemoTimeline RPC path, no new runtime dependencies.
Global Constraints
- Do not add AI SDK, AI Elements, or a new chat component library in this slice.
- Do not add another RPC transport or direct
@effect/rpccaller in presentation code; use the existing consolecallOperationpath throughuseDemoTimeline. - Do not make
/presentrequire a live server. Replay must remain the safe default/fallback. - Chat must trigger real timeline methods when live mode is active:
start,next,submitSelectedIssues, andcancelReview. - Keep
AgentDriveras the future AI SDK seam, but do not force the live timeline bridge into the old prepared replay driver shape if a small controller adapter is clearer. - Use submitted/cancelled outcome language for workflow outcomes. Internal booleans such as
approvedmay remain where existing runtime payloads require them. - Keep tests scoped; do not run the full repo unless focused web checks pass first.
File Structure
- Create
web/apps/console/src/presentation/live-target.ts- Reads the persisted console target safely and decides whether
/presentcan attempt live mode.
- Reads the persisted console target safely and decides whether
- Create
web/apps/console/src/presentation/live-target.test.ts- Tests session storage fallback, explicit URL parsing, and safe failure when storage is unavailable.
- Create
web/apps/console/src/demo/agent/timelineAgent.ts- Builds messages and actions around
DemoTimelineControllerinstead of replay events.
- Builds messages and actions around
- Create
web/apps/console/src/demo/agent/timelineAgent.test.tsx- Hook/controller tests for start, live/replay mode labels, approval submit/cancel, and failure copy.
- Modify
web/apps/console/src/demo/agent/events.ts- Add a small command/result vocabulary if needed for timeline-backed chat status.
- Modify
web/apps/console/src/demo/agent/useDemoAgent.ts- Either keep as the replay-driver hook or add a sibling hook; do not contort it if timeline-backed chat has different lifecycle.
- Modify
web/apps/console/src/presentation/OperatorChat.tsx- Add a visible "Run prepared workflow" affordance in chat and route approval submit/cancel through timeline-backed handlers.
- Modify
web/apps/console/src/presentation/OperatorChat.test.tsx- Cover the run affordance, live/replay status, schema approval submit/cancel, and disabled state.
- Modify
web/apps/console/src/presentation/PresentationRoute.tsx- Resolve target, create
useDemoTimeline(target, recordEvidence, recording), create timeline-backed chat controller, and stop hardcoding replay-only timeline.
- Resolve target, create
- Modify
web/apps/console/src/presentation/PresentationRoute.test.tsx- Cover replay default, live target use, chat run click advancing the prepared timeline, and approval flow wiring.
- Modify
web/apps/console/src/presentation/presentation.cssorstyles/demo-workflow.css- Minimal styling for the chat run affordance and live/replay status. Do not redesign chat in this slice.
- Modify
docs/current_roadmap.md- Mark this bridge completed after implementation and leave source-owned AI Elements as a later slice.
Task 1: Presentation Live Target Resolution
Files:
- Create:
web/apps/console/src/presentation/live-target.ts - Create:
web/apps/console/src/presentation/live-target.test.ts
Interfaces:
- Produces:
export type PresentationTargetState =
| { readonly mode: "live"; readonly target: string; readonly source: "session-storage" | "default" }
| { readonly mode: "replay"; readonly target: null; readonly reason: string };
export const DEFAULT_PRESENTATION_TARGET = "http://127.0.0.1:8765/rpc";
export const resolvePresentationTarget = (
storage?: Storage | null,
): PresentationTargetState;
-
Consumes: no React APIs; this is pure browser-boundary logic.
-
Step 1: Write the failing target tests
Create web/apps/console/src/presentation/live-target.test.ts:
import { describe, expect, it } from "vitest";
import {
DEFAULT_PRESENTATION_TARGET,
resolvePresentationTarget,
} from "./live-target.js";
const fakeStorage = (value: string | null): Storage => ({
length: value === null ? 0 : 1,
clear() {},
getItem: (key: string) => key === "lda.workflowConsole.target" ? value : null,
key: () => null,
removeItem() {},
setItem() {},
});
describe("resolvePresentationTarget", () => {
it("uses the console target from session storage", () => {
expect(resolvePresentationTarget(fakeStorage("http://127.0.0.1:8765/rpc"))).toEqual({
mode: "live",
target: "http://127.0.0.1:8765/rpc",
source: "session-storage",
});
});
it("falls back to the default loopback target when storage is empty", () => {
expect(resolvePresentationTarget(fakeStorage(null))).toEqual({
mode: "live",
target: DEFAULT_PRESENTATION_TARGET,
source: "default",
});
});
it("uses replay mode when storage access throws", () => {
const broken = {
getItem() {
throw new Error("blocked");
},
} as unknown as Storage;
expect(resolvePresentationTarget(broken)).toEqual({
mode: "replay",
target: null,
reason: "session storage is unavailable",
});
});
it("uses replay mode for non-http targets", () => {
expect(resolvePresentationTarget(fakeStorage("file:///tmp/rpc"))).toEqual({
mode: "replay",
target: null,
reason: "presentation target is not an HTTP URL",
});
});
});
- Step 2: Run the tests to verify they fail
Run:
pnpm --filter @lda/console test -- src/presentation/live-target.test.ts
Expected: FAIL because live-target.ts does not exist.
- Step 3: Implement target resolution
Create web/apps/console/src/presentation/live-target.ts:
import { STORAGE_KEY } from "../app/state.js";
export type PresentationTargetState =
| { readonly mode: "live"; readonly target: string; readonly source: "session-storage" | "default" }
| { readonly mode: "replay"; readonly target: null; readonly reason: string };
export const DEFAULT_PRESENTATION_TARGET = "http://127.0.0.1:8765/rpc";
const isHttpUrl = (value: string): boolean => {
try {
const url = new URL(value);
return url.protocol === "http:" || url.protocol === "https:";
} catch {
return false;
}
};
export const resolvePresentationTarget = (
storage: Storage | null = typeof sessionStorage === "undefined" ? null : sessionStorage,
): PresentationTargetState => {
let stored: string | null;
try {
stored = storage?.getItem(STORAGE_KEY) ?? null;
} catch {
return {
mode: "replay",
target: null,
reason: "session storage is unavailable",
};
}
const target = stored ?? DEFAULT_PRESENTATION_TARGET;
if (!isHttpUrl(target)) {
return {
mode: "replay",
target: null,
reason: "presentation target is not an HTTP URL",
};
}
return {
mode: "live",
target,
source: stored ? "session-storage" : "default",
};
};
- Step 4: Run the target tests
Run:
pnpm --filter @lda/console test -- src/presentation/live-target.test.ts
Expected: PASS.
- Step 5: Commit
git add web/apps/console/src/presentation/live-target.ts web/apps/console/src/presentation/live-target.test.ts
git commit -m "feat: resolve presentation live target"
Task 2: Timeline-Backed Chat Controller
Files:
- Create:
web/apps/console/src/demo/agent/timelineAgent.ts - Create:
web/apps/console/src/demo/agent/timelineAgent.test.tsx - Modify:
web/apps/console/src/demo/agent/events.ts
Interfaces:
- Consumes:
import type { DemoTimelineController } from "../useDemoTimeline.js";
- Produces:
export type TimelineAgentMode = "live" | "replay";
export type TimelineAgentController = {
readonly messages: ReadonlyArray<AgentMessage>;
readonly canRun: boolean;
readonly runLabel: string;
readonly runPreparedWorkflow: () => Promise<void>;
readonly submitSelectedIssues: () => Promise<void>;
readonly cancelReview: () => Promise<void>;
};
export const useTimelineAgent = (
demo: DemoTimelineController,
modeLabel: TimelineAgentMode,
): TimelineAgentController;
-
The hook may use React state internally. It must not call
callOperationdirectly. -
Step 1: Add event helper if needed
Modify web/apps/console/src/demo/agent/events.ts only if the existing agentTextMessage, agentToolCallPart, and agentToolResultPart are insufficient. Prefer not changing it unless tests need a stable message shape.
If you add a helper, use this exact shape:
export const agentToolMessage = (
id: string,
name: AgentToolName,
input: unknown,
status: "success" | "failure",
output: unknown,
): AgentMessage => ({
id,
role: "assistant",
parts: [
agentToolCallPart(`${id}-call`, name, input),
agentToolResultPart(`${id}-call`, name, status, output),
],
});
- Step 2: Write failing timeline-agent tests
Create web/apps/console/src/demo/agent/timelineAgent.test.tsx:
import { act, renderHook } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import type { DemoTimelineController } from "../useDemoTimeline.js";
import { initialDemoTimelineState } from "../timeline/reducer.js";
import { useTimelineAgent } from "./timelineAgent.js";
const demoController = (
overrides: Partial<DemoTimelineController> = {},
): DemoTimelineController => ({
state: initialDemoTimelineState,
inFlight: false,
interruptPayload: null,
output: null,
trace: null,
missingDeploymentMessage: null,
recordingId: null,
canStart: true,
setMode: vi.fn(),
start: vi.fn(),
pause: vi.fn(),
play: vi.fn(),
next: vi.fn(async () => {}),
submitSelectedIssues: vi.fn(async () => {}),
cancelReview: vi.fn(async () => {}),
restart: vi.fn(),
...overrides,
});
describe("useTimelineAgent", () => {
it("starts the prepared workflow through the timeline", async () => {
const start = vi.fn();
const demo = demoController({ start });
const { result } = renderHook(() => useTimelineAgent(demo, "live"));
await act(async () => result.current.runPreparedWorkflow());
expect(start).toHaveBeenCalledTimes(1);
expect(result.current.messages.at(-1)?.parts).toEqual(
expect.arrayContaining([
expect.objectContaining({ type: "tool-result" }),
]),
);
});
it("submits selected issues from the current interrupt payload", async () => {
const submitSelectedIssues = vi.fn(async () => {});
const demo = demoController({
state: { ...initialDemoTimelineState, phase: "review" },
interruptPayload: {
report_markdown: "# Report",
proposed_issues: [
{ id: "risk-1", title: "Risk", body: "Body", severity: "medium" },
],
},
submitSelectedIssues,
});
const { result } = renderHook(() => useTimelineAgent(demo, "replay"));
await act(async () => result.current.submitSelectedIssues());
expect(submitSelectedIssues).toHaveBeenCalledWith(["risk-1"], "Create the selected issue.");
});
it("cancels review through the timeline", async () => {
const cancelReview = vi.fn(async () => {});
const demo = demoController({
state: { ...initialDemoTimelineState, phase: "review" },
cancelReview,
});
const { result } = renderHook(() => useTimelineAgent(demo, "live"));
await act(async () => result.current.cancelReview());
expect(cancelReview).toHaveBeenCalledWith("Cancelled by operator.");
});
it("disables run when the timeline cannot start", () => {
const demo = demoController({ canStart: false });
const { result } = renderHook(() => useTimelineAgent(demo, "live"));
expect(result.current.canRun).toBe(false);
});
});
- Step 3: Run tests to verify they fail
Run:
pnpm --filter @lda/console test -- src/demo/agent/timelineAgent.test.tsx
Expected: FAIL because timelineAgent.ts does not exist.
- Step 4: Implement
useTimelineAgent
Create web/apps/console/src/demo/agent/timelineAgent.ts:
import { useCallback, useMemo, useState } from "react";
import type { DemoTimelineController } from "../useDemoTimeline.js";
import {
agentTextMessage,
agentToolCallPart,
agentToolResultPart,
type AgentMessage,
} from "./events.js";
export type TimelineAgentMode = "live" | "replay";
export type TimelineAgentController = {
readonly messages: ReadonlyArray<AgentMessage>;
readonly canRun: boolean;
readonly runLabel: string;
readonly runPreparedWorkflow: () => Promise<void>;
readonly submitSelectedIssues: () => Promise<void>;
readonly cancelReview: () => Promise<void>;
};
const DEFAULT_COMMENT = "Create the selected issue.";
const appendToolMessage = (
messages: ReadonlyArray<AgentMessage>,
id: string,
name: "startPreparedReportRun" | "resumeIssueReview" | "readRunTrace",
input: unknown,
output: unknown,
): ReadonlyArray<AgentMessage> => [
...messages,
{
id,
role: "assistant",
parts: [
agentToolCallPart(`${id}-call`, name, input),
agentToolResultPart(`${id}-call`, name, "success", output),
],
},
];
export const useTimelineAgent = (
demo: DemoTimelineController,
modeLabel: TimelineAgentMode,
): TimelineAgentController => {
const [messages, setMessages] = useState<ReadonlyArray<AgentMessage>>([
agentTextMessage(
"timeline-agent-intro",
"assistant",
modeLabel === "live"
? "Live workflow server is available. I can run the prepared workflow now."
: "Replay fallback is active. I can still walk through the prepared workflow evidence.",
),
]);
const runLabel = modeLabel === "live" ? "Run prepared workflow" : "Run replay walkthrough";
const canRun = demo.canStart && !demo.inFlight && demo.state.phase !== "running";
const runPreparedWorkflow = useCallback(async () => {
if (!demo.canStart || demo.inFlight) return;
demo.restart();
demo.start();
setMessages((current) => appendToolMessage(
current,
"timeline-agent-start",
"startPreparedReportRun",
{ mode: modeLabel },
{ phase: "started" },
));
}, [demo, modeLabel]);
const selectedIssueIds = useMemo(
() => demo.interruptPayload?.proposed_issues.map((issue) => issue.id) ?? [],
[demo.interruptPayload],
);
const submitSelectedIssues = useCallback(async () => {
if (selectedIssueIds.length === 0) return;
await demo.submitSelectedIssues(selectedIssueIds, DEFAULT_COMMENT);
await demo.next();
setMessages((current) => appendToolMessage(
current,
"timeline-agent-submit",
"resumeIssueReview",
{ selectedIssueIds },
{ outcome: "submitted" },
));
}, [demo, selectedIssueIds]);
const cancelReview = useCallback(async () => {
await demo.cancelReview("Cancelled by operator.");
await demo.next();
setMessages((current) => appendToolMessage(
current,
"timeline-agent-cancel",
"resumeIssueReview",
{},
{ outcome: "cancelled" },
));
}, [demo]);
return {
messages,
canRun,
runLabel,
runPreparedWorkflow,
submitSelectedIssues,
cancelReview,
};
};
If TypeScript reports that the tool names are not assignable, import type AgentToolName and use it in appendToolMessage.
- Step 5: Run timeline-agent tests
Run:
pnpm --filter @lda/console test -- src/demo/agent/timelineAgent.test.tsx
Expected: PASS.
- Step 6: Commit
git add web/apps/console/src/demo/agent/timelineAgent.ts web/apps/console/src/demo/agent/timelineAgent.test.tsx web/apps/console/src/demo/agent/events.ts
git commit -m "feat: add timeline backed demo agent"
Task 3: Chat UI Runs The Timeline
Files:
- Modify:
web/apps/console/src/presentation/OperatorChat.tsx - Modify:
web/apps/console/src/presentation/OperatorChat.test.tsx - Modify:
web/apps/console/src/presentation/presentation.css
Interfaces:
- Consumes:
import type { TimelineAgentController } from "../demo/agent/timelineAgent.js";
- Produces:
type OperatorChatProps = {
readonly state: PresentationState;
readonly messages?: ReadonlyArray<AgentMessage> | undefined;
readonly timelineAgent?: TimelineAgentController | undefined;
readonly onApprove?: (() => void) | undefined;
readonly onDeny?: (() => void) | undefined;
};
- Step 1: Write failing chat tests
Modify web/apps/console/src/presentation/OperatorChat.test.tsx:
it("shows a chat-owned run prepared workflow action", async () => {
const user = userEvent.setup();
const runPreparedWorkflow = vi.fn(async () => {});
render(
<OperatorChat
state={initialPresentationState}
timelineAgent={{
messages: [],
canRun: true,
runLabel: "Run prepared workflow",
runPreparedWorkflow,
submitSelectedIssues: vi.fn(async () => {}),
cancelReview: vi.fn(async () => {}),
}}
/>,
);
await user.click(screen.getByRole("button", { name: /run prepared workflow/i }));
expect(runPreparedWorkflow).toHaveBeenCalledTimes(1);
});
it("routes schema approval submit and cancel through the timeline agent when present", async () => {
const user = userEvent.setup();
const submitSelectedIssues = vi.fn(async () => {});
const cancelReview = vi.fn(async () => {});
const messages: ReadonlyArray<AgentMessage> = [
{
id: "approval",
role: "assistant",
parts: [
{
type: "approval-request",
callId: "call-1",
name: "resumeIssueReview",
prompt: "Submit resume request?",
contract: {
kind: "issue_review",
outcomes: ["submitted", "cancelled"],
resumeSchema: { type: "object" },
resumePayloadPreview: { selected_issue_ids: ["risk-1"] },
runId: "run_recorded_lda_report",
},
},
],
},
];
render(
<OperatorChat
state={initialPresentationState}
messages={messages}
timelineAgent={{
messages: [],
canRun: false,
runLabel: "Run prepared workflow",
runPreparedWorkflow: vi.fn(async () => {}),
submitSelectedIssues,
cancelReview,
}}
/>,
);
await user.click(screen.getByRole("button", { name: /submit/i }));
await user.click(screen.getByRole("button", { name: /cancel/i }));
expect(submitSelectedIssues).toHaveBeenCalledTimes(1);
expect(cancelReview).toHaveBeenCalledTimes(1);
});
- Step 2: Run tests to verify they fail
Run:
pnpm --filter @lda/console test -- src/presentation/OperatorChat.test.tsx
Expected: FAIL because timelineAgent prop does not exist.
- Step 3: Wire run action and timeline approval handlers
Modify web/apps/console/src/presentation/OperatorChat.tsx:
import type { TimelineAgentController } from "../demo/agent/timelineAgent.js";
Update props:
type OperatorChatProps = {
readonly state: PresentationState;
readonly messages?: ReadonlyArray<AgentMessage> | undefined;
readonly timelineAgent?: TimelineAgentController | undefined;
readonly onApprove?: (() => void) | undefined;
readonly onDeny?: (() => void) | undefined;
};
In the component:
const visibleMessages = messages && messages.length > 0
? messages
: timelineAgent && timelineAgent.messages.length > 0
? timelineAgent.messages
: fallbackMessages(state);
const submit = timelineAgent?.submitSelectedIssues ?? onApprove;
const cancel = timelineAgent?.cancelReview ?? onDeny;
Render the action before messages:
{timelineAgent ? (
<div className="operator-chat__action">
<button
type="button"
onClick={() => void timelineAgent.runPreparedWorkflow()}
disabled={!timelineAgent.canRun}
>
{timelineAgent.runLabel}
</button>
</div>
) : null}
Pass submit and cancel to renderPart.
- Step 4: Add minimal CSS
Append to web/apps/console/src/presentation/presentation.css:
.operator-chat__action {
display: flex;
padding: 0.65rem;
border-bottom: 1px solid var(--stage-line);
}
.operator-chat__action button {
width: 100%;
border: 1px solid color-mix(in oklch, var(--accent-cyan) 56%, var(--stage-line));
border-radius: 0.55rem;
padding: 0.6rem 0.75rem;
background: color-mix(in oklch, var(--accent-cyan) 18%, var(--stage-surface));
color: var(--text-primary);
font: 700 0.8rem/1 var(--font-mono);
}
.operator-chat__action button:disabled {
cursor: not-allowed;
opacity: 0.55;
}
- Step 5: Run chat tests
Run:
pnpm --filter @lda/console test -- src/presentation/OperatorChat.test.tsx
Expected: PASS.
- Step 6: Commit
git add web/apps/console/src/presentation/OperatorChat.tsx web/apps/console/src/presentation/OperatorChat.test.tsx web/apps/console/src/presentation/presentation.css
git commit -m "feat: let chat run demo timeline"
Task 4: Presentation Route Uses Live Timeline When Available
Files:
- Modify:
web/apps/console/src/presentation/PresentationRoute.tsx - Modify:
web/apps/console/src/presentation/PresentationRoute.test.tsx - Modify:
web/apps/console/src/presentation/PresentationStage.tsx
Interfaces:
-
Consumes:
resolvePresentationTarget()useTimelineAgent(demo, modeLabel)- existing
useDemoTimeline(target, recordEvidence, recording)
-
Produces:
/presentno longer hardcodesuseDemoTimeline(null, ...).- Chat run button drives the same
demoused byDemoWorkflowScene. PresentationRouteno longer creates the old recording-only prepared replay agent; the timeline-backed chat controller owns the run affordance.
-
Step 1: Write failing route tests
Modify web/apps/console/src/presentation/PresentationRoute.test.tsx:
it("renders a chat run action on the presentation route", async () => {
window.location.hash = "#scene/agent-handoff/request";
const { PresentationRoute } = await import("./PresentationRoute.js");
render(<PresentationRoute />);
expect(await screen.findByRole("button", { name: /run prepared workflow|run replay walkthrough/i })).toBeInTheDocument();
});
it("uses stored target for live presentation mode", async () => {
window.sessionStorage.setItem("lda.workflowConsole.target", "http://127.0.0.1:8765/rpc");
const { PresentationRoute } = await import("./PresentationRoute.js");
render(<PresentationRoute />);
expect(await screen.findByRole("button", { name: /run prepared workflow/i })).toBeInTheDocument();
});
If tests share session state, add window.sessionStorage.clear() in afterEach.
- Step 2: Run tests to verify they fail
Run:
pnpm --filter @lda/console test -- src/presentation/PresentationRoute.test.tsx
Expected: FAIL because route does not create or pass timelineAgent.
- Step 3: Wire live target and timeline agent
Modify imports in web/apps/console/src/presentation/PresentationRoute.tsx:
import { useTimelineAgent } from "../demo/agent/timelineAgent.js";
import { resolvePresentationTarget } from "./live-target.js";
Replace:
const demo = useDemoTimeline(null, recordEvidence, recording);
With:
const presentationTarget = useMemo(() => resolvePresentationTarget(), []);
const demo = useDemoTimeline(presentationTarget.target, recordEvidence, recording);
const timelineAgent = useTimelineAgent(
demo,
presentationTarget.mode === "live" ? "live" : "replay",
);
Replace the auto-forced replay effects with target-aware behavior:
useEffect(() => {
if (demo.state.phase === "ready" && presentationTarget.mode === "replay" && demo.state.mode !== "replay") {
demo.setMode("replay");
}
if (demo.state.phase === "ready" && presentationTarget.mode === "live" && demo.state.mode !== "live") {
demo.setMode("live");
}
}, [demo.state.phase, demo.state.mode, demo.setMode, presentationTarget.mode]);
Keep the existing replay auto-start only for baseline visual content, but do not let it fight live mode:
useEffect(() => {
if (presentationTarget.mode === "replay" && demo.state.phase === "ready" && demo.state.mode === "replay") {
demo.start();
}
}, [demo.state.phase, demo.state.mode, demo.start, presentationTarget.mode]);
Remove these old prepared-agent lines from PresentationRoute.tsx:
import { createPreparedRecipeDriver, assertNever } from "../demo/agent/preparedRecipeDriver.js";
import { useDemoAgent } from "../demo/agent/useDemoAgent.js";
Remove the agentDriver, agent, handleApprove, handleDeny, and agent.pendingActions effect. The prepared recording-only agent is still available for tests and future comparison, but /present should use the timeline-backed chat controller.
Pass timelineAgent to PresentationStage:
timelineAgent={timelineAgent}
Remove messages={agent.messages}, onApprove={...}, and onDeny={...} from the PresentationStage call in PresentationRoute.tsx.
- Step 4: Thread the prop through
PresentationStage
Modify web/apps/console/src/presentation/PresentationStage.tsx:
import type { TimelineAgentController } from "../demo/agent/timelineAgent.js";
Add prop:
readonly timelineAgent?: TimelineAgentController | undefined;
Pass to chat:
<OperatorChat
state={state}
messages={messages}
timelineAgent={timelineAgent}
onApprove={onApprove}
onDeny={onDeny}
/>
- Step 5: Run route tests
Run:
pnpm --filter @lda/console test -- src/presentation/PresentationRoute.test.tsx src/presentation/OperatorChat.test.tsx
Expected: PASS.
- Step 6: Commit
git add web/apps/console/src/presentation/PresentationRoute.tsx web/apps/console/src/presentation/PresentationRoute.test.tsx web/apps/console/src/presentation/PresentationStage.tsx
git commit -m "feat: connect presentation chat to timeline"
Task 5: End-To-End Behavior Polish And Verification
Files:
- Modify:
web/apps/console/src/presentation/PresentationRoute.test.tsx - Modify:
docs/current_roadmap.md - Move after completion:
docs/superpowers/plans/2026-07-09-presentation-chat-timeline-bridge.md->docs/historical/superpowers/plans/2026-07-09-presentation-chat-timeline-bridge.md
Interfaces:
-
Consumes all previous task outputs.
-
Produces completed roadmap entry and archived plan.
-
Step 1: Add one integrated replay smoke test
Modify web/apps/console/src/presentation/PresentationRoute.test.tsx:
it("chat run action advances the replay timeline when no live server is configured", async () => {
window.sessionStorage.setItem("lda.workflowConsole.target", "file:///invalid");
window.location.hash = "#scene/workflow-demo/operation";
const user = userEvent.setup();
const { PresentationRoute } = await import("./PresentationRoute.js");
render(<PresentationRoute />);
await user.click(await screen.findByRole("button", { name: /run replay walkthrough/i }));
// Replay mode is timer-driven after the chat starts the timeline.
await userEvent.keyboard("{ArrowRight}");
expect(await screen.findByLabelText("workflow.runs.start operation")).toBeInTheDocument();
});
- Step 2: Run presentation and demo tests
Run:
pnpm --filter @lda/console test -- src/demo src/presentation
Expected: PASS.
- Step 3: Run broad web checks
Run:
pnpm --dir web test
pnpm --dir web typecheck
pnpm --dir web build
Expected:
-
Tests pass.
-
Typecheck clean.
-
Build passes with only the known Vite chunk-size warning if it appears.
-
Step 4: Browser smoke
With pnpm dev running, check:
http://127.0.0.1:5173/present#scene/agent-handoff/request
http://127.0.0.1:5173/present#scene/interrupt-evidence/approval
Expected:
-
Chat shows a run action.
-
Replay fallback button says "Run replay walkthrough" when target resolution fails.
-
Live button says "Run prepared workflow" when a valid target is in
sessionStorage. -
Approval submit/cancel routes through the timeline agent.
-
Scene evidence and graph still update from the same
democontroller. -
Step 5: Update roadmap
In docs/current_roadmap.md:
- Add a completed item after the schema approval surface entry.
- Keep AI Elements/chat-library adoption as future work.
Suggested wording:
16. Completed: presentation chat now drives the prepared workflow timeline.
The chat run action, schema approval submit/cancel, graph, evidence, and
live/replay execution all share `useDemoTimeline`; AI SDK remains a later
driver for the same seam. Implementation:
[`presentation chat timeline bridge`](historical/superpowers/plans/2026-07-09-presentation-chat-timeline-bridge.md).
- Step 6: Archive the plan
Run:
git mv docs/superpowers/plans/2026-07-09-presentation-chat-timeline-bridge.md docs/historical/superpowers/plans/2026-07-09-presentation-chat-timeline-bridge.md
- Step 7: Commit
git add docs/current_roadmap.md docs/historical/superpowers/plans/2026-07-09-presentation-chat-timeline-bridge.md web/apps/console/src/presentation/PresentationRoute.test.tsx
git commit -m "docs: complete presentation chat timeline bridge"
Self-Review Checklist
/presentno longer hardcodesuseDemoTimeline(null, ...).- Chat run action calls timeline methods, not direct RPC and not the old recording-only driver.
- Live mode uses the existing
callOperationpath throughuseDemoTimeline. - Replay fallback remains available and test-covered.
- Approval submit/cancel uses timeline review methods.
- Existing schema approval surface remains reusable and does not become presentation-only.
- No new dependency or AI SDK package was added.
- Roadmap keeps AI Elements/source-owned chat primitive work as a later slice.