Compare commits

...

2 Commits

Author SHA1 Message Date
Jiayuan
42c77406f6 merge: resolve conflicts with main, adopt main's archive/restore implementation
Keep main's richer archive (archived_by field, task cancellation, restore
naming) and re-add delete agent functionality that was removed in main.
2026-04-03 15:29:00 +08:00
Jiayuan
b9bd54d6d5 feat(agents): add archive/unarchive support and filter archived agents from dropdowns
Archived agents are hidden from all assignee pickers, subscriber lists,
and filter panels so users only see active agents in selection dropdowns.
The agents management page shows archive/unarchive actions and visual
indicators for archived agents.
2026-04-03 15:14:27 +08:00
12 changed files with 117 additions and 18 deletions

View File

@@ -1376,17 +1376,20 @@ function AgentDetail({
onUpdate,
onArchive,
onRestore,
onDelete,
}: {
agent: Agent;
runtimes: RuntimeDevice[];
onUpdate: (id: string, data: Partial<Agent>) => Promise<void>;
onArchive: (id: string) => Promise<void>;
onRestore: (id: string) => Promise<void>;
onDelete: (id: string) => Promise<void>;
}) {
const st = statusConfig[agent.status];
const runtimeDevice = getRuntimeDevice(agent, runtimes);
const [activeTab, setActiveTab] = useState<DetailTab>("instructions");
const [confirmArchive, setConfirmArchive] = useState(false);
const [confirmDelete, setConfirmDelete] = useState(false);
const isArchived = !!agent.archived_at;
return (
@@ -1428,8 +1431,7 @@ function AgentDetail({
</span>
</div>
</div>
{!isArchived && (
<DropdownMenu>
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button variant="ghost" size="icon-sm" />
@@ -1438,16 +1440,21 @@ function AgentDetail({
<MoreHorizontal className="h-4 w-4 text-muted-foreground" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{!isArchived && (
<DropdownMenuItem onClick={() => setConfirmArchive(true)}>
<Archive className="h-3.5 w-3.5" />
Archive Agent
</DropdownMenuItem>
)}
<DropdownMenuItem
className="text-destructive"
onClick={() => setConfirmArchive(true)}
onClick={() => setConfirmDelete(true)}
>
<Trash2 className="h-3.5 w-3.5" />
Archive Agent
Delete Agent
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
{/* Tabs */}
@@ -1533,6 +1540,39 @@ function AgentDetail({
</DialogContent>
</Dialog>
)}
{/* Delete Confirmation */}
{confirmDelete && (
<Dialog open onOpenChange={(v) => { if (!v) setConfirmDelete(false); }}>
<DialogContent className="max-w-sm" showCloseButton={false}>
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-destructive/10">
<AlertCircle className="h-5 w-5 text-destructive" />
</div>
<DialogHeader className="flex-1 gap-1">
<DialogTitle className="text-sm font-semibold">Delete agent?</DialogTitle>
<DialogDescription className="text-xs">
This will permanently delete &quot;{agent.name}&quot; and all its configuration.
</DialogDescription>
</DialogHeader>
</div>
<DialogFooter>
<Button variant="ghost" onClick={() => setConfirmDelete(false)}>
Cancel
</Button>
<Button
variant="destructive"
onClick={() => {
setConfirmDelete(false);
onDelete(agent.id);
}}
>
Delete
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)}
</div>
);
}
@@ -1610,6 +1650,20 @@ export default function AgentsPage() {
}
};
const handleDelete = async (id: string) => {
try {
await api.deleteAgent(id);
if (selectedId === id) {
const remaining = agents.filter((a) => a.id !== id);
setSelectedId(remaining[0]?.id ?? "");
}
await refreshAgents();
toast.success("Agent deleted");
} catch (e) {
toast.error(e instanceof Error ? e.message : "Failed to delete agent");
}
};
const selected = agents.find((a) => a.id === selectedId) ?? null;
if (isLoading) {
@@ -1728,6 +1782,7 @@ export default function AgentsPage() {
onUpdate={handleUpdate}
onArchive={handleArchive}
onRestore={handleRestore}
onDelete={handleDelete}
/>
) : (
<div className="flex h-full flex-col items-center justify-center text-muted-foreground">

View File

@@ -513,7 +513,7 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
{issue.assignee_type === "member" && issue.assignee_id === m.user_id && <span className="ml-auto text-xs text-muted-foreground"></span>}
</DropdownMenuItem>
))}
{agents.filter((a) => canAssignAgent(a, user?.id, currentMemberRole)).map((a) => (
{agents.filter((a) => !a.archived_at && canAssignAgent(a, user?.id, currentMemberRole)).map((a) => (
<DropdownMenuItem
key={a.id}
onClick={() => handleUpdateField({ assignee_type: "agent", assignee_id: a.id })}
@@ -742,9 +742,9 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
})}
</CommandGroup>
)}
{agents.length > 0 && (
{agents.filter((a) => !a.archived_at).length > 0 && (
<CommandGroup heading="Agents">
{agents.map((a) => {
{agents.filter((a) => !a.archived_at).map((a) => {
const sub = subscribers.find((s) => s.user_type === "agent" && s.user_id === a.id);
const isSubbed = !!sub;
return (

View File

@@ -162,7 +162,7 @@ function ActorSubContent({
m.name.toLowerCase().includes(query),
);
const filteredAgents = agents.filter((a) =>
a.name.toLowerCase().includes(query),
!a.archived_at && a.name.toLowerCase().includes(query),
);
const isSelected = (type: "member" | "agent", id: string) =>

View File

@@ -99,7 +99,7 @@ export function CreateIssueModal({ onClose, data }: { onClose: () => void; data?
const assigneeQuery = assigneeFilter.toLowerCase();
const filteredMembers = members.filter((m) => m.name.toLowerCase().includes(assigneeQuery));
const filteredAgents = agents.filter((a) => a.name.toLowerCase().includes(assigneeQuery));
const filteredAgents = agents.filter((a) => !a.archived_at && a.name.toLowerCase().includes(assigneeQuery));
const assigneeLabel =
assigneeType && assigneeId

View File

@@ -325,6 +325,10 @@ export class ApiClient {
return this.fetch(`/api/agents/${id}/restore`, { method: "POST" });
}
async deleteAgent(id: string): Promise<void> {
await this.fetch(`/api/agents/${id}`, { method: "DELETE" });
}
async listRuntimes(params?: { workspace_id?: string }): Promise<AgentRuntime[]> {
const search = new URLSearchParams();
const wsId = params?.workspace_id ?? this.workspaceId;

View File

@@ -71,10 +71,10 @@ export interface Agent {
skills: Skill[];
tools: AgentTool[];
triggers: AgentTrigger[];
created_at: string;
updated_at: string;
archived_at: string | null;
archived_by: string | null;
created_at: string;
updated_at: string;
}
export interface CreateAgentRequest {

View File

@@ -60,10 +60,10 @@ export const mockAgents: Agent[] = [
skills: [],
tools: [],
triggers: [],
created_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-01T00:00:00Z",
archived_at: null,
archived_by: null,
created_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-01T00:00:00Z",
},
];

View File

@@ -197,6 +197,7 @@ func NewRouter(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus) chi.Route
r.Route("/{id}", func(r chi.Router) {
r.Get("/", h.GetAgent)
r.Put("/", h.UpdateAgent)
r.Delete("/", h.DeleteAgent)
r.Post("/archive", h.ArchiveAgent)
r.Post("/restore", h.RestoreAgent)
r.Get("/tasks", h.ListAgentTasks)

View File

@@ -30,10 +30,10 @@ type AgentResponse struct {
Skills []SkillResponse `json:"skills"`
Tools any `json:"tools"`
Triggers any `json:"triggers"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
ArchivedAt *string `json:"archived_at"`
ArchivedBy *string `json:"archived_by"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
func agentToResponse(a db.Agent) AgentResponse {
@@ -78,10 +78,10 @@ func agentToResponse(a db.Agent) AgentResponse {
Skills: []SkillResponse{},
Tools: tools,
Triggers: triggers,
CreatedAt: timestampToString(a.CreatedAt),
UpdatedAt: timestampToString(a.UpdatedAt),
ArchivedAt: timestampToPtr(a.ArchivedAt),
ArchivedBy: uuidToPtr(a.ArchivedBy),
CreatedAt: timestampToString(a.CreatedAt),
UpdatedAt: timestampToString(a.UpdatedAt),
}
}
@@ -493,6 +493,32 @@ func (h *Handler) RestoreAgent(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, resp)
}
func (h *Handler) DeleteAgent(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
agent, ok := h.loadAgentForUser(w, r, id)
if !ok {
return
}
wsID := uuidToString(agent.WorkspaceID)
if !h.canManageAgent(w, r, agent) {
return
}
err := h.Queries.DeleteAgent(r.Context(), parseUUID(id))
if err != nil {
slog.Warn("delete agent failed", append(logger.RequestAttrs(r), "error", err, "agent_id", id)...)
writeError(w, http.StatusInternalServerError, "failed to delete agent")
return
}
slog.Info("agent deleted", append(logger.RequestAttrs(r), "agent_id", id, "workspace_id", wsID)...)
userID := requestUserID(r)
actorType, actorID := h.resolveActor(r, userID, wsID)
h.publish(protocol.EventAgentDeleted, wsID, actorType, actorID, map[string]any{"agent_id": id, "workspace_id": wsID})
w.WriteHeader(http.StatusNoContent)
}
func (h *Handler) ListAgentTasks(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
if _, ok := h.loadAgentForUser(w, r, id); !ok {

View File

@@ -313,6 +313,15 @@ func (q *Queries) CreateAgentTask(ctx context.Context, arg CreateAgentTaskParams
return i, err
}
const deleteAgent = `-- name: DeleteAgent :exec
DELETE FROM agent WHERE id = $1
`
func (q *Queries) DeleteAgent(ctx context.Context, id pgtype.UUID) error {
_, err := q.db.Exec(ctx, deleteAgent, id)
return err
}
const failAgentTask = `-- name: FailAgentTask :one
UPDATE agent_task_queue
SET status = 'failed', completed_at = now(), error = $2

View File

@@ -52,6 +52,9 @@ UPDATE agent SET archived_at = NULL, archived_by = NULL, updated_at = now()
WHERE id = $1
RETURNING *;
-- name: DeleteAgent :exec
DELETE FROM agent WHERE id = $1;
-- name: ListAgentTasks :many
SELECT * FROM agent_task_queue
WHERE agent_id = $1

View File

@@ -19,6 +19,7 @@ const (
// Agent events
EventAgentStatus = "agent:status"
EventAgentCreated = "agent:created"
EventAgentDeleted = "agent:deleted"
EventAgentArchived = "agent:archived"
EventAgentRestored = "agent:restored"