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

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/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:
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 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:

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:

    • /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:

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 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:

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

  • /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.