diff --git a/apps/mesh/src/storage/threads.ts b/apps/mesh/src/storage/threads.ts index e11f00b886..b882bdc264 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 0b7a3837b2..518965e996 100644 --- a/apps/mesh/src/tools/thread/schema.ts +++ b/apps/mesh/src/tools/thread/schema.ts @@ -81,6 +81,13 @@ export const ThreadEntitySchema = z.object({ .describe( "Automation trigger that created this thread; null/absent for human-initiated threads.", ), + 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 8a6704daae..a0d3c70248 100644 --- a/apps/mesh/src/web/components/chat/task/types.ts +++ b/apps/mesh/src/web/components/chat/task/types.ts @@ -29,6 +29,8 @@ export interface Task { * to ask "is this an automation?". */ trigger_id?: string | null; + /** 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; /** Per-thread metadata — layout tabs, expanded tools, etc. Loaded by COLLECTION_THREADS_GET. */ 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..75bdbb6eb1 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(() => { @@ -46,7 +68,7 @@ export function TaskRow({ }); }, [isActive, task.id]); - return ( + const row = (
); + + if (!automationId) return row; + + return ( + + {row} + + + + Go to Automation + + + + ); }