diff --git a/src/App.tsx b/src/App.tsx
index ad54d26..9ae2b60 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -734,6 +734,9 @@ function AppInner() {
}
if (slashResult.messages.length > 0) {
setMessages((prev) => [...prev, ...slashResult.messages]);
+ for (const msg of slashResult.messages) {
+ send(msg);
+ }
}
return;
}
@@ -854,6 +857,16 @@ function AppInner() {
[diceRequest, send],
);
+ // Yield action — player steps out of an active confrontation on their terms.
+ // Sends a YIELD message to the server; server refreshes edge by 1 + statuses taken.
+ const handleYield = useCallback(() => {
+ send({
+ type: MessageType.YIELD,
+ payload: {},
+ player_id: "",
+ });
+ }, [send]);
+
const navigate = useNavigate();
// Bug 6: Leave game — disconnect, clear state, return to lobby.
@@ -1245,6 +1258,7 @@ function AppInner() {
resourceAlerts={gameState.resourceAlerts}
confrontationData={confrontationData}
onBeatSelect={handleBeatSelect}
+ onYield={handleYield}
diceRequest={diceRequest}
diceResult={diceResult}
onDiceThrow={handleDiceThrow}
diff --git a/src/__tests__/EncounterTab.test.tsx b/src/__tests__/EncounterTab.test.tsx
new file mode 100644
index 0000000..3ffa635
--- /dev/null
+++ b/src/__tests__/EncounterTab.test.tsx
@@ -0,0 +1,83 @@
+import { render, screen } from "@testing-library/react";
+import { describe, expect, it } from "vitest";
+
+import { EncounterTab, EncounterTimeline } from "../components/Dashboard/tabs/EncounterTab";
+import type { EncounterEvent } from "../types/payloads";
+
+const sample: EncounterEvent[] = [
+ {
+ seq: 1,
+ kind: "ENCOUNTER_STARTED",
+ payload: {
+ encounter_type: "combat",
+ player_metric_threshold: 10,
+ opponent_metric_threshold: 10,
+ turn: 1,
+ },
+ created_at: "2026-04-25T00:00:00Z",
+ },
+ {
+ seq: 2,
+ kind: "ENCOUNTER_BEAT_APPLIED",
+ payload: {
+ actor: "Sam",
+ actor_side: "player",
+ beat_id: "attack",
+ beat_kind: "strike",
+ outcome_tier: "Success",
+ own_delta: 2,
+ opponent_delta: 0,
+ turn: 1,
+ },
+ created_at: "2026-04-25T00:00:01Z",
+ },
+ {
+ seq: 3,
+ kind: "ENCOUNTER_METRIC_ADVANCE",
+ payload: { side: "player", delta_kind: "own", delta: 2, before: 0, after: 2, turn: 1 },
+ created_at: "2026-04-25T00:00:02Z",
+ },
+ {
+ seq: 4,
+ kind: "ENCOUNTER_RESOLVED",
+ payload: {
+ outcome: "opponent_victory",
+ final_player_metric: 4,
+ final_opponent_metric: 11,
+ triggering_side: "opponent",
+ turn: 5,
+ },
+ created_at: "2026-04-25T00:00:10Z",
+ },
+];
+
+describe("EncounterTimeline", () => {
+ it("renders rows for each event kind with side and tier", () => {
+ render();
+ expect(screen.getByText(/Sam/)).toBeInTheDocument();
+ expect(screen.getByText(/strike/)).toBeInTheDocument();
+ expect(screen.getByText(/Success/)).toBeInTheDocument();
+ expect(screen.getByText(/opponent_victory/)).toBeInTheDocument();
+ });
+
+ it("renders dial-pair view from STARTED through RESOLVED", () => {
+ render();
+ expect(screen.getByText(/Player metric:.*0/)).toBeInTheDocument();
+ expect(screen.getByText(/Opponent metric:.*0/)).toBeInTheDocument();
+ });
+});
+
+// Wiring test — confirms EncounterTab is exported and importable (not just
+// the pure EncounterTimeline renderer). Any test that passes proves the
+// module can be imported and both named exports are accessible.
+describe("EncounterTab wiring", () => {
+ it("EncounterTab is exported from the module", () => {
+ expect(EncounterTab).toBeDefined();
+ expect(typeof EncounterTab).toBe("function");
+ });
+
+ it("renders no-session placeholder when slug is null", () => {
+ render();
+ expect(screen.getByText(/No active session/)).toBeInTheDocument();
+ });
+});
diff --git a/src/__tests__/YieldButton.test.tsx b/src/__tests__/YieldButton.test.tsx
new file mode 100644
index 0000000..0921ada
--- /dev/null
+++ b/src/__tests__/YieldButton.test.tsx
@@ -0,0 +1,20 @@
+import { fireEvent, render, screen } from "@testing-library/react";
+import { describe, expect, it, vi } from "vitest";
+
+import { YieldButton } from "../components/YieldButton";
+
+describe("YieldButton", () => {
+ it("calls onYield when clicked", () => {
+ const onYield = vi.fn();
+ render();
+ fireEvent.click(screen.getByRole("button", { name: /yield/i }));
+ expect(onYield).toHaveBeenCalledTimes(1);
+ });
+
+ it("disables when no active encounter", () => {
+ const onYield = vi.fn();
+ render();
+ fireEvent.click(screen.getByRole("button", { name: /yield/i }));
+ expect(onYield).not.toHaveBeenCalled();
+ });
+});
diff --git a/src/__tests__/slash-command-send-wiring.test.ts b/src/__tests__/slash-command-send-wiring.test.ts
new file mode 100644
index 0000000..6e60c3b
--- /dev/null
+++ b/src/__tests__/slash-command-send-wiring.test.ts
@@ -0,0 +1,57 @@
+import { describe, it, expect } from "vitest";
+import * as fs from "node:fs";
+import * as path from "node:path";
+
+// ── Wiring test: slash-command messages reach the server send pipeline ────────
+//
+// The bug (Task 26 code review): handleSend in App.tsx intercepted slash
+// commands, appended their messages to local state, but never called send().
+// The result: /yield appended a YIELD entry to the narrative log client-side
+// but transmitted nothing to the server — _handle_yield never fired.
+//
+// This test reads App.tsx source to assert that the fix is present and wired:
+// send(msg) must appear inside the slashResult.messages.length > 0 block,
+// not just setMessages.
+// ─────────────────────────────────────────────────────────────────────────────
+
+const appSrc = fs.readFileSync(
+ path.resolve(__dirname, "../App.tsx"),
+ "utf-8",
+);
+
+describe("Wiring: slash-command messages forwarded to server via send()", () => {
+ it("App.tsx calls send(msg) for each slash-command message", () => {
+ // The fix block must contain both setMessages and send(msg) inside the
+ // slashResult.messages.length > 0 branch. We verify by asserting the
+ // for-loop pattern is present immediately after setMessages.
+ expect(appSrc).toMatch(
+ /slashResult\.messages\.length > 0[\s\S]*?send\(msg\)/,
+ );
+ });
+
+ it("App.tsx iterates slashResult.messages and calls send for each", () => {
+ // The for-loop construct must be present: `for (const msg of slashResult.messages)`
+ expect(appSrc).toMatch(/for\s*\(\s*const\s+msg\s+of\s+slashResult\.messages\s*\)/);
+ });
+
+ it("send(msg) appears in the slashResult.handled branch (not only the PLAYER_ACTION path)", () => {
+ // Capture everything from `if (slashResult.handled)` to its closing `return;`
+ // and assert send(msg) appears inside it.
+ const handledBranch = appSrc.match(
+ /if\s*\(slashResult\.handled\)\s*\{([\s\S]*?)\n\s+return;/,
+ );
+ expect(handledBranch).not.toBeNull();
+ expect(handledBranch?.[1]).toMatch(/send\(msg\)/);
+ });
+
+ it("the slash-command send path does NOT call setCanType or setThinking", () => {
+ // Per spec: slash commands must not seal the input bar or show a thinking
+ // indicator — that lifecycle only applies to PLAYER_ACTION narration turns.
+ const handledBranch = appSrc.match(
+ /if\s*\(slashResult\.handled\)\s*\{([\s\S]*?)\n\s+return;/,
+ );
+ expect(handledBranch).not.toBeNull();
+ expect(handledBranch?.[1]).not.toMatch(/setCanType/);
+ expect(handledBranch?.[1]).not.toMatch(/setThinking/);
+ });
+});
diff --git a/src/components/ConfrontationOverlay.tsx b/src/components/ConfrontationOverlay.tsx
index ce6dafb..dd72a21 100644
--- a/src/components/ConfrontationOverlay.tsx
+++ b/src/components/ConfrontationOverlay.tsx
@@ -1,5 +1,6 @@
import type { DiceRequestPayload, DiceResultPayload, DiceThrowParams } from "@/types/payloads";
import { InlineDiceTray } from "@/dice/InlineDiceTray";
+import { YieldButton } from "@/components/YieldButton";
// ═══════════════════════════════════════════════════════════
// Types — exported for tests and consumers
@@ -67,6 +68,7 @@ interface ConfrontationOverlayProps {
diceResult?: DiceResultPayload | null;
playerId?: string;
onDiceThrow?: (params: DiceThrowParams, face: number[]) => void;
+ onYield?: () => void;
}
// ═══════════════════════════════════════════════════════════
@@ -222,7 +224,7 @@ function SecondaryStatsPanel({ stats }: { stats: SecondaryStats }) {
// Main component
// ═══════════════════════════════════════════════════════════
-export function ConfrontationOverlay({ data, onBeatSelect, inline, diceRequest, diceResult, playerId, onDiceThrow }: ConfrontationOverlayProps) {
+export function ConfrontationOverlay({ data, onBeatSelect, inline, diceRequest, diceResult, playerId, onDiceThrow, onYield }: ConfrontationOverlayProps) {
if (!data) return null;
const isStandoff = data.type === 'standoff';
@@ -262,6 +264,13 @@ export function ConfrontationOverlay({ data, onBeatSelect, inline, diceRequest,
{/* Beat action buttons */}
+ {/* Yield button — only rendered when parent supplies the handler */}
+ {onYield !== undefined && (
+
+
+
+ )}
+
{/* Inline dice tray — rolls right here when a beat is selected */}
{onDiceThrow && playerId && (
{
+ if (!state.debugState || state.debugState.length === 0) return null;
+ const sorted = [...state.debugState].sort((a, b) => {
+ const aTs = a.last_activity_ts ?? 0;
+ const bTs = b.last_activity_ts ?? 0;
+ return bTs - aTs;
+ });
+ return sorted[0].session_key;
+ })();
+
const errorCount = state.allEvents.filter(
(e) => e.severity === "error",
).length;
@@ -378,6 +391,9 @@ export function DashboardApp() {
{state.activeTab === 6 && (
)}
+ {state.activeTab === 7 && (
+
+ )}
);
diff --git a/src/components/Dashboard/DashboardTabs.tsx b/src/components/Dashboard/DashboardTabs.tsx
index 5d83eec..1788d76 100644
--- a/src/components/Dashboard/DashboardTabs.tsx
+++ b/src/components/Dashboard/DashboardTabs.tsx
@@ -8,6 +8,7 @@ const TAB_LABELS = [
"⑤ Console",
"⑥ Prompt",
"⑦ Lore",
+ "⑧ Encounters",
];
interface Props {
diff --git a/src/components/Dashboard/tabs/EncounterTab.tsx b/src/components/Dashboard/tabs/EncounterTab.tsx
new file mode 100644
index 0000000..02cd883
--- /dev/null
+++ b/src/components/Dashboard/tabs/EncounterTab.tsx
@@ -0,0 +1,262 @@
+import { useEffect, useState } from "react";
+import type { EncounterEvent } from "@/types/payloads";
+import { THEME } from "../shared/constants";
+
+// ---------------------------------------------------------------------------
+// Pure row renderers — one per ENCOUNTER_* event kind
+// ---------------------------------------------------------------------------
+
+function startedRow(payload: Record) {
+ const playerThresh = (payload.player_metric_threshold as number) ?? "?";
+ const oppThresh = (payload.opponent_metric_threshold as number) ?? "?";
+ return (
+
+ Encounter started — Player metric: 0 / {playerThresh},
+ {" "}Opponent metric: 0 / {oppThresh}
+
+ );
+}
+
+function beatRow(payload: Record) {
+ return (
+
+ {payload.actor as string}
+ {" (side="}{payload.actor_side as string}{") "}
+ played {payload.beat_id as string}
+ {" "}({payload.beat_kind as string}, tier {payload.outcome_tier as string});
+ {" "}deltas own={payload.own_delta as number} opp={payload.opponent_delta as number}
+
+ );
+}
+
+function advanceRow(payload: Record) {
+ const delta = payload.delta as number;
+ return (
+
+ {payload.side as string} dial advanced {delta > 0 ? "+" : ""}
+ {delta} ({payload.before as number} → {payload.after as number})
+
+ );
+}
+
+function tagRow(payload: Record) {
+ return (
+
+ tag "{payload.tag_text as string}" on {(payload.target as string) || "(scene)"}
+ {" "}— leverage {payload.leverage as number}, {(payload.fleeting as boolean) ? "fleeting" : "persistent"}
+
+ );
+}
+
+function statusRow(payload: Record) {
+ return (
+
+ {payload.actor as string} took status {payload.text as string}
+ {" "}({payload.severity as string})
+
+ );
+}
+
+function yieldRow(payload: Record) {
+ const op = payload.op as string;
+ if (op === "yield_resolved") {
+ return (
+
+ Yield resolved — {payload.yielded_actors as string} (edge refreshed:
+ {" "}{payload.edge_refreshed as number})
+
+ );
+ }
+ return Yield received from {payload.actor_name as string};
+}
+
+function resolvedRow(payload: Record) {
+ return (
+
+ RESOLVED — outcome: {payload.outcome as string}; final player_metric=
+ {payload.final_player_metric as number}, opponent_metric=
+ {payload.final_opponent_metric as number}
+
+ );
+}
+
+function skippedRow(payload: Record) {
+ return (
+
+ Beat skipped — {payload.actor as string} ({payload.actor_side as string}) /
+ {" "}{payload.beat_id as string} — reason: {payload.reason as string}
+
+ );
+}
+
+function renderEventRow(ev: EncounterEvent): React.ReactNode {
+ switch (ev.kind) {
+ case "ENCOUNTER_STARTED":
+ return startedRow(ev.payload);
+ case "ENCOUNTER_BEAT_APPLIED":
+ return beatRow(ev.payload);
+ case "ENCOUNTER_METRIC_ADVANCE":
+ return advanceRow(ev.payload);
+ case "ENCOUNTER_TAG_CREATED":
+ return tagRow(ev.payload);
+ case "ENCOUNTER_STATUS_ADDED":
+ return statusRow(ev.payload);
+ case "ENCOUNTER_YIELD":
+ return yieldRow(ev.payload);
+ case "ENCOUNTER_BEAT_SKIPPED":
+ return skippedRow(ev.payload);
+ case "ENCOUNTER_RESOLVED":
+ return resolvedRow(ev.payload);
+ case "ENCOUNTER_RESOLUTION_SIGNAL":
+ return null; // internal — no visible row
+ default: {
+ // Exhaustiveness guard: surface unknown kinds rather than silently swallowing them.
+ const unknown = (ev as { kind: string }).kind;
+ return (unknown event kind: {unknown});
+ }
+ }
+}
+
+// ---------------------------------------------------------------------------
+// EncounterTimeline — pure renderer (used by tests and by EncounterTab)
+// ---------------------------------------------------------------------------
+
+interface TimelineProps {
+ events: EncounterEvent[];
+}
+
+export function EncounterTimeline({ events }: TimelineProps) {
+ if (events.length === 0) {
+ return (
+
+ No encounter events recorded for this session.
+
+ );
+ }
+
+ return (
+
+ {events.map((ev) => {
+ const rowContent = renderEventRow(ev);
+ if (rowContent === null) return null;
+ const isResolved = ev.kind === "ENCOUNTER_RESOLVED";
+ return (
+ -
+
+ #{ev.seq}
+
+
+ {ev.kind}
+
+ {rowContent}
+
+ );
+ })}
+
+ );
+}
+
+// ---------------------------------------------------------------------------
+// EncounterTab — live wrapper that fetches from REST + renders EncounterTimeline
+// ---------------------------------------------------------------------------
+
+interface TabProps {
+ slug: string | null;
+}
+
+export function EncounterTab({ slug }: TabProps) {
+ const [events, setEvents] = useState([]);
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ if (!slug) return;
+ let cancelled = false;
+
+ fetch(`/api/sessions/${slug}/encounter_events`)
+ .then((r) => {
+ if (!r.ok) throw new Error(`HTTP ${r.status}`);
+ return r.json();
+ })
+ .then((data: unknown) => {
+ if (!cancelled) {
+ if (!Array.isArray(data)) {
+ throw new Error(`Expected array, got ${typeof data}`);
+ }
+ setEvents(data as EncounterEvent[]);
+ }
+ })
+ .catch((e: unknown) => {
+ if (!cancelled) setError(String(e));
+ });
+
+ return () => {
+ cancelled = true;
+ };
+ }, [slug]);
+
+ if (!slug) {
+ return (
+
+ No active session.
+
+ );
+ }
+
+ if (error) {
+ return (
+
+ Error loading encounter events: {error}
+
+ );
+ }
+
+ return (
+
+
+ Encounter Timeline — {events.length} event{events.length !== 1 ? "s" : ""}
+
+
+
+ );
+}
diff --git a/src/components/GameBoard/GameBoard.tsx b/src/components/GameBoard/GameBoard.tsx
index c297c48..89696b2 100644
--- a/src/components/GameBoard/GameBoard.tsx
+++ b/src/components/GameBoard/GameBoard.tsx
@@ -130,6 +130,7 @@ export interface GameBoardProps {
knowledgeEntries?: KnowledgeEntry[];
confrontationData?: ConfrontationData | null;
onBeatSelect?: (beatId: string) => void;
+ onYield?: () => void;
diceRequest?: DiceRequestPayload | null;
diceResult?: DiceResultPayload | null;
onDiceThrow?: (params: DiceThrowParams, face: number[]) => void;
@@ -160,6 +161,7 @@ export function GameBoard({
knowledgeEntries,
confrontationData,
onBeatSelect,
+ onYield,
diceRequest,
diceResult,
onDiceThrow,
@@ -329,6 +331,7 @@ export function GameBoard({
void;
+ onYield?: () => void;
}
-export function ConfrontationWidget({ data, onBeatSelect, diceRequest, diceResult, playerId, onDiceThrow }: ConfrontationWidgetProps) {
+export function ConfrontationWidget({ data, onBeatSelect, diceRequest, diceResult, playerId, onDiceThrow, onYield }: ConfrontationWidgetProps) {
return (
);
}
diff --git a/src/components/YieldButton.tsx b/src/components/YieldButton.tsx
new file mode 100644
index 0000000..f325c1d
--- /dev/null
+++ b/src/components/YieldButton.tsx
@@ -0,0 +1,21 @@
+import { Button } from "@/components/ui/button";
+
+interface Props {
+ onYield: () => void;
+ disabled: boolean;
+}
+
+export function YieldButton({ onYield, disabled }: Props) {
+ return (
+
+ );
+}
diff --git a/src/hooks/__tests__/useSlashCommands.test.ts b/src/hooks/__tests__/useSlashCommands.test.ts
index 249f1a9..fb0ee71 100644
--- a/src/hooks/__tests__/useSlashCommands.test.ts
+++ b/src/hooks/__tests__/useSlashCommands.test.ts
@@ -119,4 +119,15 @@ describe("useSlashCommands", () => {
expect(out.handled).toBe(true);
});
+
+ it("/yield returns handled=true and a single YIELD-typed message", () => {
+ const { result } = renderSlashCommands();
+ const out = result.current.execute("/yield");
+
+ expect(out.handled).toBe(true);
+ expect(out.widget).toBeUndefined();
+ expect(out.messages).toHaveLength(1);
+ expect(out.messages[0].type).toBe("YIELD");
+ expect(out.messages[0].player_id).toBe("");
+ });
});
diff --git a/src/hooks/useSlashCommands.ts b/src/hooks/useSlashCommands.ts
index 804a3b1..d94e77f 100644
--- a/src/hooks/useSlashCommands.ts
+++ b/src/hooks/useSlashCommands.ts
@@ -1,5 +1,5 @@
import { useCallback } from 'react';
-import type { GameMessage } from '../types/protocol';
+import { MessageType, type GameMessage } from '../types/protocol';
import type { WidgetId } from '@/components/GameBoard/widgetRegistry';
/** @deprecated Use WidgetId instead */
@@ -35,6 +35,17 @@ export function useSlashCommands() {
return { handled: true, messages: [], widget: 'knowledge' };
case '/gallery':
return { handled: true, messages: [], widget: 'gallery' };
+ case '/yield':
+ return {
+ handled: true,
+ messages: [
+ {
+ type: MessageType.YIELD,
+ payload: {},
+ player_id: "",
+ } satisfies GameMessage,
+ ],
+ };
default:
// Unknown slash command — swallow it client-side. The backend cannot
// receive slash text as PLAYER_ACTION without erroring. If a command
diff --git a/src/types/payloads.ts b/src/types/payloads.ts
index 36c8081..66e9209 100644
--- a/src/types/payloads.ts
+++ b/src/types/payloads.ts
@@ -582,6 +582,29 @@ export function isScrapbookEntry(msg: TypedGameMessage): msg is ScrapbookEntryMe
return msg.type === MessageType.SCRAPBOOK_ENTRY;
}
+// ---------------------------------------------------------------------------
+// Encounter event types (GM panel — Task 22)
+// REST source: GET /api/sessions/{slug}/encounter_events
+// ---------------------------------------------------------------------------
+
+export type EncounterEventKind =
+ | "ENCOUNTER_STARTED"
+ | "ENCOUNTER_BEAT_APPLIED"
+ | "ENCOUNTER_METRIC_ADVANCE"
+ | "ENCOUNTER_BEAT_SKIPPED"
+ | "ENCOUNTER_TAG_CREATED"
+ | "ENCOUNTER_STATUS_ADDED"
+ | "ENCOUNTER_YIELD"
+ | "ENCOUNTER_RESOLVED"
+ | "ENCOUNTER_RESOLUTION_SIGNAL";
+
+export interface EncounterEvent {
+ seq: number;
+ kind: EncounterEventKind;
+ payload: Record;
+ created_at: string;
+}
+
// ---------------------------------------------------------------------------
// Validation helpers (moved from useStateMirror)
// ---------------------------------------------------------------------------
diff --git a/src/types/protocol.ts b/src/types/protocol.ts
index d9b47bd..319baf5 100644
--- a/src/types/protocol.ts
+++ b/src/types/protocol.ts
@@ -48,6 +48,7 @@ export const MessageType = {
SEAT_CONFIRMED: "SEAT_CONFIRMED",
GAME_PAUSED: "GAME_PAUSED",
GAME_RESUMED: "GAME_RESUMED",
+ YIELD: "YIELD",
} as const;
export type MessageType = (typeof MessageType)[keyof typeof MessageType];