diff --git a/web/src/components/transcript/ApprovalBar.tsx b/web/src/components/transcript/ApprovalBar.tsx
index 75e9577..aa449f5 100644
--- a/web/src/components/transcript/ApprovalBar.tsx
+++ b/web/src/components/transcript/ApprovalBar.tsx
@@ -38,9 +38,25 @@ import { newRequestId, request } from "../../state/connection";
import { epochOf, focusedSessionMessages } from "../../state/messages";
import { focusedSession, focusedSessionId } from "../../state/sessions";
import { findPendingApproval } from "../../lib/approvals";
+import { classifyFleetInput } from "../../lib/fleet-cards";
+import {
+ dispatchPreview,
+ hasUnresolved,
+ type DispatchPreview as DispatchPreviewModel,
+} from "../../lib/dispatch-preview";
+import { sessionList } from "../../state/sessions";
import type { CollaborationCost, SessionMessage } from "../../protocol/types";
import { formatCollaborationCost } from "../../lib/format";
+/**
+ * Repo/branch/content preview for a send-class fleet dispatch, or null for any
+ * other tool. Non-fleet approvals are untouched.
+ */
+function dispatchFor(toolName: string, input: unknown): DispatchPreviewModel | null {
+ const card = classifyFleetInput(toolName, input);
+ return card ? dispatchPreview(card, sessionList()) : null;
+}
+
/** Custom event the prompt listens for so "Refine" can focus + hint. */
function focusPromptWithHint(hint: string): void {
window.dispatchEvent(
@@ -207,6 +223,7 @@ const ApprovalBar: Component = () => {
toolName={snap().toolName}
description={snap().description}
collaborationCost={snap().collaborationCost}
+ dispatch={dispatchFor(snap().toolName, snap().input)}
isPlanMode={isPlanMode()}
busy={busy()}
onApprove={() => safeApprove(true)}
@@ -235,11 +252,67 @@ const ApprovalBar: Component = () => {
);
};
+/**
+ * Where a dispatch is actually going — the R3 preview.
+ *
+ * Deliberately compact: this sits above the prompt on every dispatch, and a
+ * block that pushes the buttons off screen gets dismissed rather than read. The
+ * content is clamped for the same reason; the full brief is in the tool card in
+ * the transcript above.
+ */
+const DispatchDetails: Component<{ preview: DispatchPreviewModel }> = (props) => (
+
{(c) => (
diff --git a/web/src/lib/dispatch-preview.test.ts b/web/src/lib/dispatch-preview.test.ts
new file mode 100644
index 0000000..2d94d70
--- /dev/null
+++ b/web/src/lib/dispatch-preview.test.ts
@@ -0,0 +1,125 @@
+import { describe, it, expect } from "vitest";
+
+import { classifyFleetInput } from "./fleet-cards";
+import { dispatchPreview, hasUnresolved, resolveTarget } from "./dispatch-preview";
+import type { SessionInfo } from "../protocol/types";
+
+const session = (id: string, name: string, over: Partial = {}): SessionInfo =>
+ ({ id, name, workdir: `/repo/${name}`, status: "idle", ...over }) as SessionInfo;
+
+const API = session("aaaa1111-2222", "api", {
+ worktree: { branch: "codeoid/fix-login", path: "/repo/api" },
+} as Partial);
+const WEB = session("bbbb3333-4444", "web");
+const SESSIONS = [API, WEB];
+
+const card = (verb: string, input: unknown) => classifyFleetInput(`mcp__codeoid_fleet__${verb}`, input)!;
+
+describe("resolveTarget", () => {
+ it("prefers an exact name — how the conductor is told to address targets", () => {
+ expect(resolveTarget("api", SESSIONS)?.id).toBe(API.id);
+ });
+
+ it("accepts an id and an unambiguous id prefix", () => {
+ expect(resolveTarget(API.id, SESSIONS)?.name).toBe("api");
+ expect(resolveTarget("aaaa1111", SESSIONS)?.name).toBe("api");
+ });
+
+ it("resolves an AMBIGUOUS prefix to nothing rather than guessing", () => {
+ // Picking one would be a coin flip presented as a fact, on the prompt
+ // whose whole job is to prevent misrouting.
+ const twins = [session("dup1", "a"), session("dup2", "b")];
+ expect(resolveTarget("dup", twins)).toBeNull();
+ });
+
+ it("prefers an exact name over an id prefix that also matches", () => {
+ const odd = [session("zzz", "shared"), session("shared-id", "other")];
+ expect(resolveTarget("shared", odd)?.id).toBe("zzz");
+ });
+
+ it("returns null for a name nothing matches", () => {
+ expect(resolveTarget("ghost", SESSIONS)).toBeNull();
+ });
+});
+
+describe("dispatchPreview — send-class", () => {
+ it("shows repo and branch for a send, per R3", () => {
+ const p = dispatchPreview(
+ card("fleet_send", { session: "api", message: "run the linter" }),
+ SESSIONS,
+ )!;
+ expect(p.kind).toBe("send");
+ expect(p.content).toBe("run the linter");
+ expect(p.targets).toHaveLength(1);
+ expect(p.targets[0]).toMatchObject({
+ name: "api",
+ workdir: "/repo/api",
+ branch: "codeoid/fix-login",
+ unresolved: false,
+ });
+ expect(hasUnresolved(p)).toBe(false);
+ });
+
+ it("reports a branchless session without inventing one", () => {
+ const p = dispatchPreview(card("fleet_send", { session: "web", message: "hi" }), SESSIONS)!;
+ expect(p.targets[0]!.workdir).toBe("/repo/web");
+ expect(p.targets[0]!.branch).toBeNull();
+ });
+
+ it("FLAGS a target that resolves to nothing", () => {
+ // The case where an owner is most at risk of approving the wrong thing. A
+ // prompt that quietly shows no repo reads as "no repo involved" rather
+ // than "I could not tell you".
+ const p = dispatchPreview(card("fleet_send", { session: "ghost", message: "x" }), SESSIONS)!;
+ expect(p.targets[0]!.unresolved).toBe(true);
+ expect(hasUnresolved(p)).toBe(true);
+ });
+
+ it("expands every member of a panel", () => {
+ const p = dispatchPreview(
+ card("fleet_panel", { sessions: ["api", "web", "ghost"], message: "review" }),
+ SESSIONS,
+ )!;
+ expect(p.targets.map((t) => t.name)).toEqual(["api", "web", "ghost"]);
+ expect(p.targets.map((t) => t.unresolved)).toEqual([false, false, true]);
+ expect(p.content).toBe("review");
+ });
+
+ it("handles an interrupt, which carries a target but no content", () => {
+ const p = dispatchPreview(card("fleet_interrupt", { session: "api" }), SESSIONS)!;
+ expect(p.targets[0]!.workdir).toBe("/repo/api");
+ expect(p.content).toBeNull();
+ });
+
+ it("survives a dispatch with no target at all", () => {
+ const p = dispatchPreview(card("fleet_send", { message: "orphan" }), SESSIONS)!;
+ expect(p.targets).toEqual([]);
+ expect(hasUnresolved(p)).toBe(false);
+ });
+});
+
+describe("dispatchPreview — spawn", () => {
+ it("shows the workdir being created in, and does not cry wolf about resolution", () => {
+ const p = dispatchPreview(
+ card("fleet_spawn", { workdir: "/repo/new", shape: "scout", task: "investigate" }),
+ SESSIONS,
+ )!;
+ expect(p.kind).toBe("spawn");
+ expect(p.targets[0]).toMatchObject({ name: "scout", workdir: "/repo/new", unresolved: false });
+ expect(p.content).toBe("investigate");
+ // There is no existing session to resolve, so flagging it would fire on
+ // every spawn.
+ expect(hasUnresolved(p)).toBe(false);
+ });
+});
+
+describe("dispatchPreview — non-dispatch", () => {
+ it("returns null for a read verb, which has nothing to propose", () => {
+ expect(dispatchPreview(card("fleet_find", { query: "x" }), SESSIONS)).toBeNull();
+ expect(dispatchPreview(card("machine_map", {}), SESSIONS)).toBeNull();
+ });
+
+ it("returns null for an unrecognised verb rather than treating it as a send", () => {
+ expect(dispatchPreview(card("fleet_detonate", {}), SESSIONS)).toBeNull();
+ });
+});
diff --git a/web/src/lib/dispatch-preview.ts b/web/src/lib/dispatch-preview.ts
new file mode 100644
index 0000000..24d8889
--- /dev/null
+++ b/web/src/lib/dispatch-preview.ts
@@ -0,0 +1,137 @@
+/**
+ * What a fleet dispatch is about to touch — repo, branch, target session.
+ *
+ * conductor-design R3 is the reason this exists: a send-class action to an
+ * existing session "first proposes it with **repo + branch + content shown**,
+ * and acts only on confirm". Silent misrouting is named there as the one
+ * failure that would kill trust in the conductor, so the approval prompt has to
+ * show the owner where the instruction is actually going — not just the tool's
+ * own words for it.
+ *
+ * Pure: resolving a target name to a session is the decision worth testing.
+ */
+
+import type { FleetCard } from "./fleet-cards";
+import type { SessionInfo } from "../protocol/types";
+
+export interface DispatchTarget {
+ /** The name the conductor used, exactly as it will be dispatched. */
+ name: string;
+ /** The session it resolves to, or null when nothing matches. */
+ session: SessionInfo | null;
+ /** Absolute workdir of the resolved session. */
+ workdir: string | null;
+ /** Worktree branch, when the session has one. */
+ branch: string | null;
+ /**
+ * True when the name matched nothing this client can see.
+ *
+ * Surfaced rather than hidden: an unresolvable target is EXACTLY the case
+ * where an owner is at risk of approving the wrong thing, and a prompt that
+ * quietly shows no repo reads as "no repo involved" rather than "I could not
+ * tell you". The daemon resolves names itself and may still succeed — the
+ * client's view can legitimately be stale — so this is a warning, never a
+ * reason to block the approval.
+ */
+ unresolved: boolean;
+}
+
+export interface DispatchPreview {
+ /** `spawn` creates a new worker; `send` routes into sessions that already exist. */
+ kind: "spawn" | "send";
+ targets: DispatchTarget[];
+ /** The instruction or brief being delivered, when the card carries one. */
+ content: string | null;
+}
+
+/**
+ * Resolve a session reference the way a human reading the prompt would.
+ *
+ * The conductor is told to name targets by NAME so the owner can verify the
+ * repo at a glance, but its own tools also accept an id or id prefix — so all
+ * three are matched here. An exact name wins over a prefix: two sessions can
+ * share a prefix, and silently preferring the wrong one is the misrouting R3
+ * exists to prevent.
+ */
+export function resolveTarget(
+ ref: string,
+ sessions: readonly SessionInfo[],
+): SessionInfo | null {
+ const exactName = sessions.find((s) => s.name === ref);
+ if (exactName) return exactName;
+ const exactId = sessions.find((s) => s.id === ref);
+ if (exactId) return exactId;
+
+ const byPrefix = sessions.filter((s) => s.id.startsWith(ref));
+ // An ambiguous prefix resolves to NOTHING. Picking one would be a coin flip
+ // presented as a fact, on the prompt whose job is to prevent exactly that.
+ return byPrefix.length === 1 ? byPrefix[0]! : null;
+}
+
+function describe(name: string, sessions: readonly SessionInfo[]): DispatchTarget {
+ const session = resolveTarget(name, sessions);
+ return {
+ name,
+ session,
+ workdir: session?.workdir ?? null,
+ branch: session?.worktree?.branch ?? null,
+ unresolved: session === null,
+ };
+}
+
+/** Read a field off a card by label, or null. */
+function field(card: FleetCard, label: string): string | null {
+ return card.fields.find((f) => f.label === label)?.value ?? null;
+}
+
+/**
+ * Build the preview for a send-class dispatch, or null when the card is not
+ * one (a read verb has nothing to propose — it already ran).
+ */
+export function dispatchPreview(
+ card: FleetCard,
+ sessions: readonly SessionInfo[],
+): DispatchPreview | null {
+ if (!card.sendClass) return null;
+
+ if (card.verb === "fleet_spawn") {
+ // A spawn has no existing session to verify — the workdir IS the thing to
+ // check, and it comes straight off the card.
+ const workdir = field(card, "workdir");
+ return {
+ kind: "spawn",
+ targets: [
+ {
+ name: field(card, "shape") ?? "worker",
+ session: null,
+ workdir,
+ branch: null,
+ // Not "unresolved": there is nothing to resolve yet, and flagging it
+ // would cry wolf on every spawn.
+ unresolved: false,
+ },
+ ],
+ content: field(card, "task"),
+ };
+ }
+
+ // send / interrupt / panel all route into sessions that already exist.
+ const raw = field(card, "sessions") ?? field(card, "target");
+ const names = raw
+ ? raw
+ .split(",")
+ .map((n) => n.trim())
+ .filter((n) => n.length > 0)
+ : [];
+
+ return {
+ kind: "send",
+ targets: names.map((n) => describe(n, sessions)),
+ content: field(card, "message"),
+ };
+}
+
+/** True when any target could not be resolved — drives the prompt's warning. */
+export function hasUnresolved(preview: DispatchPreview): boolean {
+ return preview.targets.some((t) => t.unresolved);
+}
diff --git a/web/src/lib/fleet-cards.ts b/web/src/lib/fleet-cards.ts
index 499d7b2..a43fee1 100644
--- a/web/src/lib/fleet-cards.ts
+++ b/web/src/lib/fleet-cards.ts
@@ -106,11 +106,23 @@ export function fleetVerb(toolName: string): string | null {
* ordinary tool that should keep its existing rendering.
*/
export function classifyFleetTool(tool: ToolInfo): FleetCard | null {
- const verb = fleetVerb(tool.name);
+ return classifyFleetInput(tool.name, resolveToolInput(tool));
+}
+
+/**
+ * Same classification from a raw `(toolName, input)` pair.
+ *
+ * The approval gate holds those two directly rather than a `ToolInfo`, and it
+ * is the most important consumer of this module — it renders what the owner is
+ * about to authorize. Splitting the entry point keeps both callers on ONE
+ * classifier rather than letting the approval surface grow its own parser that
+ * could disagree with the transcript about what a dispatch says.
+ */
+export function classifyFleetInput(toolName: string, rawInput: unknown): FleetCard | null {
+ const verb = fleetVerb(toolName);
if (verb === null) return null;
- const resolved = resolveToolInput(tool);
- const input = isRecord(resolved) ? resolved : {};
+ const input = isRecord(rawInput) ? rawInput : {};
const sendClass = SEND_SET.has(verb);
const known = sendClass || READ_SET.has(verb);