diff --git a/app/(chat)/api/chat/route.ts b/app/(chat)/api/chat/route.ts index 023bc43dc..4a1e46c48 100644 --- a/app/(chat)/api/chat/route.ts +++ b/app/(chat)/api/chat/route.ts @@ -73,6 +73,7 @@ import { resolveCanvasAgentSlotCreationIntent, type CanvasAgentModelPlannerOutcome, } from "@/lib/canvas/agent-model-planner"; +import { buildCanvasAgentResultViewOperation } from "@/lib/canvas/agent-result-view"; import { getCanvasAgentOperationErrorMessage, normalizeCanvasAgentOperationErrorCode, @@ -3056,6 +3057,23 @@ export async function POST(request: Request) { }, `${params.operationLabel}:canvas-operation`, ); + if (canvasAgentFeatures.viewOperationEnabled) { + const viewOperation = buildCanvasAgentResultViewOperation({ + projectId, + operationId, + mutationOperation: operation, + createdNodeIds: result.createdNodeIds, + updatedNodeIds: result.updatedNodeIds, + anchorNodeId: result.createdNodeIds[0] || result.updatedNodeIds[0] || null, + }); + if (viewOperation) { + writeCanvasViewOperationChunk( + params.writer, + viewOperation, + `${params.operationLabel}:canvas-view-operation`, + ); + } + } return { mediaItemIds: handledCanvasMediaItemIds, handledCanvasMediaItemIds, diff --git a/lib/canvas/agent-result-view.ts b/lib/canvas/agent-result-view.ts new file mode 100644 index 000000000..2e060a3be --- /dev/null +++ b/lib/canvas/agent-result-view.ts @@ -0,0 +1,68 @@ +import type { + CanvasOperationKind, + CanvasViewOperationPlan, +} from "@/types/canvas/agent-protocol"; + +function normalizeNodeIds(nodeIds?: string[]) { + return Array.from( + new Set( + (nodeIds || []) + .map((nodeId) => String(nodeId || "").trim()) + .filter(Boolean) + ) + ); +} + +export function buildCanvasAgentResultViewOperation(params: { + projectId?: string | null; + operationId: string; + mutationOperation?: CanvasOperationKind; + createdNodeIds?: string[]; + updatedNodeIds?: string[]; + anchorNodeId?: string | null; + padding?: number; + durationMs?: number; + reason?: string; +}): CanvasViewOperationPlan | null { + const projectId = String(params.projectId || "").trim(); + const operationId = String(params.operationId || "").trim(); + if (!projectId || !operationId) return null; + + const createdNodeIds = normalizeNodeIds(params.createdNodeIds); + const updatedNodeIds = normalizeNodeIds(params.updatedNodeIds).filter( + (nodeId) => !createdNodeIds.includes(nodeId) + ); + const nodeIds = createdNodeIds.length > 0 ? createdNodeIds : updatedNodeIds; + if (nodeIds.length === 0) return null; + + const requestedAnchorNodeId = String(params.anchorNodeId || "").trim(); + const anchorNodeId = nodeIds.includes(requestedAnchorNodeId) + ? requestedAnchorNodeId + : nodeIds[0]; + const hasCreatedResult = createdNodeIds.length > 0; + const operation = + nodeIds.length > 1 + ? "focus_nodes" + : hasCreatedResult + ? "focus_latest_result" + : "focus_node"; + + return { + version: 1, + viewOperationId: `${operationId}:view`, + operation, + projectId, + nodeIds, + anchorNodeId, + padding: params.padding ?? 120, + durationMs: params.durationMs ?? 360, + persist: false, + reason: + params.reason || + (params.mutationOperation === "replace_node_media" + ? "focus_canvas_agent_replaced_nodes" + : hasCreatedResult + ? "focus_canvas_agent_writeback_result" + : "focus_canvas_agent_updated_nodes"), + }; +} diff --git a/tests/routes/canvas-chat-helpers.test.ts b/tests/routes/canvas-chat-helpers.test.ts index 4de92b669..041b37da9 100644 --- a/tests/routes/canvas-chat-helpers.test.ts +++ b/tests/routes/canvas-chat-helpers.test.ts @@ -48,6 +48,7 @@ import { shouldConsiderCanvasAgentModelPlanner, } from "@/lib/canvas/agent-model-planner"; import { resolveCanvasAgentMediaPlacements } from "@/lib/canvas/agent-placement"; +import { buildCanvasAgentResultViewOperation } from "@/lib/canvas/agent-result-view"; import { computeCanvasAgentPanViewport, resolveCanvasViewOperation, @@ -1205,6 +1206,59 @@ test.describe("canvas chat pure helpers", () => { expect(useCanvasStore.getState().isAgentOperationRefreshActive()).toBe(false); }); + test("builds transient view operations for agent writeback results", () => { + expect( + buildCanvasAgentResultViewOperation({ + projectId: plannerContext.projectId, + operationId: "operation-1", + mutationOperation: "insert_beside", + createdNodeIds: ["new-image-1"], + }) + ).toMatchObject({ + version: 1, + viewOperationId: "operation-1:view", + operation: "focus_latest_result", + nodeIds: ["new-image-1"], + anchorNodeId: "new-image-1", + persist: false, + reason: "focus_canvas_agent_writeback_result", + }); + + expect( + buildCanvasAgentResultViewOperation({ + projectId: plannerContext.projectId, + operationId: "operation-2", + mutationOperation: "append_revision", + createdNodeIds: ["new-image-1", "new-image-2", "new-image-1"], + updatedNodeIds: ["slot-1"], + }) + ).toMatchObject({ + operation: "focus_nodes", + nodeIds: ["new-image-1", "new-image-2"], + anchorNodeId: "new-image-1", + }); + + expect( + buildCanvasAgentResultViewOperation({ + projectId: plannerContext.projectId, + operationId: "operation-3", + mutationOperation: "replace_node_media", + updatedNodeIds: ["image-1"], + }) + ).toMatchObject({ + operation: "focus_node", + nodeIds: ["image-1"], + reason: "focus_canvas_agent_replaced_nodes", + }); + + expect( + buildCanvasAgentResultViewOperation({ + projectId: plannerContext.projectId, + operationId: "operation-4", + }) + ).toBeNull(); + }); + test("resolves latest-result and pan canvas view operations", () => { expect( resolveCanvasViewOperationTargetNodeIds({