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:
@@ -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} />
|
||||
</>
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user