Files
oh-my-pi/packages/collab-web/src/tool-render/tools/resolve.tsx
T
roboomp 839ba1ce02 fix(collab-web): restore resolve/reject/propose cards after xdev unwrap
After the resolve tool became xd:// devices, ToolView.executeXdevDispatch
unwraps xd://resolve/reject/propose writes so the card model lives on the
unwrapped inner details and the device name, but tools/resolve.tsx still
read action/reason from args, rendering a "?"/warn badge. The renderer now
derives the card kind (apply/discard/propose) from inner details first,
falling back to args and to the device-name default for running cards, and
surfaces propose plan metadata. registry.ts also registers reject/propose
and the historical hub-family aliases (irc, job, await, poll, cancel_job)
so those transcript names stop falling back to generic JSON.

Fixes #5640
2026-07-23 19:23:27 +00:00

132 lines
4.3 KiB
TypeScript

/**
* `resolve` / `reject` / `propose` — finalize a staged preview (apply/discard)
* or submit a plan for approval.
*
* In collab-web these arrive as `write xd://<device>` calls that `ToolView`
* unwraps: the card model then lives on the unwrapped inner details
* (`result.details`) and the device name (`props.name`). Historical top-level
* `resolve` transcripts instead carried `action`/`reason` on `args`, so read
* details first and fall back to args. When neither is present yet (a running
* card), the device name supplies the default action.
*/
import type { ReactNode } from "react";
import type { Tone } from "../parts";
import { Badge, Badges, Kv, KvGrid, Note, ResultText } from "../parts";
import type { ToolRenderer, ToolRenderProps } from "../types";
import { detailsRecord, isRecord, normalizeWs, str, truncate } from "../util";
type ResolveKind = "apply" | "discard" | "propose";
/** Short badge word (summary) and full transition badge (body) per card kind. */
const KIND_WORD: Record<ResolveKind, string> = { apply: "apply", discard: "discard", propose: "propose" };
const KIND_TRANSITION: Record<ResolveKind, string> = {
apply: "proposed → resolved",
discard: "proposed → rejected",
propose: "plan proposed",
};
const KIND_TONE: Record<ResolveKind, Tone> = { apply: "ok", discard: "warn", propose: "accent" };
interface ResolveCard {
kind: ResolveKind;
tone: Tone;
/** Apply/discard reason. */
reason: string | null;
/** Source tool that staged the preview (apply/discard). */
sourceToolName: string | null;
/** Preview label (apply/discard). */
label: string | null;
/** Plan title (propose). */
title: string | null;
/** Plan artifact path (propose). */
planFilePath: string | null;
/** Free-form extra metadata rows. */
extra: Record<string, unknown> | null;
}
/** Derive the card model from unwrapped inner details, args fallback, and device name. */
function cardModel({ name, args, result }: ToolRenderProps): ResolveCard {
const details = detailsRecord(result);
const explicit = str(details?.action) ?? str(args.action);
const kind: ResolveKind =
explicit === "apply" || explicit === "discard"
? explicit
: name === "propose"
? "propose"
: name === "reject"
? "discard"
: "apply";
const extra = isRecord(args.extra) ? args.extra : details && isRecord(details.extra) ? details.extra : null;
return {
kind,
tone: result?.isError ? "err" : KIND_TONE[kind],
reason: str(details?.reason) ?? str(args.reason),
sourceToolName: str(details?.sourceToolName) ?? str(args.sourceToolName),
label: str(details?.label) ?? str(args.label),
title: str(details?.title) ?? str(args.title),
planFilePath: str(details?.planFilePath) ?? str(args.planFilePath),
extra,
};
}
function Summary(props: ToolRenderProps): ReactNode {
const card = cardModel(props);
const trailing = card.kind === "propose" ? card.title : card.reason;
return (
<>
<Badge tone={card.tone}>{KIND_WORD[card.kind]}</Badge>{" "}
{trailing && <span>{truncate(normalizeWs(trailing), 100)}</span>}
</>
);
}
function Body(props: ToolRenderProps): ReactNode {
const { result } = props;
const card = cardModel(props);
const extraRows: ReactNode[] = [];
if (card.extra) {
for (const k in card.extra) {
const v = card.extra[k];
let text: string;
if (typeof v === "string") text = v;
else {
try {
text = JSON.stringify(v) ?? String(v);
} catch {
text = String(v);
}
}
extraRows.push(
<Kv key={k} k={k}>
{truncate(normalizeWs(text), 200)}
</Kv>,
);
}
}
return (
<>
<Badges
items={[
<Badge key="action" tone={card.tone}>
{KIND_TRANSITION[card.kind]}
</Badge>,
card.sourceToolName && <Badge key="source">{card.sourceToolName}</Badge>,
card.label && <span key="label">{truncate(normalizeWs(card.label), 120)}</span>,
card.kind === "propose" && card.title && (
<span key="title">{truncate(normalizeWs(card.title), 120)}</span>
),
]}
/>
{card.kind !== "propose" && card.reason && <Note>{card.reason}</Note>}
{card.kind === "propose" && card.planFilePath && (
<KvGrid>
<Kv k="plan">{card.planFilePath}</Kv>
</KvGrid>
)}
{extraRows.length > 0 && <KvGrid>{extraRows}</KvGrid>}
<ResultText result={result} maxLines={6} />
</>
);
}
export const resolveRenderer: ToolRenderer = { Summary, Body };