diff --git a/src/app/api/canvas-workflows/[id]/__tests__/route.test.ts b/src/app/api/canvas-workflows/[id]/__tests__/route.test.ts
index 93145577..c767856d 100644
--- a/src/app/api/canvas-workflows/[id]/__tests__/route.test.ts
+++ b/src/app/api/canvas-workflows/[id]/__tests__/route.test.ts
@@ -67,6 +67,33 @@ describe("/api/canvas-workflows/[id] route", () => {
});
});
+ it("proxies update coverUrl and omits missing description", async () => {
+ const fetchMock = vi.fn().mockResolvedValue(
+ new Response(JSON.stringify({ status: "0000", message: "ok", data: { id: 32 } }), { status: 200 })
+ );
+ global.fetch = fetchMock;
+
+ const body = {
+ title: "Workflow",
+ properties: "{\"nodes\":[],\"edges\":[]}",
+ coverUrl: "https://example.com/cover.png",
+ };
+ const response = await POST(createRequest("POST", body), context);
+
+ await expect(response.json()).resolves.toEqual({ success: true, data: { id: 32 } });
+ expect(fetchMock).toHaveBeenCalledWith("https://popi.example/api_client/canvas/update", {
+ method: "POST",
+ headers: {
+ Accept: "application/json",
+ "Content-Type": "application/json",
+ Authorization: "Bearer user-token",
+ token: "user-token",
+ },
+ body: JSON.stringify({ id: 32, ...body }),
+ cache: "no-store",
+ });
+ });
+
it("proxies delete requests to canvas delete", async () => {
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify({ status: "0000", message: "ok", data: true }), { status: 200 })
diff --git a/src/app/api/canvas-workflows/[id]/route.ts b/src/app/api/canvas-workflows/[id]/route.ts
index 93b3b597..15cbadd6 100644
--- a/src/app/api/canvas-workflows/[id]/route.ts
+++ b/src/app/api/canvas-workflows/[id]/route.ts
@@ -78,11 +78,13 @@ export async function POST(request: NextRequest, context: RouteContext) {
title?: unknown;
desp?: unknown;
properties?: unknown;
+ coverUrl?: unknown;
} | null;
const title = typeof body?.title === "string" ? body.title.trim() : "";
- const desp = typeof body?.desp === "string" ? body.desp : "";
+ const desp = typeof body?.desp === "string" ? body.desp : undefined;
const properties = typeof body?.properties === "string" ? body.properties : "";
+ const coverUrl = typeof body?.coverUrl === "string" && body.coverUrl.trim() ? body.coverUrl : undefined;
if (!title) {
return NextResponse.json(
@@ -97,6 +99,14 @@ export async function POST(request: NextRequest, context: RouteContext) {
);
}
+ const upstreamBody = {
+ id,
+ title,
+ ...(desp !== undefined ? { desp } : {}),
+ properties,
+ ...(coverUrl ? { coverUrl } : {}),
+ };
+
const upstream = await fetch(getPopiBaseUrl(request) + POPI_CANVAS_UPDATE_PATH, {
method: "POST",
headers: {
@@ -105,7 +115,7 @@ export async function POST(request: NextRequest, context: RouteContext) {
Authorization: `Bearer ${login.token}`,
token: login.token,
},
- body: JSON.stringify({ id, title, desp, properties }),
+ body: JSON.stringify(upstreamBody),
cache: "no-store",
});
@@ -136,8 +146,6 @@ export async function DELETE(request: NextRequest, context: RouteContext) {
const upstreamUrl = new URL(getPopiBaseUrl(request) + POPI_CANVAS_DELETE_PATH);
upstreamUrl.searchParams.set("id", String(id));
- console.log('upstreamUrl --------------', upstreamUrl)
-
const upstream = await fetch(upstreamUrl, {
method: "GET",
headers: {
diff --git a/src/app/canvas/page.tsx b/src/app/canvas/page.tsx
index 614abdfe..6066680c 100644
--- a/src/app/canvas/page.tsx
+++ b/src/app/canvas/page.tsx
@@ -17,6 +17,8 @@ import { installAuthFetchInterceptor } from "@/utils/authFetch";
import { useModelStore } from "@/store/modelStore";
import { isPopiProviderMode } from "@/lib/providerMode";
import { LoginModal } from "@/components/auth/LoginModal";
+import { useCanvasWorkflowLoader } from "@/hooks/useCanvasWorkflowLoader";
+import { useCanvasWorkflowAutoUpdate } from "@/hooks/useCanvasWorkflowAutoUpdate";
if (typeof window !== "undefined") {
installAuthFetchInterceptor();
@@ -34,6 +36,10 @@ function CanvasPageContent() {
const searchParams = useSearchParams();
const token = searchParams.get("token") || '';
+ const workflowId = searchParams.get("workflowId");
+
+ useCanvasWorkflowLoader(workflowId);
+ useCanvasWorkflowAutoUpdate();
useEffect(() => {
if (token || getStoredLoginToken()) {
diff --git a/src/components/CanvasWorkflowPickerModal.tsx b/src/components/CanvasWorkflowPickerModal.tsx
index e64f9119..eefa84df 100644
--- a/src/components/CanvasWorkflowPickerModal.tsx
+++ b/src/components/CanvasWorkflowPickerModal.tsx
@@ -35,6 +35,11 @@ function formatWorkflowTime(value?: string): string {
return date.toLocaleString();
}
+function buildCanvasWorkflowUrl(searchParams: URLSearchParams): string {
+ const query = searchParams.toString();
+ return query ? `/canvas?${query}` : "/canvas";
+}
+
export function CanvasWorkflowPickerModal({ isOpen, onClose }: CanvasWorkflowPickerModalProps) {
const { t } = useI18n();
const { message } = App.useApp();
@@ -69,7 +74,7 @@ export function CanvasWorkflowPickerModal({ isOpen, onClose }: CanvasWorkflowPic
const openWorkflow = (workflow: CanvasWorkflowListItem) => {
const nextParams = new URLSearchParams(searchParams.toString());
nextParams.set("workflowId", String(workflow.id));
- router.push(`/?${nextParams.toString()}`);
+ router.push(buildCanvasWorkflowUrl(nextParams));
onClose();
};
@@ -80,8 +85,7 @@ export function CanvasWorkflowPickerModal({ isOpen, onClose }: CanvasWorkflowPic
if (currentCanvasWorkflowId === workflow.id) {
const nextParams = new URLSearchParams(searchParams.toString());
nextParams.delete("workflowId");
- const nextQuery = nextParams.toString();
- router.replace(nextQuery ? `/?${nextQuery}` : "/");
+ router.replace(buildCanvasWorkflowUrl(nextParams));
clearCurrentCanvasWorkflow();
}
await loadWorkflows(pageInfo.page, pageInfo.pageSize);
diff --git a/src/components/Header.tsx b/src/components/Header.tsx
index 21a5f4b9..b5c803f4 100644
--- a/src/components/Header.tsx
+++ b/src/components/Header.tsx
@@ -1,8 +1,9 @@
"use client";
-import { useState, useMemo, useCallback, type ReactNode } from "react";
+import { useState, useMemo, useCallback, useEffect, type ReactNode } from "react";
import { Button } from "antd";
import { useWorkflowStore } from "@/store/workflowStore";
+import { useCanvasWorkflowStore } from "@/store/canvasWorkflowStore";
import { useShallow } from "zustand/shallow";
import { ProjectSetupModal } from "./ProjectSetupModal";
import { CostIndicator } from "./CostIndicator";
@@ -96,10 +97,9 @@ export function Header() {
workflowName,
workflowId,
saveDirectoryPath,
- hasUnsavedChanges,
- lastSavedAt,
isSaving,
setWorkflowMetadata,
+ setWorkflowName,
saveToFile,
loadWorkflow,
previousWorkflowSnapshot,
@@ -111,10 +111,9 @@ export function Header() {
workflowName: state.workflowName,
workflowId: state.workflowId,
saveDirectoryPath: state.saveDirectoryPath,
- hasUnsavedChanges: state.hasUnsavedChanges,
- lastSavedAt: state.lastSavedAt,
isSaving: state.isSaving,
setWorkflowMetadata: state.setWorkflowMetadata,
+ setWorkflowName: state.setWorkflowName,
saveToFile: state.saveToFile,
loadWorkflow: state.loadWorkflow,
previousWorkflowSnapshot: state.previousWorkflowSnapshot,
@@ -129,16 +128,41 @@ export function Header() {
const [showWorkflowBrowser, setShowWorkflowBrowser] = useState(false);
const [showWorkflowSaveModal, setShowWorkflowSaveModal] = useState(false);
const [showCanvasWorkflowPicker, setShowCanvasWorkflowPicker] = useState(false);
+ const [workflowNameDraft, setWorkflowNameDraft] = useState(workflowName || "");
+ const currentCanvasWorkflowId = useCanvasWorkflowStore((state) => state.currentCanvasWorkflowId);
+ const currentCanvasWorkflowDescription = useCanvasWorkflowStore((state) => state.currentCanvasWorkflowDescription);
+ const setCurrentCanvasWorkflow = useCanvasWorkflowStore((state) => state.setCurrentCanvasWorkflow);
const isProjectConfigured = !!workflowName;
const canSave = !!(workflowId && workflowName && saveDirectoryPath);
- const formatTime = (timestamp: number) => {
- return new Date(timestamp).toLocaleTimeString(language, {
- hour: "numeric",
- minute: "2-digit",
- });
- };
+ useEffect(() => {
+ setWorkflowNameDraft(workflowName || "");
+ }, [workflowName]);
+
+ const commitWorkflowName = useCallback(() => {
+ const nextName = workflowNameDraft.trim();
+ if (!nextName || nextName === workflowName) {
+ setWorkflowNameDraft(workflowName || "");
+ return;
+ }
+
+ setWorkflowName(nextName);
+ if (currentCanvasWorkflowId) {
+ setCurrentCanvasWorkflow({
+ id: currentCanvasWorkflowId,
+ title: nextName,
+ description: currentCanvasWorkflowDescription,
+ });
+ }
+ }, [
+ workflowNameDraft,
+ workflowName,
+ setWorkflowName,
+ currentCanvasWorkflowId,
+ currentCanvasWorkflowDescription,
+ setCurrentCanvasWorkflow,
+ ]);
const handleNewProject = () => {
setProjectModalMode("new");
@@ -278,8 +302,23 @@ export function Header() {
{isProjectConfigured ? (
<>
- {workflowName}
- |
+ setWorkflowNameDraft(event.target.value)}
+ onBlur={commitWorkflowName}
+ onKeyDown={(event) => {
+ if (event.key === "Enter") {
+ event.currentTarget.blur();
+ }
+ if (event.key === "Escape") {
+ setWorkflowNameDraft(workflowName || "");
+ event.currentTarget.blur();
+ }
+ }}
+ size={Math.max(workflowNameDraft.length, 1)}
+ className="min-w-0 bg-transparent p-0 text-sm text-neutral-300 outline-none"
+ aria-label={t("workflowSave.name")}
+ />
{/* File operations group */}
@@ -304,9 +343,6 @@ export function Header() {
d="M19.5 14.25v-2.625a3.375 3.375 0 0 0-3.375-3.375h-1.5A1.125 1.125 0 0 1 13.5 7.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H8.25m.75 12 3 3m0 0 3-3m-3 3v-6m-1.5-9H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 0 0-9-9Z"
/>
- {hasUnsavedChanges && !isSaving && (
-
- )}
)}
-
- {isProjectConfigured ? (
- isSaving ? (
- t("header.saving")
- ) : lastSavedAt ? (
- t("header.savedAt", { time: formatTime(lastSavedAt) })
- ) : (
- t("header.notSaved")
- )
- ) : (
- t("header.notSaved")
- )}
-
ยท