diff --git a/frontend/src/app/workspace/agents/new/page.tsx b/frontend/src/app/workspace/agents/new/page.tsx index 7b04b4486..33f6de213 100644 --- a/frontend/src/app/workspace/agents/new/page.tsx +++ b/frontend/src/app/workspace/agents/new/page.tsx @@ -1,8 +1,16 @@ "use client"; -import { ArrowLeftIcon, BotIcon, CheckCircleIcon } from "lucide-react"; +import { + ArrowLeftIcon, + BotIcon, + CheckCircleIcon, + InfoIcon, + MoreHorizontalIcon, + SaveIcon, +} from "lucide-react"; import { useRouter } from "next/navigation"; -import { useCallback, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { toast } from "sonner"; import { PromptInput, @@ -10,17 +18,20 @@ import { PromptInputSubmit, PromptInputTextarea, } from "@/components/ai-elements/prompt-input"; +import { Alert, AlertDescription } from "@/components/ui/alert"; import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; import { Input } from "@/components/ui/input"; import { ArtifactsProvider } from "@/components/workspace/artifacts"; import { MessageList } from "@/components/workspace/messages"; import { ThreadContext } from "@/components/workspace/messages/context"; import type { Agent } from "@/core/agents"; -import { - AgentNameCheckError, - checkAgentName, - getAgent, -} from "@/core/agents/api"; +import { checkAgentName, getAgent } from "@/core/agents/api"; import { useI18n } from "@/core/i18n/hooks"; import { useThreadStream } from "@/core/threads/hooks"; import { uuid } from "@/core/utils/uuid"; @@ -28,23 +39,46 @@ import { isIMEComposing } from "@/lib/ime"; import { cn } from "@/lib/utils"; type Step = "name" | "chat"; +type SetupAgentStatus = "idle" | "requested" | "completed"; const NAME_RE = /^[A-Za-z0-9-]+$/; +const SAVE_HINT_STORAGE_KEY = "deerflow.agent-create.save-hint-seen"; +const AGENT_READ_RETRY_DELAYS_MS = [200, 500, 1_000, 2_000]; + +function wait(ms: number) { + return new Promise((resolve) => window.setTimeout(resolve, ms)); +} + +async function getAgentWithRetry(agentName: string) { + for (const delay of [0, ...AGENT_READ_RETRY_DELAYS_MS]) { + if (delay > 0) { + await wait(delay); + } + + try { + return await getAgent(agentName); + } catch { + // Retry until the write settles or the attempts are exhausted. + } + } + + return null; +} export default function NewAgentPage() { const { t } = useI18n(); const router = useRouter(); - // ── Step 1: name form ────────────────────────────────────────────────────── const [step, setStep] = useState("name"); const [nameInput, setNameInput] = useState(""); const [nameError, setNameError] = useState(""); const [isCheckingName, setIsCheckingName] = useState(false); const [agentName, setAgentName] = useState(""); const [agent, setAgent] = useState(null); - // ── Step 2: chat ─────────────────────────────────────────────────────────── + const [showSaveHint, setShowSaveHint] = useState(false); + const [setupAgentStatus, setSetupAgentStatus] = + useState("idle"); - // Stable thread ID — all turns belong to the same thread const threadId = useMemo(() => uuid(), []); const [thread, sendMessage] = useThreadStream({ @@ -53,17 +87,35 @@ export default function NewAgentPage() { mode: "flash", is_bootstrap: true, }, + onFinish() { + if (!agent && setupAgentStatus === "requested") { + setSetupAgentStatus("idle"); + } + }, onToolEnd({ name }) { if (name !== "setup_agent" || !agentName) return; - getAgent(agentName) - .then((fetched) => setAgent(fetched)) - .catch(() => { - // agent write may not be flushed yet — ignore silently - }); + setSetupAgentStatus("completed"); + void getAgentWithRetry(agentName).then((fetched) => { + if (fetched) { + setAgent(fetched); + return; + } + + toast.error(t.agents.agentCreatedPendingRefresh); + }); }, }); - // ── Handlers ─────────────────────────────────────────────────────────────── + useEffect(() => { + if (typeof window === "undefined" || step !== "chat") { + return; + } + if (window.localStorage.getItem(SAVE_HINT_STORAGE_KEY) === "1") { + return; + } + setShowSaveHint(true); + window.localStorage.setItem(SAVE_HINT_STORAGE_KEY, "1"); + }, [step]); const handleConfirmName = useCallback(async () => { const trimmed = nameInput.trim(); @@ -72,6 +124,7 @@ export default function NewAgentPage() { setNameError(t.agents.nameStepInvalidError); return; } + setNameError(""); setIsCheckingName(true); try { @@ -90,6 +143,7 @@ export default function NewAgentPage() { } finally { setIsCheckingName(false); } + setAgentName(trimmed); setStep("chat"); await sendMessage(threadId, { @@ -99,12 +153,12 @@ export default function NewAgentPage() { }, [ nameInput, sendMessage, - threadId, - t.agents.nameStepBootstrapMessage, - t.agents.nameStepInvalidError, t.agents.nameStepAlreadyExistsError, t.agents.nameStepNetworkError, + t.agents.nameStepBootstrapMessage, t.agents.nameStepCheckError, + t.agents.nameStepInvalidError, + threadId, ]); const handleNameKeyDown = (e: React.KeyboardEvent) => { @@ -124,26 +178,82 @@ export default function NewAgentPage() { { agent_name: agentName }, ); }, - [thread.isLoading, sendMessage, threadId, agentName], + [agentName, sendMessage, thread.isLoading, threadId], ); - // ── Shared header ────────────────────────────────────────────────────────── + const handleSaveAgent = useCallback(async () => { + if ( + !agentName || + agent || + thread.isLoading || + setupAgentStatus !== "idle" + ) { + return; + } + + setSetupAgentStatus("requested"); + setShowSaveHint(false); + try { + await sendMessage( + threadId, + { text: t.agents.saveCommandMessage, files: [] }, + { agent_name: agentName }, + { additionalKwargs: { hide_from_ui: true } }, + ); + toast.success(t.agents.saveRequested); + } catch (error) { + setSetupAgentStatus("idle"); + toast.error(error instanceof Error ? error.message : String(error)); + } + }, [ + agent, + agentName, + sendMessage, + setupAgentStatus, + t.agents.saveCommandMessage, + t.agents.saveRequested, + thread.isLoading, + threadId, + ]); const header = ( -
- -

{t.agents.createPageTitle}

+
+
+ +

{t.agents.createPageTitle}

+
+ + {step === "chat" ? ( + + + + + + void handleSaveAgent()} + disabled={ + !!agent || thread.isLoading || setupAgentStatus !== "idle" + } + > + + {setupAgentStatus === "requested" + ? t.agents.saving + : t.agents.save} + + + + ) : null}
); - // ── Step 1: name form ────────────────────────────────────────────────────── - if (step === "name") { return (
@@ -176,9 +286,9 @@ export default function NewAgentPage() { onKeyDown={handleNameKeyDown} className={cn(nameError && "border-destructive")} /> - {nameError && ( + {nameError ? (

{nameError}

- )} + ) : null}