docs: plan presentation live replay truth

This commit is contained in:
lda
2026-07-09 15:38:15 +07:00 Verified
parent 2de037c9fe
commit b9bf5bac60
3 changed files with 973 additions and 2 deletions
@@ -0,0 +1,821 @@
# Presentation Live/Replay Truth 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 mode explicitly distinguish replay evidence, configured live target, reachable live target, active live run, and replay fallback.
**Architecture:** Add a small presentation target-status model/hook, render it in the presentation footer, and route chat run labels through that status. Keep direct scene hashes replay-backed until the operator intentionally starts live execution.
**Tech Stack:** React 19, TypeScript, Vitest, Testing Library, existing console RPC operation layer, existing presentation footer/chat components.
## Global Constraints
- Do not add a new JSON-RPC transport if the existing console operation layer can call `workflow.health`.
- Do not change `/console` connection behavior.
- Direct scene hashes must remain replay-backed and ready.
- Do not say "live run active" before the operator starts the timeline in live mode.
- Health check failure must fall back to replay wording, not crash the presentation.
- Keep UI compact; this is a truth badge, not a new hero panel.
---
## File Structure
- Create `web/apps/console/src/presentation/presentation-target-status.ts`
- Pure status model and copy helpers.
- Create `web/apps/console/src/presentation/presentation-target-status.test.ts`
- Tests status derivation.
- Create `web/apps/console/src/presentation/usePresentationTargetStatus.ts`
- Hook that probes target health and combines it with demo timeline state.
- Create `web/apps/console/src/presentation/usePresentationTargetStatus.test.tsx`
- Tests health success/failure and active live state.
- Create `web/apps/console/src/presentation/PresentationTruthBadge.tsx`
- Compact footer badge.
- Create `web/apps/console/src/presentation/PresentationTruthBadge.test.tsx`
- Tests labels and data-state attributes.
- Modify `web/apps/console/src/presentation/PresentationRoute.tsx`
- Uses the hook and passes status to stage/chat/footer.
- Modify `web/apps/console/src/presentation/PresentationStage.tsx`
- Threads status to footer and chat.
- Modify `web/apps/console/src/presentation/PresentationFooter.tsx`
- Renders `PresentationTruthBadge`.
- Modify `web/apps/console/src/presentation/OperatorChat.tsx`
- Uses status-aware run label/copy.
- Modify `web/apps/console/src/demo/agent/timelineAgent.ts`
- Accepts status-derived mode/copy or receives enabled label from route.
- Modify `web/apps/console/src/presentation/presentation.css`
- Adds compact badge styles.
---
### Task 1: Add pure presentation target status model
**Files:**
- Create: `web/apps/console/src/presentation/presentation-target-status.ts`
- Create: `web/apps/console/src/presentation/presentation-target-status.test.ts`
**Interfaces:**
- Produces:
```ts
export type PresentationTargetHealth =
| { readonly kind: "replay"; readonly label: string; readonly detail: string }
| { readonly kind: "checking"; readonly target: string; readonly label: string; readonly detail: string }
| { readonly kind: "ready"; readonly target: string; readonly label: string; readonly detail: string }
| { readonly kind: "active"; readonly target: string; readonly label: string; readonly detail: string }
| { readonly kind: "failed"; readonly target: string | null; readonly label: string; readonly detail: string };
export type TargetProbeState = "none" | "checking" | "ready" | "failed";
export const presentationTargetHealth = (input: {
readonly target: string | null;
readonly probe: TargetProbeState;
readonly liveActive: boolean;
readonly failureReason?: string | undefined;
}): PresentationTargetHealth;
```
- [ ] **Step 1: Write failing tests**
Create `web/apps/console/src/presentation/presentation-target-status.test.ts`:
```ts
import { describe, expect, it } from "vitest";
import { presentationTargetHealth } from "./presentation-target-status.js";
describe("presentationTargetHealth", () => {
it("shows replay evidence when no target exists", () => {
expect(presentationTargetHealth({
target: null,
probe: "none",
liveActive: false,
})).toMatchObject({
kind: "replay",
label: "Replay evidence",
});
});
it("separates ready target from active live run", () => {
expect(presentationTargetHealth({
target: "http://127.0.0.1:8765/rpc",
probe: "ready",
liveActive: false,
})).toMatchObject({
kind: "ready",
label: "Live target ready",
});
});
it("marks live active only after live timeline starts", () => {
expect(presentationTargetHealth({
target: "http://127.0.0.1:8765/rpc",
probe: "ready",
liveActive: true,
})).toMatchObject({
kind: "active",
label: "Live run active",
});
});
it("shows replay fallback on failed health", () => {
expect(presentationTargetHealth({
target: "http://127.0.0.1:8765/rpc",
probe: "failed",
liveActive: false,
failureReason: "connection refused",
})).toMatchObject({
kind: "failed",
label: "Replay fallback",
});
});
});
```
- [ ] **Step 2: Run failing tests**
```bash
pnpm --dir web --filter @lda/console test -- src/presentation/presentation-target-status.test.ts
```
Expected: FAIL because the model file does not exist.
- [ ] **Step 3: Implement model**
Create `web/apps/console/src/presentation/presentation-target-status.ts`:
```ts
export type PresentationTargetHealth =
| { readonly kind: "replay"; readonly label: string; readonly detail: string }
| { readonly kind: "checking"; readonly target: string; readonly label: string; readonly detail: string }
| { readonly kind: "ready"; readonly target: string; readonly label: string; readonly detail: string }
| { readonly kind: "active"; readonly target: string; readonly label: string; readonly detail: string }
| { readonly kind: "failed"; readonly target: string | null; readonly label: string; readonly detail: string };
export type TargetProbeState = "none" | "checking" | "ready" | "failed";
const shortTarget = (target: string): string => {
const url = new URL(target);
return `${url.hostname}:${url.port || (url.protocol === "https:" ? "443" : "80")}`;
};
export const presentationTargetHealth = ({
target,
probe,
liveActive,
failureReason,
}: {
readonly target: string | null;
readonly probe: TargetProbeState;
readonly liveActive: boolean;
readonly failureReason?: string | undefined;
}): PresentationTargetHealth => {
if (!target) {
return {
kind: "replay",
label: "Replay evidence",
detail: "reviewed recording",
};
}
if (liveActive && probe === "ready") {
return {
kind: "active",
target,
label: "Live run active",
detail: `operations sent to ${shortTarget(target)}`,
};
}
if (probe === "ready") {
return {
kind: "ready",
target,
label: "Live target ready",
detail: shortTarget(target),
};
}
if (probe === "checking") {
return {
kind: "checking",
target,
label: "Live target configured",
detail: "checking",
};
}
return {
kind: "failed",
target,
label: "Replay fallback",
detail: failureReason ?? "live target unreachable",
};
};
```
- [ ] **Step 4: Run tests**
```bash
pnpm --dir web --filter @lda/console test -- src/presentation/presentation-target-status.test.ts
```
Expected: PASS.
- [ ] **Step 5: Commit**
```bash
git add web/apps/console/src/presentation/presentation-target-status.ts web/apps/console/src/presentation/presentation-target-status.test.ts
git commit -m "feat: model presentation live replay status"
```
---
### Task 2: Add target health hook
**Files:**
- Create: `web/apps/console/src/presentation/usePresentationTargetStatus.ts`
- Create: `web/apps/console/src/presentation/usePresentationTargetStatus.test.tsx`
**Interfaces:**
- Consumes:
- `PresentationTargetState` from `live-target.ts`
- `DemoTimelineController["state"]`
- Existing operation call path if available.
- Produces:
```ts
export const usePresentationTargetStatus = (
targetState: PresentationTargetState,
demoState: DemoTimelineState,
): PresentationTargetHealth;
```
- [ ] **Step 1: Inspect existing RPC operation helper**
Run:
```bash
rg -n "workflow.health|callOperation|OperationName" web/apps/console/src web/packages/rpc/src
```
Use the existing helper that the console already uses for lifecycle/demo calls.
Do not create a separate fetch transport if `callOperation` can call
`workflow.health`.
- [ ] **Step 2: Write failing hook tests**
Create `web/apps/console/src/presentation/usePresentationTargetStatus.test.tsx`.
Mock the existing call helper. If it is `callOperation`, use:
```tsx
import { renderHook, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { callOperation } from "../connection/api.js";
import { initialDemoTimelineState } from "../demo/timeline/reducer.js";
import { usePresentationTargetStatus } from "./usePresentationTargetStatus.js";
vi.mock("../connection/api.js", () => ({ callOperation: vi.fn() }));
const mockedCallOperation = vi.mocked(callOperation);
beforeEach(() => mockedCallOperation.mockReset());
describe("usePresentationTargetStatus", () => {
it("marks live target ready after workflow health succeeds", async () => {
mockedCallOperation.mockResolvedValueOnce({
ok: true,
operation: "workflow.health",
label: "Health",
interpreted: { status: "ok", storeRoot: "store" },
exchange: { request: {}, response: {} },
equivalentCli: "uv run wf status",
durationMs: 2,
});
const { result } = renderHook(() =>
usePresentationTargetStatus(
{ mode: "live", target: "http://127.0.0.1:8765/rpc", source: "default" },
initialDemoTimelineState,
),
);
await waitFor(() => expect(result.current.kind).toBe("ready"));
});
it("falls back to replay when health fails", async () => {
mockedCallOperation.mockRejectedValueOnce(new Error("connection refused"));
const { result } = renderHook(() =>
usePresentationTargetStatus(
{ mode: "live", target: "http://127.0.0.1:8765/rpc", source: "default" },
initialDemoTimelineState,
),
);
await waitFor(() => expect(result.current.kind).toBe("failed"));
expect(result.current.label).toBe("Replay fallback");
});
});
```
Adjust operation response fields to match existing `callOperation` types.
- [ ] **Step 3: Run failing tests**
```bash
pnpm --dir web --filter @lda/console test -- src/presentation/usePresentationTargetStatus.test.tsx
```
Expected: FAIL because the hook does not exist.
- [ ] **Step 4: Implement hook**
Create `web/apps/console/src/presentation/usePresentationTargetStatus.ts`:
```ts
import { useEffect, useState } from "react";
import { callOperation } from "../connection/api.js";
import type { DemoTimelineState } from "../demo/timeline/reducer.js";
import type { PresentationTargetState } from "./live-target.js";
import {
presentationTargetHealth,
type PresentationTargetHealth,
type TargetProbeState,
} from "./presentation-target-status.js";
const liveActive = (state: DemoTimelineState): boolean =>
state.mode === "live" && state.phase !== "ready";
export const usePresentationTargetStatus = (
targetState: PresentationTargetState,
demoState: DemoTimelineState,
): PresentationTargetHealth => {
const [probe, setProbe] = useState<TargetProbeState>(
targetState.mode === "live" ? "checking" : "none",
);
const [failureReason, setFailureReason] = useState<string | undefined>(undefined);
useEffect(() => {
let cancelled = false;
if (targetState.mode !== "live") {
setProbe("none");
setFailureReason(targetState.reason);
return;
}
setProbe("checking");
setFailureReason(undefined);
void callOperation(targetState.target, "workflow.health", {}).then(
() => {
if (!cancelled) setProbe("ready");
},
(error: unknown) => {
if (!cancelled) {
setProbe("failed");
setFailureReason(error instanceof Error ? error.message : String(error));
}
},
);
return () => {
cancelled = true;
};
}, [targetState]);
return presentationTargetHealth({
target: targetState.mode === "live" ? targetState.target : null,
probe,
liveActive: liveActive(demoState),
failureReason,
});
};
```
If `callOperation` takes a different parameter shape, adapt to the existing
function signature and update the test mock accordingly.
- [ ] **Step 5: Run hook tests**
```bash
pnpm --dir web --filter @lda/console test -- src/presentation/usePresentationTargetStatus.test.tsx
```
Expected: PASS.
- [ ] **Step 6: Commit**
```bash
git add web/apps/console/src/presentation/usePresentationTargetStatus.ts web/apps/console/src/presentation/usePresentationTargetStatus.test.tsx
git commit -m "feat: probe presentation live target"
```
---
### Task 3: Render the truth badge in the footer
**Files:**
- Create: `web/apps/console/src/presentation/PresentationTruthBadge.tsx`
- Create: `web/apps/console/src/presentation/PresentationTruthBadge.test.tsx`
- Modify: `web/apps/console/src/presentation/PresentationFooter.tsx`
- Modify: `web/apps/console/src/presentation/PresentationStage.tsx`
- Modify: `web/apps/console/src/presentation/PresentationRoute.tsx`
- Modify: `web/apps/console/src/presentation/presentation.css`
**Interfaces:**
- Consumes:
- `PresentationTargetHealth`
- Produces:
- Visible badge in presentation footer.
- [ ] **Step 1: Write failing badge tests**
Create `web/apps/console/src/presentation/PresentationTruthBadge.test.tsx`:
```tsx
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { PresentationTruthBadge } from "./PresentationTruthBadge.js";
describe("PresentationTruthBadge", () => {
it("renders status label and detail", () => {
render(
<PresentationTruthBadge
status={{
kind: "ready",
target: "http://127.0.0.1:8765/rpc",
label: "Live target ready",
detail: "127.0.0.1:8765",
}}
/>,
);
expect(screen.getByLabelText("presentation evidence mode")).toHaveAttribute("data-status", "ready");
expect(screen.getByText("Live target ready")).toBeInTheDocument();
expect(screen.getByText("127.0.0.1:8765")).toBeInTheDocument();
});
});
```
- [ ] **Step 2: Run failing tests**
```bash
pnpm --dir web --filter @lda/console test -- src/presentation/PresentationTruthBadge.test.tsx
```
Expected: FAIL because component does not exist.
- [ ] **Step 3: Implement badge**
Create `web/apps/console/src/presentation/PresentationTruthBadge.tsx`:
```tsx
import type { PresentationTargetHealth } from "./presentation-target-status.js";
export const PresentationTruthBadge = ({
status,
}: {
readonly status: PresentationTargetHealth;
}) => (
<aside
className="presentation-truth-badge"
data-status={status.kind}
aria-label="presentation evidence mode"
>
<strong>{status.label}</strong>
<span>{status.detail}</span>
</aside>
);
```
- [ ] **Step 4: Thread status through stage/footer**
In `PresentationRoute.tsx`, import and call hook:
```ts
import { usePresentationTargetStatus } from "./usePresentationTargetStatus.js";
const targetStatus = usePresentationTargetStatus(presentationTarget, demo.state);
```
Pass to `PresentationStage`.
In `PresentationStage.tsx`, add prop:
```ts
readonly targetStatus: PresentationTargetHealth;
```
Pass to `PresentationFooter`.
In `PresentationFooter.tsx`, add prop:
```ts
readonly targetStatus: PresentationTargetHealth;
```
Render:
```tsx
<PresentationTruthBadge status={targetStatus} />
```
Place it between `SceneProgress` and `EvidenceReceipt`.
- [ ] **Step 5: Add compact CSS**
In `presentation.css`, add:
```css
.presentation-truth-badge {
display: inline-flex;
align-items: baseline;
gap: 0.45rem;
min-width: 0;
padding: 0.3rem 0.55rem;
border: 1px solid var(--stage-line);
border-radius: 999px;
background: color-mix(in oklch, var(--stage-surface) 88%, transparent);
color: var(--text-primary);
font-family: var(--font-mono);
font-size: 0.68rem;
}
.presentation-truth-badge span {
color: var(--text-muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.presentation-truth-badge[data-status="failed"] {
border-color: color-mix(in oklch, var(--accent-amber) 70%, var(--stage-line));
}
.presentation-truth-badge[data-status="active"],
.presentation-truth-badge[data-status="ready"] {
border-color: color-mix(in oklch, var(--accent-cyan) 65%, var(--stage-line));
}
```
Adapt token names to the existing file if necessary.
- [ ] **Step 6: Run tests**
```bash
pnpm --dir web --filter @lda/console test -- src/presentation/PresentationTruthBadge.test.tsx src/presentation/PresentationRoute.test.tsx
```
Expected: PASS.
- [ ] **Step 7: Commit**
```bash
git add web/apps/console/src/presentation/PresentationTruthBadge.tsx web/apps/console/src/presentation/PresentationTruthBadge.test.tsx web/apps/console/src/presentation/PresentationFooter.tsx web/apps/console/src/presentation/PresentationStage.tsx web/apps/console/src/presentation/PresentationRoute.tsx web/apps/console/src/presentation/presentation.css
git commit -m "feat: show presentation live replay status"
```
---
### Task 4: Align chat copy and run labels with status
**Files:**
- Modify: `web/apps/console/src/demo/agent/timelineAgent.ts`
- Modify: `web/apps/console/src/demo/agent/timelineAgent.test.tsx`
- Modify: `web/apps/console/src/presentation/OperatorChat.tsx`
- Modify: `web/apps/console/src/presentation/OperatorChat.test.tsx`
**Interfaces:**
- Consumes:
- `PresentationTargetHealth`
- Produces:
- Chat run label and intro copy do not overclaim live availability.
- [ ] **Step 1: Add tests for status-driven labels**
In `timelineAgent.test.tsx`, add:
```tsx
it("uses replay label when live target failed", () => {
const demo = demoController();
const { result } = renderHook(() =>
useTimelineAgent(demo, {
mode: "replay",
status: { kind: "failed", target: "http://127.0.0.1:8765/rpc", label: "Replay fallback", detail: "connection refused" },
}),
);
expect(result.current.runLabel).toBe("Run replay walkthrough");
expect(result.current.messages[0]?.parts).toEqual(
expect.arrayContaining([
expect.objectContaining({ text: expect.stringMatching(/Replay fallback/i) }),
]),
);
});
```
This intentionally changes `useTimelineAgent` signature from `(demo, modeLabel)`
to `(demo, options)`.
- [ ] **Step 2: Run failing test**
```bash
pnpm --dir web --filter @lda/console test -- src/demo/agent/timelineAgent.test.tsx
```
Expected: FAIL because signature still uses `modeLabel`.
- [ ] **Step 3: Update timeline agent options**
In `timelineAgent.ts`, change:
```ts
export type TimelineAgentMode = "live" | "replay";
```
to:
```ts
import type { PresentationTargetHealth } from "../../presentation/presentation-target-status.js";
export type TimelineAgentMode = "live" | "replay";
export type TimelineAgentOptions = {
readonly mode: TimelineAgentMode;
readonly status: PresentationTargetHealth;
};
```
Change hook signature:
```ts
export const useTimelineAgent = (
demo: DemoTimelineController,
options: TimelineAgentOptions,
): TimelineAgentController => {
const modeLabel = options.status.kind === "ready" || options.status.kind === "active"
? options.mode
: "replay";
```
Intro message:
```ts
const introText = options.status.kind === "ready"
? "Live target is ready. Direct slides still show replay evidence until I start the live run."
: options.status.kind === "active"
? "Live run is active. Operations are being sent to the workflow server."
: options.status.kind === "failed"
? "Replay fallback is active because the live target is unavailable."
: "Replay evidence is active. I can walk through the reviewed recording.";
```
Run label:
```ts
const runLabel = modeLabel === "live" ? "Run prepared workflow" : "Run replay walkthrough";
```
Keep `demo.start(modeLabel)` unchanged.
- [ ] **Step 4: Update route caller**
In `PresentationRoute.tsx`, pass:
```ts
const timelineAgent = useTimelineAgent(demo, {
mode: presentationTarget.mode === "live" ? "live" : "replay",
status: targetStatus,
});
```
Ensure `targetStatus` is defined before `useTimelineAgent`.
- [ ] **Step 5: Run tests**
```bash
pnpm --dir web --filter @lda/console test -- src/demo/agent/timelineAgent.test.tsx src/presentation/OperatorChat.test.tsx src/presentation/PresentationRoute.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/presentation/PresentationRoute.tsx web/apps/console/src/presentation/OperatorChat.tsx web/apps/console/src/presentation/OperatorChat.test.tsx
git commit -m "fix: make chat honest about live replay status"
```
---
### Task 5: Docs, verification, and archive
**Files:**
- Modify: `docs/current_roadmap.md`
- Move: `docs/superpowers/plans/2026-07-09-presentation-live-replay-truth.md` to `docs/historical/superpowers/plans/2026-07-09-presentation-live-replay-truth.md`
- [ ] **Step 1: Run focused tests**
```bash
pnpm --dir web --filter @lda/console test -- src/presentation/presentation-target-status.test.ts src/presentation/usePresentationTargetStatus.test.tsx src/presentation/PresentationTruthBadge.test.tsx src/demo/agent/timelineAgent.test.tsx src/presentation/PresentationRoute.test.tsx
```
Expected: PASS.
- [ ] **Step 2: Run typecheck**
```bash
pnpm --dir web --filter @lda/console typecheck
```
Expected: PASS.
- [ ] **Step 3: Run build**
```bash
pnpm --dir web --filter @lda/console build
```
Expected: PASS. Existing chunk-size warning is acceptable.
- [ ] **Step 4: Browser smoke**
With the example server running:
```bash
uv run wf-rpc-server --config examples/lda_report_workflow/wf.config.json --host 127.0.0.1 --port 8765
```
Open:
```text
http://127.0.0.1:5173/present#scene/interrupt-evidence/approval
```
Expected:
- Badge says `Live target ready`.
- Slide still shows replay approval state.
- Chat says direct slides are replay until live run starts.
Then set session storage target to an invalid URL or use existing replay helper
in tests. Expected:
- Badge says `Replay fallback`.
- Chat run label says `Run replay walkthrough`.
- [ ] **Step 5: Update roadmap**
In `docs/current_roadmap.md`, add completed item:
```md
20. Completed: presentation live/replay truth surface distinguishes reviewed
replay evidence, live target readiness, live active run state, and replay
fallback. Design:
[`presentation live/replay truth`](superpowers/specs/2026-07-09-presentation-live-replay-truth-design.md).
Implementation:
[`presentation live/replay truth plan`](historical/superpowers/plans/2026-07-09-presentation-live-replay-truth.md).
```
Renumber following future items.
- [ ] **Step 6: Archive plan**
```bash
git mv docs/superpowers/plans/2026-07-09-presentation-live-replay-truth.md docs/historical/superpowers/plans/2026-07-09-presentation-live-replay-truth.md
```
- [ ] **Step 7: Diff hygiene**
```bash
git diff --check
git status --short
```
Expected: no whitespace errors; only intended files listed.
- [ ] **Step 8: Commit**
```bash
git add docs/current_roadmap.md docs/historical/superpowers/plans/2026-07-09-presentation-live-replay-truth.md
git commit -m "docs: complete presentation live replay truth"
```
---
## Self-Review
- Spec coverage: health check, badge, chat copy, replay fallback, live active
state, tests, and smoke are covered.
- Placeholder scan: No TODO/TBD placeholders remain.
- Type consistency: `PresentationTargetHealth`, `TargetProbeState`,
`presentationTargetHealth`, and `usePresentationTargetStatus` signatures are
consistent.
- Scope check: This avoids AI Elements/chat replacement and stays focused on
truthfulness.
@@ -0,0 +1,138 @@
# Presentation Live/Replay Truth Design
## Purpose
The defense presentation can now show a prepared workflow as replay evidence and
can also run against a local `wf-rpc-server`. The UI must make that distinction
obvious. A viewer should never have to infer whether the screen is showing
recorded evidence, a configured live target, or an actually reachable live
server.
## Problem
The presentation currently resolves `http://127.0.0.1:8765/rpc` as a live target
by default, even before a health check. At the same time, direct scene hashes are
replay-backed so the slides are immediately ready. This is technically useful
but semantically blurry:
- The chat says a live workflow target is configured.
- The screen often shows replay evidence.
- The run button can say "Run prepared workflow" even when the visible evidence
is replayed.
- When the example server is running, the fake/replay path can look real enough
to be misleading.
## Design Goal
Add a small truth layer that distinguishes:
1. **Replay evidence**: the slide is showing the reviewed recording.
2. **Live target configured**: a URL exists, but reachability has not been
proven in this presentation session.
3. **Live target reachable**: `workflow.health` succeeded.
4. **Live run active**: the operator clicked the run action and the timeline is
executing live operations.
5. **Live unavailable**: health check failed or target is invalid; replay is the
fallback.
This is not a large connection redesign. It is a presentation honesty layer.
## Scope
In scope:
- Health probing the presentation target.
- A visible presentation status badge or strip.
- Clear chat copy for replay vs configured vs reachable vs live active.
- Route tests and component tests for status rendering.
- Browser smoke for reachable local example server and replay fallback.
Out of scope:
- New connection UI.
- Replacing chat with AI Elements.
- Changing `/console` connection behavior.
- Adding remote/VPS presenter companion behavior.
- Full live/replay recording reconciliation.
## UX Rules
- Direct scene hashes remain replay-backed until the operator intentionally
starts a live run.
- A reachable live server should be shown as "Live target ready", not "Live run
active".
- "Live run active" only appears after `Run prepared workflow` starts a live
timeline.
- If health fails, the run button should either use replay wording or explain
why live is unavailable.
- Replay evidence should be labelled calmly, not apologetically.
Recommended copy:
- Replay: `Replay evidence · reviewed recording`
- Configured: `Live target configured · checking`
- Reachable: `Live target ready · 127.0.0.1:8765`
- Active: `Live run active · operations sent to wf-rpc-server`
- Failed: `Replay fallback · live target unreachable`
## Architecture
Add a small presentation target status hook:
```ts
export type PresentationTargetHealth =
| { readonly kind: "replay"; readonly label: string; readonly detail: string }
| { readonly kind: "checking"; readonly target: string; readonly label: string; readonly detail: string }
| { readonly kind: "ready"; readonly target: string; readonly label: string; readonly detail: string }
| { readonly kind: "active"; readonly target: string; readonly label: string; readonly detail: string }
| { readonly kind: "failed"; readonly target: string | null; readonly label: string; readonly detail: string };
```
The hook should not own timeline state. It consumes target resolution and demo
state, then exposes display status and whether live run actions should be
enabled.
Health check should use the existing RPC client/operation layer if one is
already available in the console package. Do not hand-roll a second JSON-RPC
transport unless the existing operation layer cannot call `workflow.health`.
## UI Placement
Add the status to the footer near scene progress/evidence receipt. It should be
visible but quiet: a compact pill or strip. The audience should see it without
it becoming a new hero element.
The chat intro should use the same status language. It should not claim "server
is available" before health succeeds.
## Testing Requirements
Model/hook tests:
- Invalid target produces replay/failed fallback.
- Default target starts as checking, then ready when health succeeds.
- Health failure produces failed fallback.
- Demo state `live` + running/review/completed after live start produces active.
Presentation tests:
- Direct approval hash shows replay evidence status and ready live target status
separately if health succeeds.
- Chat run label uses live wording only when health is ready.
- If health fails, chat run label uses replay wording.
Smoke:
- With `wf-rpc-server --config examples/lda_report_workflow/wf.config.json
--host 127.0.0.1 --port 8765`, `/present#scene/interrupt-evidence/approval`
shows the live target as ready while the slide remains replay-backed.
- With an invalid target in session storage, the same route shows replay
fallback.
## Success Criteria
- No screen implies a live run is active before the operator starts it.
- The audience can distinguish replay evidence from live server reachability.
- Live target success makes the demo feel credible without hiding replay
fallback.
- Existing direct-hash readiness remains intact.