998 lines
30 KiB
Markdown
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.
|