From 2510066d68a278ccaaecb34782a1a76636a68992 Mon Sep 17 00:00:00 2001 From: rafavalls Date: Fri, 12 Jun 2026 14:46:48 -0300 Subject: [PATCH 1/2] =?UTF-8?q?feat(web):=20tasks=20board=20=E2=80=94=20or?= =?UTF-8?q?g=20tasks=20as=20kanban/list=20with=20prompt-first=20create?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds /$org/tasks: the org's tasks (threads) on a board whose columns are the real thread statuses (in progress, needs review, done, failed). Dragging a card to another column persists the status through the thread store; data comes from the same useThreads() store the sidebar uses, so rows stay live as runs progress. Linear-style filters narrow by agent and by origin (manual vs automation-triggered), with a list layout toggle. New task is a prompt-first dialog: the prompt is handed to a freshly created thread via the autosend channel (same handoff the chat uses), so the chosen agent starts working immediately. Sidebar gets a Tasks entry between Home and Library. Co-Authored-By: Claude Fable 5 --- .../web/hooks/use-project-sidebar-items.tsx | 14 +- apps/mesh/src/web/index.tsx | 10 + .../src/web/layouts/tasks-board/index.tsx | 515 ++++++++++++++++++ .../layouts/tasks-board/new-task-dialog.tsx | 207 +++++++ 4 files changed, 744 insertions(+), 2 deletions(-) create mode 100644 apps/mesh/src/web/layouts/tasks-board/index.tsx create mode 100644 apps/mesh/src/web/layouts/tasks-board/new-task-dialog.tsx 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..4e9ee22a35 --- /dev/null +++ b/apps/mesh/src/web/layouts/tasks-board/index.tsx @@ -0,0 +1,515 @@ +/** + * 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"; + +export default function TasksBoard() { + 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 ( + { + if (!next) { + reset(); + onClose(); + } + }} + > + + New task + + {/* Header — org breadcrumb + close. */} +
+ + {orgLogo ? ( + + ) : ( + + )} + {orgName} + + + New task + +
+ + {/* Prompt — the task is described like a chat message. */} +
+