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
This commit is contained in:
roboomp
2026-07-23 19:23:19 +00:00
parent c64e7146e0
commit 839ba1ce02
4 changed files with 225 additions and 26 deletions
+4
View File
@@ -2,6 +2,10 @@
## [Unreleased]
### Fixed
- Fixed `xd://resolve`/`xd://reject`/`xd://propose` cards losing action metadata after the xdev unwrap (badge rendered `?`/warn instead of apply/discard/propose semantics), and registered the missing `reject`/`propose` renderers plus the hub-family aliases (`irc`, `job`, `await`, `poll`, `cancel_job`) so those transcript names no longer fall back to generic JSON. ([#5640](https://github.com/can1357/oh-my-pi/issues/5640))
## [17.0.8] - 2026-07-22
### Fixed
@@ -19,6 +19,8 @@ import { goalRenderer } from "./tools/goal";
import { grepRenderer } from "./tools/grep";
import { hubRenderer } from "./tools/hub";
import { inspectImageRenderer } from "./tools/inspect-image";
import { ircRenderer } from "./tools/irc";
import { jobRenderer } from "./tools/job";
import { lspRenderer } from "./tools/lsp";
import { recallRenderer } from "./tools/memory-recall";
import { reflectRenderer } from "./tools/memory-reflect";
@@ -55,6 +57,11 @@ const RENDERERS: Record<string, ToolRenderer> = {
goal: goalRenderer,
inspect_image: inspectImageRenderer,
hub: hubRenderer,
irc: ircRenderer,
job: jobRenderer,
await: jobRenderer,
poll: jobRenderer,
cancel_job: jobRenderer,
lsp: lspRenderer,
recall: recallRenderer,
reflect: reflectRenderer,
@@ -62,6 +69,8 @@ const RENDERERS: Record<string, ToolRenderer> = {
read: readRenderer,
report_tool_issue: reportToolIssueRenderer,
resolve: resolveRenderer,
reject: resolveRenderer,
propose: resolveRenderer,
grep: grepRenderer,
search: grepRenderer,
task: taskRenderer,
@@ -1,32 +1,91 @@
/** `resolve` — apply or discard a pending preview/approval action. */
/**
* `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";
function Summary({ args, result }: ToolRenderProps): ReactNode {
const action = str(args.action);
const reason = str(args.reason);
const tone = result?.isError ? "err" : action === "apply" ? "ok" : "warn";
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={tone}>{action ?? "?"}</Badge> {reason && <span>{truncate(normalizeWs(reason), 100)}</span>}
<Badge tone={card.tone}>{KIND_WORD[card.kind]}</Badge>{" "}
{trailing && <span>{truncate(normalizeWs(trailing), 100)}</span>}
</>
);
}
function Body({ args, result }: ToolRenderProps): ReactNode {
const action = str(args.action);
const reason = str(args.reason);
const tone = result?.isError ? "err" : action === "apply" ? "ok" : "warn";
const details = detailsRecord(result);
const sourceToolName = details ? str(details.sourceToolName) : null;
const label = details ? str(details.label) : null;
const extra = isRecord(args.extra) ? args.extra : details && isRecord(details.extra) ? details.extra : null;
function Body(props: ToolRenderProps): ReactNode {
const { result } = props;
const card = cardModel(props);
const extraRows: ReactNode[] = [];
if (extra) {
for (const k in extra) {
const v = extra[k];
if (card.extra) {
for (const k in card.extra) {
const v = card.extra[k];
let text: string;
if (typeof v === "string") text = v;
else {
@@ -47,18 +106,22 @@ function Body({ args, result }: ToolRenderProps): ReactNode {
<>
<Badges
items={[
<Badge key="action" tone={tone}>
{action === "apply"
? "proposed → resolved"
: action === "discard"
? "proposed → rejected"
: (action ?? "?")}
<Badge key="action" tone={card.tone}>
{KIND_TRANSITION[card.kind]}
</Badge>,
sourceToolName && <Badge key="source">{sourceToolName}</Badge>,
label && <span key="label">{truncate(normalizeWs(label), 120)}</span>,
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>
),
]}
/>
{reason && <Note>{reason}</Note>}
{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} />
</>
+123
View File
@@ -28,4 +28,127 @@ describe("ToolView xd:// dispatches", () => {
expect(html).toContain("alpine lake");
expect(html).toContain('src="data:image/png;base64,aW1hZ2U="');
});
it("renders xd://resolve apply cards from unwrapped inner details", () => {
const html = renderToStaticMarkup(
<ToolView
name="write"
defaultOpen
result={{
content: [{ type: "text", text: "Applied 1 replacement in 1 file." }],
details: {
xdev: {
tool: "resolve",
mode: "execute",
args: { reason: "looks correct" },
inner: {
action: "apply",
reason: "looks correct",
sourceToolName: "ast_edit",
label: "ast_edit: edit foo.ts",
},
},
},
}}
/>,
);
expect(html).toContain("xd://resolve");
expect(html).toContain("proposed → resolved");
expect(html).toContain("tv-badge--ok");
expect(html).toContain("ast_edit: edit foo.ts");
// The historical args-only path once left the badge as a warn "?".
expect(html).not.toContain('tv-badge--warn">?');
});
it("renders xd://reject discard cards with reject semantics", () => {
const html = renderToStaticMarkup(
<ToolView
name="write"
defaultOpen
result={{
content: [{ type: "text", text: "Discarded pending action." }],
details: {
xdev: {
tool: "reject",
mode: "execute",
args: { reason: "not right" },
inner: { action: "discard", reason: "not right", sourceToolName: "edit" },
},
},
}}
/>,
);
expect(html).toContain("xd://reject");
expect(html).toContain("proposed → rejected");
expect(html).toContain("tv-badge--warn");
// Not the generic JSON dump.
expect(html).not.toContain("tv-out-title");
});
it("defaults a running xd://reject to discard before details arrive", () => {
const html = renderToStaticMarkup(
<ToolView name="reject" defaultOpen running args={{ reason: "" }} />,
);
expect(html).toContain("proposed → rejected");
});
it("renders xd://propose plan metadata from unwrapped inner details", () => {
const html = renderToStaticMarkup(
<ToolView
name="write"
defaultOpen
result={{
content: [{ type: "text", text: "Plan ready for review." }],
details: {
xdev: {
tool: "propose",
mode: "execute",
args: { title: "ship it" },
inner: { planFilePath: "local://ship-it-plan.md", title: "ship it", planExists: true },
},
},
}}
/>,
);
expect(html).toContain("xd://propose");
expect(html).toContain("plan proposed");
expect(html).toContain("local://ship-it-plan.md");
});
it("keeps historical top-level resolve cards working from args.action", () => {
const html = renderToStaticMarkup(
<ToolView
name="resolve"
defaultOpen
args={{ action: "apply", reason: "ok" }}
result={{ content: [], details: { sourceToolName: "ast_edit", label: "edit foo.ts" } }}
/>,
);
expect(html).toContain("proposed → resolved");
expect(html).toContain("tv-badge--ok");
});
it("routes the hub-family alias irc through the messaging renderer", () => {
const html = renderToStaticMarkup(
<ToolView name="irc" defaultOpen args={{ op: "send", to: "Main", message: "hi" }} result={{ content: [] }} />,
);
expect(html).toContain("→ Main");
// Not the generic JSON dump of the args.
expect(html).not.toContain("tv-out-title");
});
it("routes the hub-family alias job through the job renderer", () => {
const html = renderToStaticMarkup(
<ToolView name="job" defaultOpen args={{ poll: ["a1b2"] }} result={{ content: [] }} />,
);
expect(html).toContain("poll a1b2");
expect(html).not.toContain("tv-out-title");
});
});