From 8be692824efa60c9bb1c0238ab68351a1f0e927e Mon Sep 17 00:00:00 2001 From: youngbeom Date: Tue, 14 Jul 2026 18:50:01 +0800 Subject: [PATCH 1/2] feat(web): autosave profile metadata --- .../src/hooks/workspace/useAgentController.ts | 62 +++++++++++++++++++ .../AgentDetailPane/AgentDetailPane.tsx | 19 +++++- .../HumanDetailPane/HumanDetailPane.tsx | 31 +++++----- 3 files changed, 94 insertions(+), 18 deletions(-) diff --git a/web/app/src/hooks/workspace/useAgentController.ts b/web/app/src/hooks/workspace/useAgentController.ts index 6e714aa1..a6f10402 100644 --- a/web/app/src/hooks/workspace/useAgentController.ts +++ b/web/app/src/hooks/workspace/useAgentController.ts @@ -1396,6 +1396,67 @@ export function useAgentController({ ); } + async function saveAgentPageMetadata(patch: Pick, "description" | "name">): Promise { + const agentID = String(selectedAgentForPage?.id ?? "").trim(); + if (!agentID || !agentPageDraft || agentPageBusy) { + return; + } + const payload: AgentUpdatePayload = {}; + const isManagerDraft = + isManagerAgent(selectedAgentForPage) || + agentPageDraft.agent_id === MANAGER_AGENT_ID || + agentPageDraft.role === MANAGER_AGENT_ROLE; + if (patch.name !== undefined && !isManagerDraft) { + const name = String(patch.name ?? ""); + if (!name.trim()) { + setAgentPageError(t("profileSaveIncompleteError")); + return; + } + if (name !== String(agentPageSavedDraft?.name ?? "")) { + payload.name = name; + } + } + if (patch.description !== undefined) { + const description = String(patch.description ?? ""); + if (description !== String(agentPageSavedDraft?.description ?? "")) { + payload.description = description; + } + } + if (!Object.keys(payload).length) { + return; + } + setAgentPageBusy(true); + setAgentPageError(""); + try { + const saved = await updateAgentRequest(agentID, payload); + setAgentsData((current) => mergeAgentIntoList(current, saved)); + setAgentPageDraft((current) => { + if (!current || String(current.agent_id ?? "").trim() !== agentID) { + return current; + } + return { + ...current, + ...(payload.name !== undefined ? { name: saved.name || "" } : {}), + ...(payload.description !== undefined ? { description: saved.description || "" } : {}), + }; + }); + setAgentPageSavedDraft((current) => { + if (!current || String(current.agent_id ?? "").trim() !== agentID) { + return current; + } + return { + ...current, + ...(payload.name !== undefined ? { name: saved.name || "" } : {}), + ...(payload.description !== undefined ? { description: saved.description || "" } : {}), + }; + }); + } catch (err) { + setAgentPageError(errorMessage(err, t("agentActionFailed"))); + } finally { + setAgentPageBusy(false); + } + } + async function saveAgentPage(): Promise { const draftToSave = agentPageDraft; if (!draftToSave || !selectedAgentForPage?.id) { @@ -2285,6 +2346,7 @@ export function useAgentController({ workspaceSupported: Boolean(selectedAgentForPage), onDraftChange: setAgentPageDraft, onSave: saveAgentPage, + onMetadataSave: saveAgentPageMetadata, onPublish: publishAgentPage, onProviderLogin: loginCLIProxyProvider, onStart: (item: AgentLike | null | undefined) => runAgentAction(item, "start"), diff --git a/web/app/src/pages/AgentPage/components/AgentDetailPane/AgentDetailPane.tsx b/web/app/src/pages/AgentPage/components/AgentDetailPane/AgentDetailPane.tsx index 818db9a5..f48facf4 100644 --- a/web/app/src/pages/AgentPage/components/AgentDetailPane/AgentDetailPane.tsx +++ b/web/app/src/pages/AgentPage/components/AgentDetailPane/AgentDetailPane.tsx @@ -83,6 +83,7 @@ import { AgentActivityPanel } from "./AgentActivityPanel"; type VoidOrPromise = void | Promise; type AgentActionHandler = (item: AgentLike) => VoidOrPromise; +type AgentMetadataSavePatch = Pick, "description" | "name">; type AgentNoticeTone = "info" | "warning" | "success"; const AGENT_PROFILE_TAB_IDS = ["profile", "activity", "channels", "instructions", "skills", "mcp"] as const; type AgentProfileTabID = (typeof AGENT_PROFILE_TAB_IDS)[number]; @@ -127,6 +128,7 @@ export type AgentDetailPaneProps = { onPublish?: () => VoidOrPromise; onRecreate: AgentActionHandler; onSave?: () => VoidOrPromise; + onMetadataSave?: (patch: AgentMetadataSavePatch) => VoidOrPromise; onStart: AgentActionHandler; onStartFeishuConnect?: AgentActionHandler; onStop: AgentActionHandler; @@ -215,6 +217,7 @@ export function AgentDetailPane({ onStart, onStop, onRecreate, + onMetadataSave, onStartFeishuConnect, onDisconnectFeishu, onUpgrade, @@ -256,6 +259,12 @@ export function AgentDetailPane({ hasUnsavedChangesProp ?? Boolean(draft && savedDraft && JSON.stringify(draft) !== JSON.stringify(savedDraft)); const saveDisabled = agentProfilePageSaveDisabled(draft, item, { saving, savedDraft }); const updateDraft = (patch: Partial) => onDraftChange?.({ ...(draft || agentToDraft(item)), ...patch }); + const saveMetadataPatch = (patch: AgentMetadataSavePatch) => { + if (!onMetadataSave || saving) { + return; + } + void onMetadataSave(patch); + }; const runtimeOptionSchemas = runtimeOptionSchemasForAgent(draft?.runtime_kind || runtimeKind, item); const fallbackProviderID = String(draft?.model_provider_id || "").trim(); const fallbackModelOptions = @@ -428,7 +437,10 @@ export function AgentDetailPane({ value={draft.name} required aria-required="true" - onBlur={() => setIsEditingName(false)} + onBlur={(event) => { + setIsEditingName(false); + saveMetadataPatch({ name: event.currentTarget.value }); + }} onInput={(event) => updateDraft({ name: event.currentTarget.value })} onKeyDown={(event) => { if (event.key === "Escape" || event.key === "Enter") { @@ -479,7 +491,10 @@ export function AgentDetailPane({ ref={descriptionInputRef} className="compact-textarea" value={draft.description} - onBlur={() => setIsEditingDescription(false)} + onBlur={(event) => { + setIsEditingDescription(false); + saveMetadataPatch({ description: event.currentTarget.value }); + }} onInput={(event) => updateDraft({ description: event.currentTarget.value })} onKeyDown={(event) => { if (event.key === "Escape") { diff --git a/web/app/src/pages/HumanPage/components/HumanDetailPane/HumanDetailPane.tsx b/web/app/src/pages/HumanPage/components/HumanDetailPane/HumanDetailPane.tsx index 4848acc8..44ef50ca 100644 --- a/web/app/src/pages/HumanPage/components/HumanDetailPane/HumanDetailPane.tsx +++ b/web/app/src/pages/HumanPage/components/HumanDetailPane/HumanDetailPane.tsx @@ -1,7 +1,6 @@ import { Check, CheckCircle2, Edit3, Link2 } from "lucide-react"; import { useEffect, useRef, useState } from "react"; import { AgentAvatarPicker } from "@/components/business/AgentAvatar"; -import { Button } from "@/components/ui"; import { localizeRole } from "@/shared/i18n"; import { feishuHumanParticipant } from "@/models/conversations"; import type { IMUser, LocaleCode, TranslateFn } from "@/models/conversations"; @@ -61,6 +60,11 @@ export function HumanDetailPane({ const online = user.is_online !== false; const currentDescription = String(user.description || ""); const descriptionChanged = descriptionDraft.trim() !== currentDescription.trim(); + const saveDescriptionIfChanged = (description: string) => { + if (!descriptionBusy && description.trim() !== currentDescription.trim()) { + void onDescriptionSave(description); + } + }; return (
@@ -91,7 +95,10 @@ export function HumanDetailPane({ value={descriptionDraft} rows={4} disabled={descriptionBusy} - onBlur={() => setIsEditingDescription(false)} + onBlur={(event) => { + setIsEditingDescription(false); + saveDescriptionIfChanged(event.currentTarget.value); + }} onChange={(event) => setDescriptionDraft(event.currentTarget.value)} onKeyDown={(event) => { if (event.key === "Escape") { @@ -141,24 +148,16 @@ export function HumanDetailPane({ ) : null}
- {descriptionChanged || descriptionBusy ? ( - - ) : ( + {descriptionBusy ? ( + + {t("agentSavingChanges")} + + ) : !descriptionChanged ? ( - )} + ) : null}
From 23c63dbc2db6ad83c04ab75197e5d1a41c21facb Mon Sep 17 00:00:00 2001 From: youngbeom Date: Tue, 14 Jul 2026 18:53:42 +0800 Subject: [PATCH 2/2] fix(web): cancel metadata autosave on escape --- .../AgentDetailPane/AgentDetailPane.tsx | 22 ++++++++++++++++++- .../HumanDetailPane/HumanDetailPane.tsx | 11 ++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/web/app/src/pages/AgentPage/components/AgentDetailPane/AgentDetailPane.tsx b/web/app/src/pages/AgentPage/components/AgentDetailPane/AgentDetailPane.tsx index f48facf4..ca857f18 100644 --- a/web/app/src/pages/AgentPage/components/AgentDetailPane/AgentDetailPane.tsx +++ b/web/app/src/pages/AgentPage/components/AgentDetailPane/AgentDetailPane.tsx @@ -243,6 +243,7 @@ export function AgentDetailPane({ const [mcpPendingDelete, setMCPPendingDelete] = useState(null); const descriptionInputRef = useRef(null); const nameInputRef = useRef(null); + const skipMetadataAutosaveRef = useRef(false); const isManager = isManagerAgent(item); const canEditAgentName = Boolean(draft && !isManager); const running = isAgentRunning(item); @@ -260,11 +261,25 @@ export function AgentDetailPane({ const saveDisabled = agentProfilePageSaveDisabled(draft, item, { saving, savedDraft }); const updateDraft = (patch: Partial) => onDraftChange?.({ ...(draft || agentToDraft(item)), ...patch }); const saveMetadataPatch = (patch: AgentMetadataSavePatch) => { + if (skipMetadataAutosaveRef.current) { + skipMetadataAutosaveRef.current = false; + return; + } if (!onMetadataSave || saving) { return; } void onMetadataSave(patch); }; + const cancelNameEdit = () => { + skipMetadataAutosaveRef.current = true; + updateDraft({ name: savedDraft?.name ?? item.name ?? "" }); + setIsEditingName(false); + }; + const cancelDescriptionEdit = () => { + skipMetadataAutosaveRef.current = true; + updateDraft({ description: savedDraft?.description ?? item.description ?? "" }); + setIsEditingDescription(false); + }; const runtimeOptionSchemas = runtimeOptionSchemasForAgent(draft?.runtime_kind || runtimeKind, item); const fallbackProviderID = String(draft?.model_provider_id || "").trim(); const fallbackModelOptions = @@ -443,7 +458,11 @@ export function AgentDetailPane({ }} onInput={(event) => updateDraft({ name: event.currentTarget.value })} onKeyDown={(event) => { - if (event.key === "Escape" || event.key === "Enter") { + if (event.key === "Escape") { + event.preventDefault(); + cancelNameEdit(); + event.currentTarget.blur(); + } else if (event.key === "Enter") { event.preventDefault(); event.currentTarget.blur(); } @@ -499,6 +518,7 @@ export function AgentDetailPane({ onKeyDown={(event) => { if (event.key === "Escape") { event.preventDefault(); + cancelDescriptionEdit(); event.currentTarget.blur(); } }} diff --git a/web/app/src/pages/HumanPage/components/HumanDetailPane/HumanDetailPane.tsx b/web/app/src/pages/HumanPage/components/HumanDetailPane/HumanDetailPane.tsx index 44ef50ca..038d1aae 100644 --- a/web/app/src/pages/HumanPage/components/HumanDetailPane/HumanDetailPane.tsx +++ b/web/app/src/pages/HumanPage/components/HumanDetailPane/HumanDetailPane.tsx @@ -32,6 +32,7 @@ export function HumanDetailPane({ const [descriptionDraft, setDescriptionDraft] = useState(""); const [isEditingDescription, setIsEditingDescription] = useState(false); const descriptionInputRef = useRef(null); + const skipDescriptionAutosaveRef = useRef(false); useEffect(() => { setDescriptionDraft(String(user?.description || "")); @@ -61,10 +62,19 @@ export function HumanDetailPane({ const currentDescription = String(user.description || ""); const descriptionChanged = descriptionDraft.trim() !== currentDescription.trim(); const saveDescriptionIfChanged = (description: string) => { + if (skipDescriptionAutosaveRef.current) { + skipDescriptionAutosaveRef.current = false; + return; + } if (!descriptionBusy && description.trim() !== currentDescription.trim()) { void onDescriptionSave(description); } }; + const cancelDescriptionEdit = () => { + skipDescriptionAutosaveRef.current = true; + setDescriptionDraft(currentDescription); + setIsEditingDescription(false); + }; return (
@@ -103,6 +113,7 @@ export function HumanDetailPane({ onKeyDown={(event) => { if (event.key === "Escape") { event.preventDefault(); + cancelDescriptionEdit(); event.currentTarget.blur(); } }}