diff --git a/apps/mesh/src/web/hooks/use-project-sidebar-items.tsx b/apps/mesh/src/web/hooks/use-project-sidebar-items.tsx
index 616dc65940..79112be4ad 100644
--- a/apps/mesh/src/web/hooks/use-project-sidebar-items.tsx
+++ b/apps/mesh/src/web/hooks/use-project-sidebar-items.tsx
@@ -4,7 +4,7 @@ import type {
SidebarSection,
} from "@/web/components/sidebar/types";
import { useNavigate, useRouterState } from "@tanstack/react-router";
-import { Folder, Home01 } from "@untitledui/icons";
+import { Columns03, Folder, Home01 } from "@untitledui/icons";
import {
SidebarGroup,
SidebarGroupContent,
@@ -32,6 +32,16 @@ export function useProjectSidebarItems(): SidebarSection[] {
},
};
+ const tasksItem: NavigationSidebarItem = {
+ key: "tasks",
+ label: "Tasks",
+ icon: ,
+ isActive: pathname.startsWith(`/${slug}/tasks`),
+ onClick: () => {
+ navigate({ to: "/$org/tasks", params: { org: slug } });
+ },
+ };
+
const filesItem: NavigationSidebarItem = {
key: "files",
label: "Library",
@@ -43,7 +53,7 @@ export function useProjectSidebarItems(): SidebarSection[] {
};
const sections: SidebarSection[] = [
- { type: "items", items: [homeItem, filesItem] },
+ { type: "items", items: [homeItem, tasksItem, filesItem] },
];
if (isCollapsed) {
diff --git a/apps/mesh/src/web/index.tsx b/apps/mesh/src/web/index.tsx
index abc8afe37a..f8c83cd41d 100644
--- a/apps/mesh/src/web/index.tsx
+++ b/apps/mesh/src/web/index.tsx
@@ -254,6 +254,15 @@ const libraryRoute = createRoute({
component: lazyRouteComponent(() => import("./layouts/library/index.tsx")),
});
+// Tasks board (/$org/tasks) — the org's tasks as a kanban or list.
+const tasksRoute = createRoute({
+ getParentRoute: () => orgShellLayout,
+ path: "/tasks",
+ component: lazyRouteComponent(
+ () => import("./layouts/tasks-board/index.tsx"),
+ ),
+});
+
// ============================================
// SETTINGS LAYOUT (/$org/settings)
// ============================================
@@ -615,6 +624,7 @@ const agentShellWithChildren = agentShellLayout.addChildren([
const orgShellWithChildren = orgShellLayout.addChildren([
orgIndexRoute,
libraryRoute,
+ tasksRoute,
agentShellWithChildren,
]);
diff --git a/apps/mesh/src/web/layouts/tasks-board/index.tsx b/apps/mesh/src/web/layouts/tasks-board/index.tsx
new file mode 100644
index 0000000000..f9633c79b1
--- /dev/null
+++ b/apps/mesh/src/web/layouts/tasks-board/index.tsx
@@ -0,0 +1,529 @@
+/**
+ * Tasks board (/$org/tasks) — the org's tasks (threads) as a kanban or list.
+ *
+ * Columns are the real thread statuses (in progress, needs review, done,
+ * failed) and dragging a card to another column persists the status via
+ * ThreadManager. Data comes from the same `useThreads()` store the sidebar
+ * uses, so rows stay live as runs progress. Filters narrow by agent and by
+ * origin (manual vs automation-triggered).
+ */
+
+import { useState } from "react";
+import { useNavigate, useParams } from "@tanstack/react-router";
+import { useProjectContext, useVirtualMCPs } from "@decocms/mesh-sdk";
+import { cn } from "@deco/ui/lib/utils.ts";
+import { Button } from "@deco/ui/components/button.tsx";
+import {
+ DropdownMenu,
+ DropdownMenuCheckboxItem,
+ DropdownMenuContent,
+ DropdownMenuTrigger,
+} from "@deco/ui/components/dropdown-menu.tsx";
+import {
+ Columns03,
+ CpuChip01,
+ List,
+ Loading01,
+ Plus,
+ X,
+ Zap,
+} from "@untitledui/icons";
+import { AgentAvatar } from "@/web/components/agent-icon";
+import { useThreads } from "@/web/components/chat/store/hooks";
+import type { Task } from "@/web/components/chat/task/types";
+import { useThreadActions } from "@/web/components/chat/store/hooks";
+import { formatTimeAgo } from "@/web/lib/format-time";
+import { getStatusConfig } from "@/web/lib/task-status";
+import { isSyntheticBranch } from "@/shared/is-synthetic-branch";
+import { NewTaskDialog } from "./new-task-dialog";
+
+type Lane = "in_progress" | "requires_action" | "completed" | "failed";
+
+const LANES: Lane[] = ["in_progress", "requires_action", "completed", "failed"];
+
+/** Buckets a thread's display status into one of the four columns.
+ * `expired` is a virtual read-time status for stale runs — it lives in the
+ * failed column but is never written back. */
+function laneFor(status: Task["status"]): Lane {
+ switch (status) {
+ case "in_progress":
+ return "in_progress";
+ case "requires_action":
+ return "requires_action";
+ case "failed":
+ case "expired":
+ return "failed";
+ case "completed":
+ case undefined:
+ return "completed";
+ default: {
+ const _exhaustive: never = status;
+ return _exhaustive;
+ }
+ }
+}
+
+type Layout = "kanban" | "list";
+
+/** Route component — the same rounded content card every routed page sits in
+ * (see Library): sidebar-colored gutter, then the card with the board. */
+export default function TasksBoard() {
+ return (
+
+ );
+}
+
+function TasksBoardPage() {
+ const navigate = useNavigate();
+ const { org } = useParams({ strict: false }) as { org?: string };
+ const { org: organization } = useProjectContext();
+ const { threads, status, hasMore, isFetchingMore, fetchNextPage } =
+ useThreads();
+ const actions = useThreadActions();
+ const agents = useVirtualMCPs();
+
+ const [layout, setLayout] = useState("kanban");
+ const [newTaskOpen, setNewTaskOpen] = useState(false);
+ const [agentSel, setAgentSel] = useState>(new Set());
+ const [originSel, setOriginSel] = useState>(new Set());
+
+ const visibleThreads = threads.filter((t) => !t.hidden);
+
+ // Agent options come from the agents actually present on the board, so the
+ // menu never lists agents with nothing to filter.
+ const agentById = new Map(agents.map((a) => [a.id, a]));
+ const agentOptions = [
+ ...new Set(
+ visibleThreads.flatMap((t) =>
+ t.virtual_mcp_id ? [t.virtual_mcp_id] : [],
+ ),
+ ),
+ ].map((id) => ({ key: id, label: agentById.get(id)?.title ?? "Decopilot" }));
+
+ const matchesFilters = (t: Task) =>
+ (agentSel.size === 0 ||
+ (!!t.virtual_mcp_id && agentSel.has(t.virtual_mcp_id))) &&
+ (originSel.size === 0 ||
+ originSel.has(t.trigger_id ? "automation" : "manual"));
+
+ const items = visibleThreads
+ .filter(matchesFilters)
+ .sort(
+ (a, b) =>
+ new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime(),
+ );
+
+ const anyFilter = agentSel.size + originSel.size > 0;
+ const toggleIn =
+ (set: Set, update: (next: Set) => void) =>
+ (key: string) => {
+ const next = new Set(set);
+ if (next.has(key)) next.delete(key);
+ else next.add(key);
+ update(next);
+ };
+
+ const open = (taskId: string, virtualMcpId?: string) => {
+ if (!org) return;
+ navigate({
+ to: "/$org/$taskId",
+ params: { org, taskId },
+ search: virtualMcpId ? { virtualmcpid: virtualMcpId } : {},
+ });
+ };
+
+ if (status.kind === "loading" && threads.length === 0) {
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+
+
Tasks
+
+
+
+
+
+
+ {anyFilter && (
+
+ )}
+
+
+ setLayout("list")}
+ icon={List}
+ label="List"
+ />
+ setLayout("kanban")}
+ icon={Columns03}
+ label="Board"
+ />
+
+
+
+ {items.length === 0 ? (
+
+ No tasks yet. Start one with New task.
+
+ ) : layout === "kanban" ? (
+
void actions.setStatus(id, lane)}
+ />
+ ) : (
+
+ {items.map((t) => (
+ open(t.id, t.virtual_mcp_id)}
+ />
+ ))}
+
+ )}
+
+ {hasMore && (
+
+ )}
+
+
+
setNewTaskOpen(false)}
+ orgName={organization.name}
+ orgLogo={organization.logo ?? null}
+ />
+
+ );
+}
+
+/** A Linear-style multi-select filter chip: a dropdown of checkbox options.
+ * Selecting keeps the menu open; the chip shows the active count. */
+function FilterMenu({
+ label,
+ icon: Icon,
+ options,
+ selected,
+ onToggle,
+}: {
+ label: string;
+ icon: typeof Zap;
+ options: { key: string; label: string }[];
+ selected: Set;
+ onToggle: (key: string) => void;
+}) {
+ if (options.length === 0) return null;
+ return (
+
+
+
+
+
+ {options.map((o) => (
+ onToggle(o.key)}
+ onSelect={(e) => e.preventDefault()}
+ >
+ {o.label}
+
+ ))}
+
+
+ );
+}
+
+function LayoutToggle({
+ active,
+ onClick,
+ icon: Icon,
+ label,
+}: {
+ active: boolean;
+ onClick: () => void;
+ icon: typeof List;
+ label: string;
+}) {
+ return (
+
+ );
+}
+
+/** The board: threads bucketed by status. Dragging a card to another column
+ * persists that status through the thread store (optimistic + server). */
+function KanbanBoard({
+ items,
+ agentById,
+ onOpen,
+ onMove,
+}: {
+ items: Task[];
+ agentById: Map;
+ onOpen: (taskId: string, virtualMcpId?: string) => void;
+ onMove: (id: string, lane: Lane) => void;
+}) {
+ const [overLane, setOverLane] = useState(null);
+ return (
+
+ {LANES.map((lane) => {
+ const laneItems = items.filter((t) => laneFor(t.status) === lane);
+ const config = getStatusConfig(lane);
+ const LaneIcon = config.icon;
+ return (
+
{
+ e.preventDefault();
+ setOverLane(lane);
+ }}
+ onDragLeave={(e) => {
+ if (!e.currentTarget.contains(e.relatedTarget as Node)) {
+ setOverLane(null);
+ }
+ }}
+ onDrop={(e) => {
+ e.preventDefault();
+ const id = e.dataTransfer.getData("text/plain");
+ if (id) onMove(id, lane);
+ setOverLane(null);
+ }}
+ className={cn(
+ "flex flex-col rounded-xl p-1 transition-colors",
+ overLane === lane && "bg-muted/50",
+ )}
+ >
+
+
+ {config.label}
+
+ {laneItems.length}
+
+
+
+ {laneItems.map((t) => (
+ onOpen(t.id, t.virtual_mcp_id)}
+ />
+ ))}
+
+
+ );
+ })}
+
+ );
+}
+
+function TaskMetaLine({ task }: { task: Task }) {
+ return (
+
+ {task.branch && !isSyntheticBranch(task.branch) && (
+ <>
+ {task.branch}
+ ·
+ >
+ )}
+
+ {formatTimeAgo(new Date(task.updated_at))}
+
+
+ );
+}
+
+function KanbanCard({
+ task,
+ agent,
+ onOpen,
+}: {
+ task: Task;
+ agent?: { title: string; icon?: string | null };
+ onOpen: () => void;
+}) {
+ return (
+
+ );
+}
+
+function ListRow({
+ task,
+ agent,
+ onOpen,
+}: {
+ task: Task;
+ agent?: { title: string; icon?: string | null };
+ onOpen: () => void;
+}) {
+ const config = getStatusConfig(task.status);
+ const StatusIcon = config.icon;
+ return (
+
+ );
+}
diff --git a/apps/mesh/src/web/layouts/tasks-board/new-task-dialog.tsx b/apps/mesh/src/web/layouts/tasks-board/new-task-dialog.tsx
new file mode 100644
index 0000000000..d8aca7223f
--- /dev/null
+++ b/apps/mesh/src/web/layouts/tasks-board/new-task-dialog.tsx
@@ -0,0 +1,207 @@
+/**
+ * New task — a prompt-first create dialog. The prompt is the task: it is
+ * handed to the new thread via the autosend channel (the same handoff the
+ * chat uses for follow-up tasks), so the agent starts working immediately.
+ * An agent picker chooses which vMCP runs it (default: Decopilot).
+ */
+
+import { useState } from "react";
+import { useNavigate, useParams } from "@tanstack/react-router";
+import {
+ getWellKnownDecopilotVirtualMCP,
+ useProjectContext,
+ useVirtualMCPs,
+} from "@decocms/mesh-sdk";
+import {
+ Dialog,
+ DialogContent,
+ DialogTitle,
+} from "@deco/ui/components/dialog.tsx";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from "@deco/ui/components/dropdown-menu.tsx";
+import { Button } from "@deco/ui/components/button.tsx";
+import { ChevronRight, Cube01, X } from "@untitledui/icons";
+import { AgentAvatar } from "@/web/components/agent-icon";
+import { useThreadActions } from "@/web/components/chat/store/hooks";
+import type { TiptapDoc } from "@/web/components/chat/types";
+import { AUTOSEND_QUERY_VALUE, writeStoredAutosend } from "@/web/lib/autosend";
+
+function promptDoc(text: string): TiptapDoc {
+ return {
+ type: "doc",
+ content: [{ type: "paragraph", content: [{ type: "text", text }] }],
+ };
+}
+
+export function NewTaskDialog({
+ open,
+ onClose,
+ orgName,
+ orgLogo,
+}: {
+ open: boolean;
+ onClose: () => void;
+ orgName: string;
+ orgLogo: string | null;
+}) {
+ const navigate = useNavigate();
+ const { org } = useParams({ strict: false }) as { org?: string };
+ const { org: organization, locator } = useProjectContext();
+ const actions = useThreadActions();
+ const agents = useVirtualMCPs();
+ const decopilot = getWellKnownDecopilotVirtualMCP(organization.id);
+
+ const [prompt, setPrompt] = useState("");
+ const [agentId, setAgentId] = useState(decopilot.id);
+ const [creating, setCreating] = useState(false);
+
+ const agentOptions = [
+ { id: decopilot.id, title: decopilot.title, icon: decopilot.icon },
+ ...agents.map((a) => ({ id: a.id, title: a.title, icon: a.icon })),
+ ];
+ const selectedAgent =
+ agentOptions.find((a) => a.id === agentId) ?? agentOptions[0]!;
+
+ const reset = () => {
+ setPrompt("");
+ setAgentId(decopilot.id);
+ setCreating(false);
+ };
+
+ const submit = async () => {
+ const text = prompt.trim();
+ if (!text || !org || creating) return;
+ setCreating(true);
+ const taskId = crypto.randomUUID();
+ // Stage the message first so the new task's chat finds it on mount even
+ // if navigation lands before the create round-trip settles.
+ writeStoredAutosend(sessionStorage, locator, taskId, {
+ tiptapDoc: promptDoc(text),
+ });
+ try {
+ await actions.create({ id: taskId, virtual_mcp_id: agentId });
+ } finally {
+ setCreating(false);
+ }
+ reset();
+ onClose();
+ navigate({
+ to: "/$org/$taskId",
+ params: { org, taskId },
+ search: { virtualmcpid: agentId, autosend: AUTOSEND_QUERY_VALUE },
+ });
+ };
+
+ return (
+
+ );
+}