From 0ae111d98a77d4a5604241a25559b4d4fe60159f Mon Sep 17 00:00:00 2001 From: viniciusventura29 Date: Fri, 15 May 2026 14:00:41 -0300 Subject: [PATCH 1/2] feat(tasks-panel): right-click task to open its Automation config Adds a "Go to Automation" context menu item on tasks triggered by an automation. Backend exposes automation_id via a leftJoin on automation_triggers so the frontend can navigate to the automation detail panel without an extra round-trip. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/mesh/src/storage/threads.ts | 52 +++-- apps/mesh/src/storage/types.ts | 5 + apps/mesh/src/tools/thread/helpers.test.ts | 1 + apps/mesh/src/tools/thread/schema.ts | 7 + .../src/web/components/chat/task/types.ts | 2 + .../src/web/layouts/tasks-panel/task-row.tsx | 190 +++++++++++------- 6 files changed, 162 insertions(+), 95 deletions(-) diff --git a/apps/mesh/src/storage/threads.ts b/apps/mesh/src/storage/threads.ts index 6b9e434f33..df59f880db 100644 --- a/apps/mesh/src/storage/threads.ts +++ b/apps/mesh/src/storage/threads.ts @@ -367,43 +367,53 @@ export class SqlThreadStorage implements ThreadStoragePort { const archived = options?.includeArchived === true; let query = this.db .selectFrom("threads") - .selectAll() - .where("organization_id", "=", organizationId) - .where("hidden", "=", archived) - .orderBy("updated_at", "desc"); + .leftJoin( + "automation_triggers", + "automation_triggers.id", + "threads.trigger_id", + ) + .selectAll("threads") + .select("automation_triggers.automation_id as automation_id") + .where("threads.organization_id", "=", organizationId) + .where("threads.hidden", "=", archived) + .orderBy("threads.updated_at", "desc"); if (createdBy) { - query = query.where("created_by", "=", createdBy); + query = query.where("threads.created_by", "=", createdBy); } const virtualMcpFilter = options?.virtualMcpId ?? options?.agentId; if (virtualMcpFilter) { - query = query.where("virtual_mcp_id", "=", virtualMcpFilter); + query = query.where("threads.virtual_mcp_id", "=", virtualMcpFilter); } if (options?.hasTrigger === true) { - query = query.where("trigger_id", "is not", null); + query = query.where("threads.trigger_id", "is not", null); } else if (options?.hasTrigger === false) { - query = query.where("trigger_id", "is", null); + query = query.where("threads.trigger_id", "is", null); } if (options?.startDate) { // updated_at is stored as ISO text — string comparison is correct for ISO dates query = query.where( - "updated_at", + "threads.updated_at", ">=", options.startDate as unknown as Date, ); } if (options?.endDate) { query = query.where( - "updated_at", + "threads.updated_at", "<=", options.endDate as unknown as Date, ); } if (options?.search) { - query = query.where("title", "ilike", `%${options.search}%`); + query = query.where("threads.title", "ilike", `%${options.search}%`); } if (options?.status) { - query = query.where("status", "=", options.status as ThreadStatus); + query = query.where( + "threads.status", + "=", + options.status as ThreadStatus, + ); } let countQuery = this.db @@ -477,11 +487,17 @@ export class SqlThreadStorage implements ThreadStoragePort { let query = this.db .selectFrom("threads") - .selectAll() - .where("organization_id", "=", organizationId) - .where("hidden", "=", false) - .where("trigger_id", "in", triggerIds) - .orderBy("updated_at", "desc"); + .leftJoin( + "automation_triggers", + "automation_triggers.id", + "threads.trigger_id", + ) + .selectAll("threads") + .select("automation_triggers.automation_id as automation_id") + .where("threads.organization_id", "=", organizationId) + .where("threads.hidden", "=", false) + .where("threads.trigger_id", "in", triggerIds) + .orderBy("threads.updated_at", "desc"); const countQuery = this.db .selectFrom("threads") @@ -838,6 +854,7 @@ export class SqlThreadStorage implements ThreadStoragePort { description: string | null; status: string; trigger_id?: string | null; + automation_id?: string | null; context_start_message_id?: string | null; run_owner_pod?: string | null; run_config?: Record | null; @@ -876,6 +893,7 @@ export class SqlThreadStorage implements ThreadStoragePort { description: row.description, status: row.status as ThreadStatus, trigger_id: row.trigger_id ?? null, + automation_id: row.automation_id ?? null, context_start_message_id: row.context_start_message_id ?? null, run_owner_pod: row.run_owner_pod ?? null, run_config: row.run_config ?? null, diff --git a/apps/mesh/src/storage/types.ts b/apps/mesh/src/storage/types.ts index 224515a53d..3bca603530 100644 --- a/apps/mesh/src/storage/types.ts +++ b/apps/mesh/src/storage/types.ts @@ -818,6 +818,11 @@ export interface Thread { hidden: boolean | null; status: ThreadStatus; trigger_id: string | null; + /** + * Derived via join with `automation_triggers.automation_id` when present. + * Null for threads without a trigger, or when the loading path doesn't join. + */ + automation_id: string | null; context_start_message_id: string | null; run_owner_pod: string | null; run_config: Record | null; diff --git a/apps/mesh/src/tools/thread/helpers.test.ts b/apps/mesh/src/tools/thread/helpers.test.ts index 8234bd2886..d0fb85e1f8 100644 --- a/apps/mesh/src/tools/thread/helpers.test.ts +++ b/apps/mesh/src/tools/thread/helpers.test.ts @@ -18,6 +18,7 @@ const BASE_THREAD: Thread = { hidden: null, status: "completed", trigger_id: null, + automation_id: null, context_start_message_id: null, run_owner_pod: null, run_config: null, diff --git a/apps/mesh/src/tools/thread/schema.ts b/apps/mesh/src/tools/thread/schema.ts index cf30f15172..65b072d877 100644 --- a/apps/mesh/src/tools/thread/schema.ts +++ b/apps/mesh/src/tools/thread/schema.ts @@ -74,6 +74,13 @@ export const ThreadEntitySchema = z.object({ .string() .optional() .describe("Virtual MCP (agent) this thread was initiated with"), + automation_id: z + .string() + .nullable() + .optional() + .describe( + "ID of the automation that triggered this thread, derived from automation_triggers via trigger_id. Null for manual threads.", + ), branch: z .string() .nullable() diff --git a/apps/mesh/src/web/components/chat/task/types.ts b/apps/mesh/src/web/components/chat/task/types.ts index 3f45d5b013..ea21f16cc3 100644 --- a/apps/mesh/src/web/components/chat/task/types.ts +++ b/apps/mesh/src/web/components/chat/task/types.ts @@ -24,6 +24,8 @@ export interface Task { virtual_mcp_id?: string; /** True when this task was triggered by an automation */ fromAutomation?: boolean; + /** ID of the automation that triggered this task, when triggered by an automation. */ + automation_id?: string | null; /** Git branch associated with this thread, when the vMCP is GitHub-linked. */ branch?: string | null; } diff --git a/apps/mesh/src/web/layouts/tasks-panel/task-row.tsx b/apps/mesh/src/web/layouts/tasks-panel/task-row.tsx index 9fc41e4cdd..df989487d7 100644 --- a/apps/mesh/src/web/layouts/tasks-panel/task-row.tsx +++ b/apps/mesh/src/web/layouts/tasks-panel/task-row.tsx @@ -1,12 +1,19 @@ import { cn } from "@deco/ui/lib/utils.js"; -import { Archive } from "@untitledui/icons"; +import { Archive, Zap } from "@untitledui/icons"; import { useEffect, useRef } from "react"; import { Tooltip, TooltipContent, TooltipTrigger, } from "@deco/ui/components/tooltip.tsx"; -import { useVirtualMCP } from "@decocms/mesh-sdk"; +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuTrigger, +} from "@deco/ui/components/context-menu.tsx"; +import { useVirtualMCP, useProjectContext } from "@decocms/mesh-sdk"; +import { useNavigate } from "@tanstack/react-router"; import { McpAvatar } from "./mcp-avatar"; import { getStatusConfig } from "@/web/lib/task-status"; import { formatTimeAgo } from "@/web/lib/format-time"; @@ -31,6 +38,21 @@ export function TaskRow({ const virtualMcp = useVirtualMCP(task.virtual_mcp_id); const githubRepo = getActiveGithubRepo(virtualMcp); const rowRef = useRef(null); + const navigate = useNavigate(); + const { org } = useProjectContext(); + const automationId = task.automation_id; + + const handleGoToAutomation = () => { + if (!automationId) return; + navigate({ + to: "/$org/$taskId", + params: { org: org.slug, taskId: task.id }, + search: { + virtualmcpid: task.virtual_mcp_id, + main: "automation:" + automationId, + }, + }); + }; // oxlint-disable-next-line ban-use-effect/ban-use-effect -- syncs route-selected task row with the scrollable tasks panel DOM useEffect(() => { @@ -47,84 +69,96 @@ export function TaskRow({ }, [isActive, task.id]); return ( -
{ - if (e.target !== e.currentTarget) return; - if (e.key === "Enter" || e.key === " ") { - e.preventDefault(); - onClick(); - } - }} - className={cn( - "group/row flex items-center gap-3 px-2 py-1.5 rounded-md cursor-pointer transition-colors", - "focus-visible:outline-none focus-visible:inset-ring-2 focus-visible:inset-ring-ring/50", - isActive ? "bg-accent" : "hover:bg-accent/60", - )} - > - -
-
- {task.title || "Untitled task"} -
- {task.updated_at && ( -
- {task.branch ? ( - <> - {task.branch} - · - - ) : githubRepo ? ( - <> - - {githubRepo.owner}/{githubRepo.name} - - · - - ) : null} - - {formatTimeAgo(new Date(task.updated_at))} - -
- )} -
-
- + +
{ + if (e.target !== e.currentTarget) return; + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + onClick(); + } + }} + className={cn( + "group/row flex items-center gap-3 px-2 py-1.5 rounded-md cursor-pointer transition-colors", + "focus-visible:outline-none focus-visible:inset-ring-2 focus-visible:inset-ring-ring/50", + isActive ? "bg-accent" : "hover:bg-accent/60", + )} > - - - - - - - Archive - -
-
+ + + + + + + Archive + +
+ + + {automationId && ( + + + + Go to Automation + + + )} + ); } From 5e014a7409badf94523ce96cc689ebb7d2ef7c13 Mon Sep 17 00:00:00 2001 From: viniciusventura29 Date: Sun, 17 May 2026 16:34:38 -0300 Subject: [PATCH 2/2] fix(tasks-panel): skip context-menu wrapper for manual tasks Manual tasks now render the row directly without ContextMenuTrigger so right-click falls through to the native browser context menu. Only automation-triggered tasks get the custom menu. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/web/layouts/tasks-panel/task-row.tsx | 178 +++++++++--------- 1 file changed, 90 insertions(+), 88 deletions(-) diff --git a/apps/mesh/src/web/layouts/tasks-panel/task-row.tsx b/apps/mesh/src/web/layouts/tasks-panel/task-row.tsx index df989487d7..75bdbb6eb1 100644 --- a/apps/mesh/src/web/layouts/tasks-panel/task-row.tsx +++ b/apps/mesh/src/web/layouts/tasks-panel/task-row.tsx @@ -68,97 +68,99 @@ export function TaskRow({ }); }, [isActive, task.id]); - return ( - - -
{ - if (e.target !== e.currentTarget) return; - if (e.key === "Enter" || e.key === " ") { - e.preventDefault(); - onClick(); - } - }} - className={cn( - "group/row flex items-center gap-3 px-2 py-1.5 rounded-md cursor-pointer transition-colors", - "focus-visible:outline-none focus-visible:inset-ring-2 focus-visible:inset-ring-ring/50", - isActive ? "bg-accent" : "hover:bg-accent/60", - )} - > - -
-
- {task.title || "Untitled task"} -
- {task.updated_at && ( -
- {task.branch ? ( - <> - {task.branch} - · - - ) : githubRepo ? ( - <> - - {githubRepo.owner}/{githubRepo.name} - - · - - ) : null} - - {formatTimeAgo(new Date(task.updated_at))} + const row = ( +
{ + if (e.target !== e.currentTarget) return; + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + onClick(); + } + }} + className={cn( + "group/row flex items-center gap-3 px-2 py-1.5 rounded-md cursor-pointer transition-colors", + "focus-visible:outline-none focus-visible:inset-ring-2 focus-visible:inset-ring-ring/50", + isActive ? "bg-accent" : "hover:bg-accent/60", + )} + > + +
+
+ {task.title || "Untitled task"} +
+ {task.updated_at && ( +
+ {task.branch ? ( + <> + {task.branch} + · + + ) : githubRepo ? ( + <> + + {githubRepo.owner}/{githubRepo.name} -
- )} -
-
- - + · + + ) : null} + + {formatTimeAgo(new Date(task.updated_at))} - - - - - Archive -
-
- - {automationId && ( - - - - Go to Automation - - - )} + )} +
+
+ + + + + + + + Archive + +
+
+ ); + + if (!automationId) return row; + + return ( + + {row} + + + + Go to Automation + + ); }