feat(canvas): Agent引用与局部重绘 - 完善附件预览多图展开并修复取消清理

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
ddCat
2026-09-08 22:59:49 +08:00
parent 897981b893
commit 6c1d17dbf6
26 changed files with 999 additions and 236 deletions

View File

@@ -0,0 +1,40 @@
import { expect, test } from "bun:test";
import { readFileSync } from "node:fs";
const component = readFileSync(new URL("../src/components/canvas/canvas-workspace-overlays.tsx", import.meta.url), "utf8");
const css = readFileSync(new URL("../src/styles/globals.css", import.meta.url), "utf8");
test("connection menu omits the header and provides descriptions for every option", () => {
expect(component).not.toContain("引用当前节点");
expect(component).not.toContain(">创建下一步</span>");
expect(component).not.toContain("ChevronRight");
expect(component).not.toContain("absolute inset-x-8 top-0 h-px");
const options = component.match(/<ConnectionCreateOption [^\n]+/g) || [];
expect(options).toHaveLength(7);
for (const option of options) expect(option).toMatch(/description="[^"]+"/);
});
test("add-node submenu uses a four-column medium-size grid", () => {
const menu = readFileSync(new URL("../src/components/canvas/canvas-create-menu.tsx", import.meta.url), "utf8");
const context = readFileSync(new URL("../src/components/canvas/canvas-context-menu.tsx", import.meta.url), "utf8");
const styles = readFileSync(new URL("../src/styles/globals.css", import.meta.url), "utf8");
expect(menu).toContain('variant === "node" ? "grid-cols-4"');
expect(menu).toContain("size-6 shrink-0");
expect(context).toContain('w-[360px]');
expect(styles).toContain("--canvas-create-node-height: var(--space-16)");
});
test("only the active option expands; stationary pointer retargeting cannot switch rows", () => {
const descriptionRule = css.match(/\.canvas-connection-create-description \{([^}]+)\}/)?.[1] || "";
expect(descriptionRule).toContain("grid-template-rows: 0fr");
expect(descriptionRule).not.toContain("min-height");
const option = component.slice(component.indexOf("function ConnectionCreateOption("), component.indexOf("function clamp("));
expect(option).not.toContain("motion.button");
expect(css).toContain('.canvas-connection-create-option[data-expanded="true"]');
expect(component).toContain("previous?.x === event.clientX && previous.y === event.clientY");
expect(component).not.toContain("SpotlightSurface");
expect(css).toContain('.canvas-connection-create-option[data-motion="reduced"]');
expect(component).toContain('aria-disabled={Boolean(disabledReason)}');
expect(component).toContain("if (!disabledReason) onClick()");
expect(component).toContain('event.key === "Escape"');
});

View File

@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test";
import { failedImageBatchChildren, markImageBatchRetrying, reconcileImageBatchRoot, restoreUnsubmittedImageBatchChild } from "../src/lib/canvas/canvas-image-batch-retry";
import { cancelIncompleteImageBatch, failedImageBatchChildren, markImageBatchRetrying, reconcileImageBatchRoot, restoreUnsubmittedImageBatchChild, retireImageBatchChildren } from "../src/lib/canvas/canvas-image-batch-retry";
import { CanvasNodeType, type CanvasNodeData, type CanvasNodeStatus } from "../src/types/canvas";
function imageNode(id: string, status: CanvasNodeStatus, metadata: Partial<NonNullable<CanvasNodeData["metadata"]>> = {}): CanvasNodeData {
@@ -85,4 +85,27 @@ describe("canvas image batch retry", () => {
expect(restoreUnsubmittedImageBatchChild(loading, original).metadata).toMatchObject({ status: "error", errorDetails: "原始失败" });
expect(restoreUnsubmittedImageBatchChild(imageNode("failed", "success", { content: "image:success" }), original).metadata?.status).toBe("success");
});
test("重新生成时清掉未完成子图,并把已有结果从折叠组里拆出去", () => {
const root = imageNode("root", "loading", { isBatchRoot: true, batchChildIds: ["done", "pending"], imageBatchExpanded: false });
const done = imageNode("done", "success", { batchRootId: root.id, content: "image:done" });
const pending = imageNode("pending", "loading", { batchRootId: root.id });
const retired = retireImageBatchChildren(root, [root, done, pending], [{ id: "c1", fromNodeId: root.id, toNodeId: done.id }, { id: "c2", fromNodeId: root.id, toNodeId: pending.id }]);
expect(retired.removedIds).toEqual(["pending"]);
expect(retired.nodes.map((node) => node.id)).toEqual(["root", "done"]);
expect(retired.nodes.find((node) => node.id === "done")?.metadata?.batchRootId).toBeUndefined();
expect(retired.nodes.find((node) => node.id === "root")?.metadata).toMatchObject({ imageBatchExpanded: true, status: "idle" });
expect(retired.connections.map((item) => item.id)).toEqual(["c1"]);
});
test("取消生成时删除没有结果的子图并展开剩余批次", () => {
const root = imageNode("root", "loading", { isBatchRoot: true, batchChildIds: ["done", "pending"] });
const done = imageNode("done", "success", { batchRootId: root.id, content: "image:done" });
const pending = imageNode("pending", "loading", { batchRootId: root.id });
const cancelled = cancelIncompleteImageBatch(root.id, ["done", "pending"], [root, done, pending], [{ id: "c2", fromNodeId: root.id, toNodeId: pending.id }]);
expect(cancelled.removedIds).toEqual(["pending"]);
expect(cancelled.nodes.map((node) => node.id)).toEqual(["root", "done"]);
expect(cancelled.nodes.find((node) => node.id === "root")?.metadata?.batchChildIds).toBeUndefined();
expect(cancelled.nodes.find((node) => node.id === "done")?.metadata?.batchRootId).toBeUndefined();
});
});

View File

@@ -47,6 +47,11 @@ describe("上传图片是输入素材", () => {
expect(portrait).toContain("fromNodeId: node.id, toNodeId: child.id");
expect(portrait).toContain("setDialogNodeId(child.id)");
expect(portrait).not.toContain("current.map");
expect(mediaTools).toContain("payload.generationConfig");
expect(mediaTools).toContain("imageBatchExpanded: requestedCount > 1 ? true : undefined");
const dialog = read("components/canvas/canvas-node-mask-edit-dialog.tsx");
expect(dialog).toContain("ModelPicker");
expect(dialog).toContain("ImageSettingsPanel");
});
});

View File

@@ -7,6 +7,18 @@ function source(path: string) {
}
describe("canvas resource mention editor", () => {
test("refreshes async reference previews even when prompt text has not changed", () => {
const component = source("../src/components/canvas/canvas-resource-mention-textarea.tsx");
const unchangedTextBranch = component.match(/if \(currentValue === value && lastRenderedValueRef.current === value\) \{([^}]+)\}/)?.[1] || "";
expect(unchangedTextBranch).toContain("syncInlineMentionPreviews(editor, activeReferences)");
const sync = component.slice(component.indexOf("function syncInlineMentionPreviews("), component.indexOf("function MentionMenu("));
expect(sync).toContain('byId.get(chip.dataset.mentionReferenceId || "")');
expect(sync).toContain('preview.getAttribute("src") !== src');
expect(sync).toContain("preview.replaceWith(createInlinePreview(reference))");
expect(sync).not.toContain("replaceChildren");
expect(sync).not.toContain("onChange(");
});
test("uses stable component classes for inline media references", () => {
const component = source("../src/components/canvas/canvas-resource-mention-textarea.tsx");
@@ -62,6 +74,9 @@ describe("canvas resource mention editor", () => {
const generationExecutor = source("../src/pages/canvas/use-canvas-generation-executor.ts");
expect(imageExecutor.match(/canvasGenerationPromptMetadata\(prompt, effectivePrompt\)/g)?.length).toBeGreaterThanOrEqual(3);
expect(imageExecutor).toContain("imageBatchExpanded: count > 1 ? true : undefined");
expect(imageExecutor).toContain("imageGenerationReferenceConnections");
expect(imageExecutor).toContain("retireImageBatchChildren");
expect(mediaExecutors.match(/canvasGenerationPromptMetadata\(prompt, effectivePrompt\)/g)?.length).toBe(2);
expect(textExecutor.match(/canvasGenerationPromptMetadata\(prompt, effectivePrompt\)/g)?.length).toBe(2);
expect(generationExecutor).toContain("composerContent: prompt");
@@ -89,4 +104,14 @@ describe("canvas resource mention editor", () => {
expect(css).toContain(".canvas-resource-mention-item.is-skill");
expect(css).toContain(".canvas-resource-mention-meta");
});
test("agent composer attachments stay large, previewable, and mentionable", () => {
const component = source("../src/components/canvas/canvas-agent-chat-ui.tsx");
expect(component).toContain("w-20 shrink-0");
expect(component).toContain("insertAttachmentMention");
expect(component).toContain("@[attachment:");
expect(component).toContain("AgentImagePreview");
expect(component).toContain("composerReferences");
expect(component).toContain("点击放大预览");
});
});

View File

@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test";
import { applyCanvasConnectionPromptSync, buildAssetMentionReferences, buildCanvasNodeMentionReferenceMap, buildNodeMentionReferences, buildOrderedCanvasResourceReferences, canvasResourceMentionToken, collectUpstreamVideoNodes } from "../src/lib/canvas/canvas-resource-references";
import { applyCanvasConnectionPromptSync, buildAssetMentionReferences, buildCanvasNodeMentionReferenceMap, buildNodeMentionReferences, buildOrderedCanvasResourceReferences, canvasResourceMentionToken, collectUpstreamVideoNodes, imageGenerationReferenceConnections } from "../src/lib/canvas/canvas-resource-references";
import { canvasNodeToAsset } from "../src/lib/canvas/canvas-node-asset";
import { buildNodeGenerationInputs } from "../src/components/canvas/canvas-node-generation";
import { CanvasNodeType, type CanvasConnection, type CanvasNodeData } from "../src/types/canvas";
@@ -260,3 +260,16 @@ describe("remove canvas resource mention tokens", () => {
expect(nextTarget.metadata?.composerContent).toBe("比较 和 @图片1");
});
});
describe("image generation reference connections", () => {
test("把源节点的参考图连线复制到新结果,避免提示词图片丢失", () => {
const imageA = imageNode("image-a");
const imageB = imageNode("image-b");
const source = textNode("prompt");
const nodes = [imageA, imageB, source];
const connections = [connection(imageA.id, source.id), connection(imageB.id, source.id)];
const copied = imageGenerationReferenceConnections(source.id, "result", nodes, connections, () => "new-id");
expect(copied.map((item) => item.fromNodeId)).toEqual(["image-a", "image-b"]);
expect(copied.every((item) => item.toNodeId === "result")).toBe(true);
});
});

View File

@@ -139,6 +139,8 @@ describe("统一图片分辨率与宽高比", () => {
expect(editor).toContain(`aria-label="${tier} 16:9"`);
}
expect(editor).toContain('aria-label="默认输出"');
expect(editor).toContain("全不选");
expect(editor).toContain("全选");
const readOnly = renderToStaticMarkup(<ImageSizePresetsEditor profile={profile} disabled onChange={() => {}} />);
const buttons = [...readOnly.matchAll(/<button\b[^>]*>/g)];
expect(buttons.length).toBeGreaterThan(24);