Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 35 additions & 17 deletions apps/mesh/src/storage/threads.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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<string, unknown> | null;
Expand Down Expand Up @@ -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,
Expand Down
5 changes: 5 additions & 0 deletions apps/mesh/src/storage/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> | null;
Expand Down
1 change: 1 addition & 0 deletions apps/mesh/src/tools/thread/helpers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
7 changes: 7 additions & 0 deletions apps/mesh/src/tools/thread/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
2 changes: 2 additions & 0 deletions apps/mesh/src/web/components/chat/task/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
190 changes: 112 additions & 78 deletions apps/mesh/src/web/layouts/tasks-panel/task-row.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -31,6 +38,21 @@ export function TaskRow({
const virtualMcp = useVirtualMCP(task.virtual_mcp_id);
const githubRepo = getActiveGithubRepo(virtualMcp);
const rowRef = useRef<HTMLDivElement>(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(() => {
Expand All @@ -47,84 +69,96 @@ export function TaskRow({
}, [isActive, task.id]);

return (
<div
ref={rowRef}
role="button"
tabIndex={0}
onClick={onClick}
onKeyDown={(e) => {
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",
)}
>
<McpAvatar
virtualMcpId={task.virtual_mcp_id}
size="xs"
showAutomationBadge={showAutomationBadge}
/>
<div className="flex-1 min-w-0">
<div className="text-sm text-foreground truncate">
{task.title || "Untitled task"}
</div>
{task.updated_at && (
<div className="flex items-center gap-1 text-xs text-muted-foreground min-w-0">
{task.branch ? (
<>
<span className="truncate font-mono">{task.branch}</span>
<span className="shrink-0">·</span>
</>
) : githubRepo ? (
<>
<span className="truncate">
{githubRepo.owner}/{githubRepo.name}
</span>
<span className="shrink-0">·</span>
</>
) : null}
<span className="shrink-0">
{formatTimeAgo(new Date(task.updated_at))}
</span>
</div>
)}
</div>
<div className="shrink-0 grid [grid-template-areas:'slot'] items-center justify-items-center">
<span
className="[grid-area:slot] flex size-7 items-center justify-center group-hover/row:invisible"
aria-label={config.label}
<ContextMenu>
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Outdated
<ContextMenuTrigger asChild>
<div
ref={rowRef}
role="button"
tabIndex={0}
onClick={onClick}
onKeyDown={(e) => {
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",
)}
>
<StatusIcon
size={14}
className={cn(
config.iconClassName,
task.status === "in_progress" && "animate-spin",
)}
<McpAvatar
virtualMcpId={task.virtual_mcp_id}
size="xs"
showAutomationBadge={showAutomationBadge}
/>
</span>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
aria-label="Archive task"
onClick={(e) => {
e.stopPropagation();
onArchive();
}}
className="[grid-area:slot] invisible group-hover/row:visible flex size-7 items-center justify-center rounded-md text-muted-foreground hover:text-foreground hover:bg-muted"
<div className="flex-1 min-w-0">
<div className="text-sm text-foreground truncate">
{task.title || "Untitled task"}
</div>
{task.updated_at && (
<div className="flex items-center gap-1 text-xs text-muted-foreground min-w-0">
{task.branch ? (
<>
<span className="truncate font-mono">{task.branch}</span>
<span className="shrink-0">·</span>
</>
) : githubRepo ? (
<>
<span className="truncate">
{githubRepo.owner}/{githubRepo.name}
</span>
<span className="shrink-0">·</span>
</>
) : null}
<span className="shrink-0">
{formatTimeAgo(new Date(task.updated_at))}
</span>
</div>
)}
</div>
<div className="shrink-0 grid [grid-template-areas:'slot'] items-center justify-items-center">
<span
className="[grid-area:slot] flex size-7 items-center justify-center group-hover/row:invisible"
aria-label={config.label}
>
<Archive size={14} />
</button>
</TooltipTrigger>
<TooltipContent side="right">Archive</TooltipContent>
</Tooltip>
</div>
</div>
<StatusIcon
size={14}
className={cn(
config.iconClassName,
task.status === "in_progress" && "animate-spin",
)}
/>
</span>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
aria-label="Archive task"
onClick={(e) => {
e.stopPropagation();
onArchive();
}}
className="[grid-area:slot] invisible group-hover/row:visible flex size-7 items-center justify-center rounded-md text-muted-foreground hover:text-foreground hover:bg-muted"
>
<Archive size={14} />
</button>
</TooltipTrigger>
<TooltipContent side="right">Archive</TooltipContent>
</Tooltip>
</div>
</div>
</ContextMenuTrigger>
{automationId && (
<ContextMenuContent>
<ContextMenuItem onClick={handleGoToAutomation}>
<Zap size={14} />
Go to Automation
</ContextMenuItem>
</ContextMenuContent>
)}
</ContextMenu>
);
}
Loading