diff --git a/web/app/src/hooks/workspace/useAgentController.ts b/web/app/src/hooks/workspace/useAgentController.ts index 6ce8d695..8c0deb29 100644 --- a/web/app/src/hooks/workspace/useAgentController.ts +++ b/web/app/src/hooks/workspace/useAgentController.ts @@ -103,7 +103,7 @@ import { skillDescriptionFromMarkdown, skillOptionsFromWorkspace } from "@/model import { useCLIProxyAuthStatuses } from "./useCLIProxyAuthStatuses"; import { workspaceQueryKeys } from "./workspaceQueries"; import type { MessageAction, MessageActionError, MessageLike } from "@/components/business/MessageContent/types"; -import type { IMConversation, IMUser, TranslateFn } from "@/models/conversations"; +import type { IMConversation, IMUser } from "@/models/conversations"; import type { UseAgentControllerArgs } from "./types"; type ManagerRebuildOptions = { @@ -1446,9 +1446,6 @@ export function useAgentController({ openManagerRebuildModal(item); return; } - if (action === "delete" && !window.confirm(agentDeleteConfirmationMessage(item, t))) { - return; - } setAgentActionBusy(`${item.id}:${action}`); clearAgentOperationError(item); try { @@ -2036,51 +2033,3 @@ function csgclawParticipantIDForAgent(item: AgentLike): string { ); return String(participant?.id || item.id || "").trim(); } - -function agentDeleteConfirmationMessage(item: AgentLike, t: TranslateFn): string { - const name = String(item.name || item.id || "").trim(); - const message = t("agentDeleteConfirmMessage", { name }); - const channels = agentDeleteBoundChannels(item); - if (channels.length === 0) { - return message; - } - return [ - message, - "", - t("agentDeleteBoundChannels", { channels: channels.join(", ") }), - "", - t("agentDeleteCascadeNote"), - ].join("\n"); -} - -function agentDeleteBoundChannels(item: AgentLike): string[] { - const agentID = String(item.id || "").trim(); - const channels = new Set(); - for (const participant of item.participants || []) { - const participantID = String(participant?.id || "").trim(); - if (!participantID) { - continue; - } - const participantAgentID = String(participant?.agent_id || "").trim(); - if (participantAgentID && agentID && participantAgentID !== agentID) { - continue; - } - const channel = String(participant?.channel || "") - .trim() - .toLowerCase(); - if (!channel || channel === "csgclaw") { - continue; - } - channels.add(agentDeleteChannelLabel(channel)); - } - return Array.from(channels).sort((left, right) => left.localeCompare(right)); -} - -function agentDeleteChannelLabel(channel: string): string { - if (channel === "feishu") { - return "Feishu"; - } - return channel.replace(/(^|[-_\s]+)(\w)/g, (_, separator: string, value: string) => { - return `${separator ? " " : ""}${value.toUpperCase()}`; - }); -} diff --git a/web/app/src/models/agents.ts b/web/app/src/models/agents.ts index eb1a49e8..4bf999d9 100644 --- a/web/app/src/models/agents.ts +++ b/web/app/src/models/agents.ts @@ -597,6 +597,24 @@ export function hasConnectedAgentChannel(item: AgentLike | null | undefined, cha return agentConnectedChannels(item).some((channel) => channel.id === channelID); } +type TranslateFnWithParams = (key: string, params?: Record) => string; + +export function agentDeleteConfirmationMessage(item: AgentLike | null | undefined, t: TranslateFnWithParams): string { + const name = String(item?.name || item?.id || "").trim(); + const message = t("agentDeleteConfirmMessage", { name }); + const channels = agentConnectedChannels(item).map((channel) => channel.name); + if (channels.length === 0) { + return message; + } + return [ + message, + "", + t("agentDeleteBoundChannels", { channels: channels.join(", ") }), + "", + t("agentDeleteCascadeNote"), + ].join("\n"); +} + export function normalizeNotifierDeliveryMode(mode: unknown): string { const value = String(mode || "") .trim() diff --git a/web/app/src/pages/AgentPage/components/AgentView/AgentView.css b/web/app/src/pages/AgentPage/components/AgentView/AgentView.css new file mode 100644 index 00000000..651a3ba0 --- /dev/null +++ b/web/app/src/pages/AgentPage/components/AgentView/AgentView.css @@ -0,0 +1,177 @@ +.csg-dialog-overlay.agent-delete-backdrop { + background: rgba(16, 24, 40, 0.72); +} + +.csg-dialog-content.agent-delete-dialog { + width: min(400px, calc(100vw - 32px)); + overflow: hidden; + border: 0; + border-radius: 12px; + background: var(--white); + color: var(--gray-900); + box-shadow: + 0 20px 24px -4px rgba(16, 24, 40, 0.08), + 0 8px 8px -4px rgba(16, 24, 40, 0.03); +} + +.agent-delete-header { + position: relative; + display: block; + padding: 24px 72px 0 24px; + border: 0; + background: transparent; +} + +.agent-delete-copy { + display: grid; + gap: 4px; + min-width: 0; +} + +.agent-delete-dialog .csg-dialog-title { + color: var(--gray-900); + font-size: 18px; + font-weight: 500; + line-height: 28px; +} + +.agent-delete-description.csg-dialog-description { + margin-top: 0; + color: var(--gray-600); + font-size: 14px; + line-height: 22px; + white-space: pre-line; +} + +.agent-delete-dialog .csg-dialog-close { + --btn-height: 44px; + --btn-min-width: 44px; + + position: absolute; + top: 16px; + right: 16px; + width: 44px; + min-width: 44px; + height: 44px; + min-height: 44px; + border-radius: 8px; + color: var(--gray-400); + box-shadow: none; +} + +.agent-delete-dialog .csg-dialog-close .icon-button-mark { + display: inline-flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + line-height: 0; +} + +.agent-delete-dialog .csg-dialog-close svg { + display: block; + flex: 0 0 auto; + width: 24px; + height: 24px; +} + +.agent-delete-actions { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; + padding: 32px 24px 24px; +} + +.agent-delete-actions .agent-delete-button { + width: 100%; + min-width: 0; + height: 44px; + min-height: 44px; + border-radius: 8px; + font-size: 16px; + line-height: 24px; +} + +.csg-dialog-content.agent-delete-dialog .agent-delete-actions .btn-secondary-gray { + border-color: var(--gray-300); + background: var(--white); + color: var(--gray-700); + box-shadow: var(--shadow-xs); +} + +.csg-dialog-content.agent-delete-dialog .agent-delete-actions .btn-secondary-gray:hover:not(:disabled), +.csg-dialog-content.agent-delete-dialog .agent-delete-actions .btn-secondary-gray:focus-visible { + border-color: var(--gray-300); + background: var(--gray-50); + color: var(--gray-800); +} + +.csg-dialog-content.agent-delete-dialog .agent-delete-actions .btn-danger { + border-color: var(--error-600); + background: var(--error-600); + color: var(--white); + box-shadow: var(--shadow-xs); +} + +.csg-dialog-content.agent-delete-dialog .agent-delete-actions .btn-danger:hover:not(:disabled), +.csg-dialog-content.agent-delete-dialog .agent-delete-actions .btn-danger:focus-visible { + border-color: var(--error-700); + background: var(--error-700); + color: var(--white); +} + +:root[data-theme="dark"] .csg-dialog-overlay.agent-delete-backdrop { + background: rgba(2, 6, 23, 0.72); +} + +:root[data-theme="dark"] .csg-dialog-content.agent-delete-dialog { + border-color: color-mix(in oklab, var(--line) 75%, transparent); + background: color-mix(in oklab, var(--panel) 94%, #020617 6%); + color: var(--text); + box-shadow: + 0 24px 36px -12px rgba(2, 6, 23, 0.6), + 0 10px 18px -10px rgba(2, 6, 23, 0.38); +} + +:root[data-theme="dark"] .agent-delete-dialog .csg-dialog-title { + color: var(--text); +} + +:root[data-theme="dark"] .agent-delete-description.csg-dialog-description { + color: var(--muted); +} + +:root[data-theme="dark"] .agent-delete-dialog .csg-dialog-close { + color: var(--muted); +} + +:root[data-theme="dark"] .agent-delete-dialog .csg-dialog-close:hover:not(:disabled), +:root[data-theme="dark"] .agent-delete-dialog .csg-dialog-close:focus-visible { + color: var(--text); +} + +:root[data-theme="dark"] .csg-dialog-content.agent-delete-dialog .agent-delete-actions .btn-secondary-gray { + border-color: color-mix(in oklab, var(--line) 80%, transparent); + background: color-mix(in oklab, var(--panel) 88%, #020617 12%); + color: var(--text); +} + +:root[data-theme="dark"] .csg-dialog-content.agent-delete-dialog .agent-delete-actions .btn-secondary-gray:hover:not(:disabled), +:root[data-theme="dark"] .csg-dialog-content.agent-delete-dialog .agent-delete-actions .btn-secondary-gray:focus-visible { + border-color: color-mix(in oklab, var(--line) 80%, transparent); + background: color-mix(in oklab, var(--panel) 80%, #020617 20%); + color: var(--text); +} + +:root[data-theme="dark"] .csg-dialog-content.agent-delete-dialog .agent-delete-actions .btn-danger { + border-color: var(--error-600); + background: color-mix(in oklab, var(--error-600) 88%, #020617 12%); + color: var(--white); +} + +:root[data-theme="dark"] .csg-dialog-content.agent-delete-dialog .agent-delete-actions .btn-danger:hover:not(:disabled), +:root[data-theme="dark"] .csg-dialog-content.agent-delete-dialog .agent-delete-actions .btn-danger:focus-visible { + border-color: var(--error-700); + background: color-mix(in oklab, var(--error-600) 78%, #020617 22%); + color: var(--white); +} diff --git a/web/app/src/pages/AgentPage/components/AgentView/AgentView.tsx b/web/app/src/pages/AgentPage/components/AgentView/AgentView.tsx index 59899bdc..f302f1b1 100644 --- a/web/app/src/pages/AgentPage/components/AgentView/AgentView.tsx +++ b/web/app/src/pages/AgentPage/components/AgentView/AgentView.tsx @@ -1,11 +1,83 @@ -import { isNotificationBotAgent } from "@/models/agents"; +import { useEffect, useState } from "react"; +import { + Button, + DialogCloseButton, + DialogContent, + DialogDescription, + DialogHeader, + DialogRoot, + DialogTitle, +} from "@/components/ui"; +import { isNotificationBotAgent, agentDeleteConfirmationMessage } from "@/models/agents"; +import type { AgentLike } from "@/models/agents"; import { AgentDetailPane } from "../AgentDetailPane"; import type { AgentDetailPaneProps } from "../AgentDetailPane"; import { NotificationParticipantDetailPane } from "../NotificationParticipantDetailPane"; export function AgentView(props: AgentDetailPaneProps) { - if (isNotificationBotAgent(props.item)) { - return ; + const [deletePendingAgent, setDeletePendingAgent] = useState(null); + const deleteConfirmMessage = deletePendingAgent ? agentDeleteConfirmationMessage(deletePendingAgent, props.t) : ""; + + useEffect(() => { + setDeletePendingAgent(null); + }, [props.item?.id]); + + function requestDelete(item: AgentLike) { + setDeletePendingAgent(item); + } + + async function confirmDelete() { + const item = deletePendingAgent; + if (!item) { + return; + } + setDeletePendingAgent(null); + await Promise.resolve(props.onDelete(item)); } - return ; + + const sharedProps = { + ...props, + onDelete: requestDelete, + }; + + return ( + <> + {isNotificationBotAgent(props.item) ? ( + + ) : ( + + )} + { + if (!open) { + setDeletePendingAgent(null); + } + }} + > + + +
+ {props.t("agentDelete")} + {deleteConfirmMessage} +
+ +
+
+ + +
+
+
+ + ); } diff --git a/web/app/src/pages/AgentPage/components/AgentView/index.ts b/web/app/src/pages/AgentPage/components/AgentView/index.ts index 0344b1aa..d9ef7218 100644 --- a/web/app/src/pages/AgentPage/components/AgentView/index.ts +++ b/web/app/src/pages/AgentPage/components/AgentView/index.ts @@ -1 +1,3 @@ +import "./AgentView.css"; + export * from "./AgentView"; diff --git a/web/app/tests/components/AgentActions.test.tsx b/web/app/tests/components/AgentActions.test.tsx index 59df3017..ff9c21c3 100644 --- a/web/app/tests/components/AgentActions.test.tsx +++ b/web/app/tests/components/AgentActions.test.tsx @@ -1,14 +1,18 @@ import { render, screen, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { useState } from "react"; -import { AgentDetailPane, AgentRow, NotificationParticipantDetailPane } from "@/pages/AgentPage/components"; +import { AgentDetailPane, AgentRow, AgentView, NotificationParticipantDetailPane } from "@/pages/AgentPage/components"; import { agentToDraft, type AgentDraft } from "@/models/agents"; const labels: Record = { agentActivityTab: "Activity", agentDelete: "Delete", + agentDeleteBoundChannels: "This agent is bound to {channels}.", + agentDeleteCascadeNote: "Deleting the agent will also disconnect it from those channels.", + agentDeleteConfirmMessage: 'Delete agent "{name}"?', agentInstructions: "Instructions", agentModel: "Model", + cancel: "Cancel", editDescription: "Edit description", editAgentName: "Edit name", agentRecreate: "Recreate", @@ -52,13 +56,14 @@ const labels: Record = { profileUpgradeRequired: "Upgrade required", profileRuntimeKind: "Runtime", profileRuntimeSection: "Runtime environment", + close: "Close", agentName: "Name", agentDescription: "Description", agentImage: "Image", }; -function t(key: string): string { - return labels[key] ?? key; +function t(key: string, params: Record = {}): string { + return (labels[key] ?? key).replace(/\{(\w+)\}/g, (_, name: string) => `${params[name] ?? ""}`); } const worker = { @@ -464,7 +469,7 @@ describe("agent action visibility", () => { }; const draft = agentToDraft(connectedWorker); render( - { expect(screen.queryByRole("status")).not.toBeInTheDocument(); }); + it("opens a styled delete confirmation dialog for a Feishu-connected agent", async () => { + const user = userEvent.setup(); + const onDelete = vi.fn(); + const connectedWorker = { + ...worker, + name: "Worker with Feishu", + bot_type: "notification", + type: "notification", + participants: [ + { + channel: "csgclaw", + id: "worker-1", + type: "agent", + }, + { + channel: "feishu", + channel_user_kind: "app_id", + id: "worker-1", + type: "agent", + }, + ], + }; + + render( + , + ); + + await user.click(screen.getByRole("button", { name: "Delete" })); + const dialog = await screen.findByRole("dialog"); + expect(dialog).toHaveTextContent('Delete agent "Worker with Feishu"?'); + expect(dialog).toHaveTextContent("This agent is bound to Feishu."); + expect(dialog).toHaveTextContent("Deleting the agent will also disconnect it from those channels."); + + await user.click(within(dialog).getByRole("button", { name: "Delete" })); + expect(onDelete).toHaveBeenCalledWith(expect.objectContaining({ id: connectedWorker.id })); + }); + it("shows advanced profile options from the advanced tab", async () => { const user = userEvent.setup(); const draft = agentToDraft(worker); diff --git a/web/app/tests/hooks/useAgentController.test.tsx b/web/app/tests/hooks/useAgentController.test.tsx index f1ea6ed2..6ab6f340 100644 --- a/web/app/tests/hooks/useAgentController.test.tsx +++ b/web/app/tests/hooks/useAgentController.test.tsx @@ -409,57 +409,7 @@ describe("useAgentController", () => { expect(fetchAgent).toHaveBeenLastCalledWith("u-manager", { cacheBust: true }); }); - it("shows channel-bound cleanup warning before deleting an agent", async () => { - const confirmSpy = vi.spyOn(window, "confirm").mockReturnValue(false); - const confirmationT: TranslateFn = (key, params = {}) => { - const values: Record = { - agentDeleteBoundChannels: "This agent is bound to {channels}.", - agentDeleteCascadeNote: "Deleting the agent will also disconnect it from those channels.", - agentDeleteConfirmMessage: 'Delete agent "{name}"?', - }; - return (values[key] ?? key).replace(/\{(\w+)\}/g, (_, name) => `${params[name] ?? ""}`); - }; - const channelBoundAgent: AgentLike = { - ...oldAgent, - participants: [ - { - agent_id: oldAgent.id, - channel: "csgclaw", - channel_user_ref: "user-manager", - id: "pt-manager", - type: "agent", - }, - { - agent_id: oldAgent.id, - channel: "feishu", - channel_user_kind: "app_id", - id: "pt-manager-feishu", - type: "agent", - }, - ], - }; - const { result } = renderHook( - () => useAgentControllerHarness({ agents: [channelBoundAgent], t: confirmationT }).controller, - { wrapper: createWrapper() }, - ); - - await act(async () => { - await result.current.agentViewProps.onDelete?.(channelBoundAgent); - }); - - expect(confirmSpy).toHaveBeenCalledTimes(1); - const message = String(confirmSpy.mock.calls[0]?.[0] || ""); - expect(message).toContain("bound to Feishu"); - expect(message).toContain("disconnect it from those channels"); - expect(message).not.toContain("participant"); - expect(message).not.toContain("pt-manager"); - expect(message).not.toContain("user-manager"); - expect(message).not.toContain("csgclaw"); - confirmSpy.mockRestore(); - }); - - it("deletes a normal agent through the agent delete endpoint after confirmation", async () => { - const confirmSpy = vi.spyOn(window, "confirm").mockReturnValue(true); + it("deletes an agent through the agent delete endpoint", async () => { const { result } = renderHook(() => useAgentControllerHarness().controller, { wrapper: createWrapper() }); await act(async () => { @@ -468,7 +418,6 @@ describe("useAgentController", () => { expect(deleteAgentRequest).toHaveBeenCalledWith("u-manager"); expect(deleteBotRequest).not.toHaveBeenCalled(); - confirmSpy.mockRestore(); }); it("refreshes the selected agent workspace after saving manager profile changes without renaming manager", async () => { diff --git a/web/app/tests/models/agents.test.ts b/web/app/tests/models/agents.test.ts index 1b7c0a2c..195b80c4 100644 --- a/web/app/tests/models/agents.test.ts +++ b/web/app/tests/models/agents.test.ts @@ -9,6 +9,7 @@ import { defaultManagerRebuildImageForRuntime, defaultWorkerImageForRuntime, agentDraftWithRuntimeFieldsFromAgent, + agentDeleteConfirmationMessage, agentRuntimePollSettled, agentStatusLabel, agentSandboxEnabled, @@ -315,6 +316,46 @@ describe("agent model helpers", () => { ).toBe(false); }); + it("describes agent delete confirmation with bound channels", () => { + const message = agentDeleteConfirmationMessage( + { + id: "u-dev", + name: "Demo Agent", + participants: [ + { + channel: "csgclaw", + id: "dev", + type: "agent", + }, + { + channel: "feishu", + channel_user_kind: "app_id", + id: "dev", + type: "agent", + }, + ], + }, + (key, params = {}) => { + const values: Record = { + agentDeleteBoundChannels: "This agent is bound to {channels}.", + agentDeleteCascadeNote: "Deleting the agent will also disconnect it from those channels.", + agentDeleteConfirmMessage: 'Delete agent "{name}"?', + }; + return (values[key] ?? key).replace(/\{(\w+)\}/g, (_, name) => `${params[name] ?? ""}`); + }, + ); + + expect(message).toBe( + [ + 'Delete agent "Demo Agent"?', + "", + "This agent is bound to Feishu.", + "", + "Deleting the agent will also disconnect it from those channels.", + ].join("\n"), + ); + }); + it("keeps JSON profile fields object-shaped", () => { expect(parseJSONMap("")).toEqual({}); expect(parseJSONMap('{"temperature":0.1}')).toEqual({ temperature: 0.1 });