feat(canvas): Agent引用与局部重绘 - 完善附件预览多图展开并修复取消清理
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
40
web/test/canvas-connection-create-menu.test.ts
Normal file
40
web/test/canvas-connection-create-menu.test.ts
Normal 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"');
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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("点击放大预览");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user