docs: plan presentation chat timeline bridge

This commit is contained in:
lda
2026-07-09 06:06:30 +07:00 Verified
parent a86f54d14a
commit 5a3070df11
2 changed files with 1003 additions and 2 deletions
+6 -2
View File
@@ -120,9 +120,13 @@ Implementation order:
approval UI instead of raw `{ "type": "object" }` as the primary product approval UI instead of raw `{ "type": "object" }` as the primary product
proof. Implementation: proof. Implementation:
[`schema approval surface`](historical/superpowers/plans/2026-07-09-schema-approval-surface.md). [`schema approval surface`](historical/superpowers/plans/2026-07-09-schema-approval-surface.md).
16. Next: implement Guided Run Beat Gates, presenter companion, Scene 10 16. Next: bridge presentation chat to the prepared workflow timeline so chat
can start the live/replay demo and submit/cancel the schema approval through
the existing `useDemoTimeline` execution path. Active implementation plan:
[`presentation chat timeline bridge`](superpowers/plans/2026-07-09-presentation-chat-timeline-bridge.md).
17. Future: implement Guided Run Beat Gates, presenter companion, Scene 10
product graph, final scene visuals, evidence assets, and rehearsal timing. product graph, final scene visuals, evidence assets, and rehearsal timing.
17. Add a static slide/appendix shell only after presentation mode is clear. 18. Add a static slide/appendix shell only after presentation mode is clear.
Astro remains an option, not the default next surface. Astro remains an option, not the default next surface.
Presentation wishlist / defense readiness: Presentation wishlist / defense readiness:
@@ -0,0 +1,997 @@
# 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.