Files
lda-wf/docs/historical/superpowers/plans/2026-07-09-presentation-chat-timeline-bridge.md
T

998 lines
30 KiB
Markdown

# 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/rpc` caller in presentation code; use the existing console `callOperation` path through `useDemoTimeline`.
- Do not make `/present` require a live server. Replay must remain the safe default/fallback.
- Chat must trigger real timeline methods when live mode is active: `start`, `next`, `submitSelectedIssues`, and `cancelReview`.
- Keep `AgentDriver` as 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 `approved` may 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 `/present` can attempt live mode.
- 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 `DemoTimelineController` instead of replay events.
- 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.
- 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.css` or `styles/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:
```ts
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`:
```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:
```bash
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`:
```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:
```bash
pnpm --filter @lda/console test -- src/presentation/live-target.test.ts
```
Expected: PASS.
- [ ] **Step 5: Commit**
```bash
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:
```ts
import type { DemoTimelineController } from "../useDemoTimeline.js";
```
- Produces:
```ts
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 `callOperation` directly.
- [ ] **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:
```ts
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`:
```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:
```bash
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`:
```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:
```bash
pnpm --filter @lda/console test -- src/demo/agent/timelineAgent.test.tsx
```
Expected: PASS.
- [ ] **Step 6: Commit**
```bash
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:
```ts
import type { TimelineAgentController } from "../demo/agent/timelineAgent.js";
```
- Produces:
```ts
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`:
```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:
```bash
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`:
```tsx
import type { TimelineAgentController } from "../demo/agent/timelineAgent.js";
```
Update props:
```ts
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:
```tsx
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:
```tsx
{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`:
```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:
```bash
pnpm --filter @lda/console test -- src/presentation/OperatorChat.test.tsx
```
Expected: PASS.
- [ ] **Step 6: Commit**
```bash
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:
- `/present` no longer hardcodes `useDemoTimeline(null, ...)`.
- Chat run button drives the same `demo` used by `DemoWorkflowScene`.
- `PresentationRoute` no 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`:
```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:
```bash
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`:
```ts
import { useTimelineAgent } from "../demo/agent/timelineAgent.js";
import { resolvePresentationTarget } from "./live-target.js";
```
Replace:
```ts
const demo = useDemoTimeline(null, recordEvidence, recording);
```
With:
```ts
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:
```ts
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:
```ts
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`:
```ts
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`:
```tsx
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`:
```ts
import type { TimelineAgentController } from "../demo/agent/timelineAgent.js";
```
Add prop:
```ts
readonly timelineAgent?: TimelineAgentController | undefined;
```
Pass to chat:
```tsx
<OperatorChat
state={state}
messages={messages}
timelineAgent={timelineAgent}
onApprove={onApprove}
onDeny={onDeny}
/>
```
- [ ] **Step 5: Run route tests**
Run:
```bash
pnpm --filter @lda/console test -- src/presentation/PresentationRoute.test.tsx src/presentation/OperatorChat.test.tsx
```
Expected: PASS.
- [ ] **Step 6: Commit**
```bash
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`:
```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:
```bash
pnpm --filter @lda/console test -- src/demo src/presentation
```
Expected: PASS.
- [ ] **Step 3: Run broad web checks**
Run:
```bash
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:
```text
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 `demo` controller.
- [ ] **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:
```md
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:
```bash
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**
```bash
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
- [ ] `/present` no longer hardcodes `useDemoTimeline(null, ...)`.
- [ ] Chat run action calls timeline methods, not direct RPC and not the old recording-only driver.
- [ ] Live mode uses the existing `callOperation` path through `useDemoTimeline`.
- [ ] 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.