diff --git a/apps/mesh/src/api/routes/decopilot/constants.ts b/apps/mesh/src/api/routes/decopilot/constants.ts index 8c4cb598ea..8c4e6528bc 100644 --- a/apps/mesh/src/api/routes/decopilot/constants.ts +++ b/apps/mesh/src/api/routes/decopilot/constants.ts @@ -54,3 +54,26 @@ Example output: Database Connection Setup Example input: "What tools are available?" Example output: Available Tools Overview`; + +export const DESCRIPTION_GENERATOR_PROMPT = `Your task: Summarize what the AI assistant did or said in 8-15 words. + +Rules: +- Output ONLY the summary, nothing else +- No quotes, no punctuation at the end +- No explanations, no "Summary:" prefix +- Use past tense for completed actions +- If the assistant asked the user a question, include the question topic +- Be specific and descriptive, not generic +- Just the raw summary text + +Example input: "I've created the database migration files and updated the schema to include the new columns you requested." +Example output: Created database migration files and updated schema with new columns + +Example input: "Here's a poem I wrote about autumn. What feeling or mood does this poem evoke for you?" +Example output: Wrote an autumn poem and asking what mood it evokes + +Example input: "I encountered an error: ECONNREFUSED when trying to connect to the Slack API." +Example output: Failed to connect to Slack API due to connection refused error + +Example input: "I've summarized 17 Slack threads and organized them into your Notion workspace." +Example output: Summarized 17 Slack threads into Notion workspace`; diff --git a/apps/mesh/src/api/routes/decopilot/description-generator.ts b/apps/mesh/src/api/routes/decopilot/description-generator.ts new file mode 100644 index 0000000000..eacb3f2f50 --- /dev/null +++ b/apps/mesh/src/api/routes/decopilot/description-generator.ts @@ -0,0 +1,67 @@ +/** + * Decopilot Description Generator + * + * Generates short thread descriptions in the background using LLM. + * Mirrors the title-generator pattern. + */ + +import type { LanguageModelV2 } from "@ai-sdk/provider"; +import { generateText } from "ai"; + +import { DESCRIPTION_GENERATOR_PROMPT } from "./constants"; + +const DESCRIPTION_TIMEOUT_MS = 3000; + +export async function generateDescriptionInBackground(config: { + model: LanguageModelV2; + assistantText: string; +}): Promise { + const { model, assistantText } = config; + + if (!assistantText || assistantText.trim().length < 10) { + return null; + } + + const abortController = new AbortController(); + + const timeoutId = setTimeout(() => { + abortController.abort(); + }, DESCRIPTION_TIMEOUT_MS); + + try { + const result = await generateText({ + model, + system: DESCRIPTION_GENERATOR_PROMPT, + messages: [{ role: "user", content: assistantText.slice(0, 2000) }], + maxOutputTokens: 80, + temperature: 0.2, + abortSignal: abortController.signal, + }); + + const rawDescription = result.text.trim(); + const firstLine = rawDescription.split("\n")[0] ?? rawDescription; + const description = firstLine + .replace(/^["']|["']$/g, "") + .replace(/^(Summary:|summary:|Description:|description:)\s*/i, "") + .replace(/[.!?]$/, "") + .slice(0, 100) + .trim(); + + return description || null; + } catch (error) { + const err = error as Error; + if (err.name === "AbortError") { + console.warn( + "[decopilot:description] Description generation aborted (timeout)", + ); + } else { + console.error( + "[decopilot:description] Failed to generate description:", + err.message, + ); + } + return null; + } finally { + clearTimeout(timeoutId); + } +} diff --git a/apps/mesh/src/api/routes/decopilot/routes.ts b/apps/mesh/src/api/routes/decopilot/routes.ts index 120d5cb5a2..abb69eb28a 100644 --- a/apps/mesh/src/api/routes/decopilot/routes.ts +++ b/apps/mesh/src/api/routes/decopilot/routes.ts @@ -39,6 +39,7 @@ import { } from "./model-permissions"; import { createModelProviderFromClient } from "./model-provider"; import { StreamRequestSchema } from "./schemas"; +import { generateDescriptionInBackground } from "./description-generator"; import { resolveThreadStatus } from "./status"; import { genTitle } from "./title-generator"; import type { ChatMessage } from "./types"; @@ -402,6 +403,36 @@ app.post("/:org/decopilot/stream", async (c) => { error, ); }); + + // Generate description from assistant response (fire-and-forget) + const assistantText = (responseMessage?.parts ?? []) + .filter( + (p): p is { type: "text"; text: string } => + "type" in p && p.type === "text" && "text" in p, + ) + .map((p) => p.text) + .join(" ") + .trim(); + + if (assistantText) { + generateDescriptionInBackground({ + model: modelProvider.fastModel ?? modelProvider.thinkingModel, + assistantText, + }) + .then(async (description) => { + if (description) { + await ctx.storage.threads.update(mem.thread.id, { + description, + }); + } + }) + .catch((error) => { + console.error( + "[decopilot:stream] Error generating description", + error, + ); + }); + } }, }), ); diff --git a/apps/mesh/src/mcp-clients/virtual-mcp/passthrough-client.ts b/apps/mesh/src/mcp-clients/virtual-mcp/passthrough-client.ts index 4320b1e23c..9b92d9df6e 100644 --- a/apps/mesh/src/mcp-clients/virtual-mcp/passthrough-client.ts +++ b/apps/mesh/src/mcp-clients/virtual-mcp/passthrough-client.ts @@ -79,8 +79,8 @@ function createLazyClient( function getRealClient(): Promise { if (!realClientPromise) { - realClientPromise = clientFromConnection(connection, ctx, superUser).then( - (client) => { + realClientPromise = clientFromConnection(connection, ctx, superUser) + .then((client) => { // Apply streaming support for HTTP connections so callStreamableTool // can stream responses via direct fetch instead of MCP transport if ( @@ -99,8 +99,13 @@ function createLazyClient( ); } return client; - }, - ); + }) + .catch((err) => { + // Clear the cached promise so the next call can retry instead of + // permanently returning the same rejected promise. + realClientPromise = null; + throw err; + }); } return realClientPromise; } diff --git a/apps/mesh/src/web/components/chat/side-panel-chat.tsx b/apps/mesh/src/web/components/chat/side-panel-chat.tsx index 38390defe4..56109b4673 100644 --- a/apps/mesh/src/web/components/chat/side-panel-chat.tsx +++ b/apps/mesh/src/web/components/chat/side-panel-chat.tsx @@ -6,7 +6,7 @@ import { getWellKnownDecopilotVirtualMCP, useProjectContext, } from "@decocms/mesh-sdk"; -import { ClockRewind, Plus, Users03, X } from "@untitledui/icons"; +import { CheckDone01, Plus, Users03, X } from "@untitledui/icons"; import { Suspense, useState, useTransition } from "react"; import { ErrorBoundary } from "../error-boundary"; import { Chat, useChat } from "./index"; @@ -22,7 +22,6 @@ function ChatPanelContent() { isChatEmpty, activeThreadId, createThread, - switchToThread, threads, } = useChat(); const activeThread = threads.find((thread) => thread.id === activeThreadId); @@ -115,9 +114,9 @@ function ChatPanelContent() { type="button" onClick={() => setShowThreadsOverlay(true)} className="flex size-6 items-center justify-center rounded-full p-1 hover:bg-transparent group cursor-pointer" - title="Chat history" + title="Tasks" > - @@ -169,7 +168,7 @@ function ChatPanelContent() { - {/* Threads view */} + {/* Tasks view */}
- setShowThreadsOverlay(false)} - /> + setShowThreadsOverlay(false)} />
); diff --git a/apps/mesh/src/web/components/chat/tasks-panel.tsx b/apps/mesh/src/web/components/chat/tasks-panel.tsx new file mode 100644 index 0000000000..2b998c2a0b --- /dev/null +++ b/apps/mesh/src/web/components/chat/tasks-panel.tsx @@ -0,0 +1,284 @@ +/** + * Tasks Panel & Task List Components + * + * Shared task list UI used in both: + * - Home page: persistent TasksPanel sidebar (left side) + * - Other pages: TaskListContent inside the chat panel overlay + * + * Design matches /tasks/ page exactly, just compact in width. + */ + +import { useChat } from "@/web/components/chat/index"; +import { CollectionSearch } from "@/web/components/collections/collection-search.tsx"; +import { User } from "@/web/components/user/user.tsx"; +import { useTaskData } from "@/web/hooks/use-task-data"; +import { formatTimeAgo } from "@/web/lib/format-time"; +import { + STATUS_ORDER, + STATUS_CONFIG, + groupByStatus, +} from "@/web/lib/task-status"; +import type { ThreadEntity } from "@/tools/thread/schema"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@deco/ui/components/tooltip.tsx"; +import { cn } from "@deco/ui/lib/utils.ts"; +import { ChevronRight, Loading01, Plus } from "@untitledui/icons"; +import { Suspense, useRef, useState } from "react"; +import { ErrorBoundary } from "../error-boundary"; + +// --- Truncated text with tooltip --- + +function TruncatedText({ + text, + className, +}: { + text: string; + className?: string; +}) { + const ref = useRef(null); + const [open, setOpen] = useState(false); + + return ( + { + if (isOpen && ref.current) { + setOpen(ref.current.scrollWidth > ref.current.clientWidth); + } else { + setOpen(false); + } + }} + > + + + {text} + + + + {text} + + + ); +} + +// --- Shared task list content --- + +interface TaskListContentProps { + /** Called when a task is selected (defaults to switchToThread from chat context) */ + onTaskSelect?: (taskId: string) => void; +} + +/** + * TaskListContent - The core task list with search + status-grouped tasks. + * Self-contained: fetches its own data, uses chat context for active thread. + * Used in both the home TasksPanel and the chat panel overlay. + * + * Design matches the /tasks/ page StatusGroup exactly, just without + * the description column and wide spacer (compact width). + */ +export function TaskListContent({ onTaskSelect }: TaskListContentProps) { + const { activeThreadId, switchToThread } = useChat(); + const { data } = useTaskData(); + + const [searchQuery, setSearchQuery] = useState(""); + const [collapsedGroups, setCollapsedGroups] = useState< + Record + >({}); + + const visible = data.filter((t) => !t.hidden); + + const searched = searchQuery.trim() + ? visible.filter((t) => + t.title.toLowerCase().includes(searchQuery.toLowerCase()), + ) + : visible; + + const groups = groupByStatus(searched); + const activeStatuses = STATUS_ORDER.filter( + (s) => groups[s] && groups[s].length > 0, + ); + + const toggleGroup = (status: string) => { + setCollapsedGroups((prev) => ({ ...prev, [status]: !prev[status] })); + }; + + const handleTaskClick = async (task: ThreadEntity) => { + if (onTaskSelect) { + onTaskSelect(task.id); + } else { + await switchToThread(task.id); + } + }; + + return ( + <> + { + if (e.key === "Escape") { + setSearchQuery(""); + (e.target as HTMLInputElement).blur(); + } + }} + /> + +
+ {searched.length === 0 ? ( +
+

+ {searchQuery ? "No tasks found" : "No tasks yet"} +

+
+ ) : ( + activeStatuses.map((status, idx) => { + const config = STATUS_CONFIG[status]; + if (!config) return null; + const Icon = config.icon; + const tasks = groups[status] ?? []; + const isOpen = !collapsedGroups[status]; + + return ( +
+ {/* Group header — same as /tasks/ page */} + + + {/* Task rows — same as /tasks/ page, minus description column */} + {isOpen && + tasks.map((task) => { + const isActive = task.id === activeThreadId; + return ( + + ); + })} +
+ ); + }) + )} +
+ + ); +} + +// --- Home page panel wrapper --- + +function TasksPanelContent() { + const { createThread, isChatEmpty } = useChat(); + + return ( +
+ {/* Header */} +
+ Tasks + +
+ + +
+ ); +} + +function TasksPanelSkeleton() { + return ( +
+
+ Tasks +
+
+ +
+
+ ); +} + +export function TasksPanel({ className }: { className?: string }) { + return ( +
+ ( +
+
+ Tasks +
+
+

+ Unable to load tasks +

+
+
+ )} + > + }> + + +
+
+ ); +} diff --git a/apps/mesh/src/web/components/chat/threads-sidebar.tsx b/apps/mesh/src/web/components/chat/threads-sidebar.tsx index 7ad3f5b6bd..7483e0091f 100644 --- a/apps/mesh/src/web/components/chat/threads-sidebar.tsx +++ b/apps/mesh/src/web/components/chat/threads-sidebar.tsx @@ -1,223 +1,70 @@ /** - * Threads Sidebar Component + * Threads View Component * - * A right-side sliding panel that displays chat thread history. + * Task-aware thread view for the side-panel chat overlay. + * Uses the shared TaskListContent for a unified tasks UI. */ -import { Input } from "@deco/ui/components/input.tsx"; -import { - Sheet, - SheetContent, - SheetHeader, - SheetTitle, -} from "@deco/ui/components/sheet.tsx"; -import { cn } from "@deco/ui/lib/utils.ts"; -import { MessageChatSquare, SearchMd } from "@untitledui/icons"; -import { useState } from "react"; -import type { Thread } from "./types.ts"; +import { Loading01 } from "@untitledui/icons"; +import { Suspense } from "react"; +import { ErrorBoundary } from "../error-boundary"; +import { useChat } from "./index"; +import { TaskListContent } from "./tasks-panel"; /** - * ThreadsViewContent Component + * ThreadsView Component * - * Core content component for displaying threads (header, search, list). - * Does not include any wrapper - meant to be used within different containers. + * A full-view of tasks for the lateral chat panel. + * Replaces the old flat thread list with the task-aware grouped UI. */ -interface ThreadsViewContentProps { - threads: Thread[]; - activeThreadId: string; - onThreadSelect: (threadId: string) => void; - onClose?: () => void; - showHeader?: boolean; - showBackButton?: boolean; +interface ThreadsViewProps { + onClose: () => void; } -function ThreadsViewContent({ - threads, - activeThreadId, - onThreadSelect, - onClose, - showHeader = true, - showBackButton = false, -}: ThreadsViewContentProps) { - const [searchQuery, setSearchQuery] = useState(""); - const filteredThreads = !searchQuery.trim() - ? threads - : threads.filter((thread) => - (thread.title || "New chat") - .toLowerCase() - .includes(searchQuery.toLowerCase()), - ); +export function ThreadsView({ onClose }: ThreadsViewProps) { + const { switchToThread } = useChat(); - const handleThreadSelect = (threadId: string) => { - onThreadSelect(threadId); - if (onClose) { - onClose(); - } + const handleTaskSelect = async (taskId: string) => { + await switchToThread(taskId); + onClose(); }; return ( - <> +
{/* Header */} - {showHeader && ( -
- Chat History - {showBackButton && ( - - )} -
- )} - -
-
- - setSearchQuery(e.target.value)} - className="pl-9 h-9 text-sm" - /> -
+
+ Tasks +
- {/* Threads list */} -
- {filteredThreads.length === 0 ? ( -
-
- -
-
-

- {searchQuery ? "No results found" : "No conversations yet"} -

-

- {searchQuery - ? "Try a different search term" - : "Start a new chat to see your history here"} -

-
-
- ) : ( -
- {filteredThreads.map((thread) => { - const isActive = thread.id === activeThreadId; - return ( - - ); - })} + ( +
+

+ Unable to load tasks +

)} -
- - ); -} - -interface ThreadsSidebarProps { - open: boolean; - onOpenChange: (open: boolean) => void; - threads: Thread[]; - activeThreadId: string; - onThreadSelect: (threadId: string) => void; -} - -export function ThreadsSidebar({ - open, - onOpenChange, - threads, - activeThreadId, - onThreadSelect, -}: ThreadsSidebarProps) { - return ( - - - - Chat History - - - - - - ); -} - -/** - * ThreadsView Component - * - * A full-view of threads for the lateral chat panel. - * Uses CSS visibility toggle instead of z-index overlay. - */ -interface ThreadsViewProps { - threads: Thread[]; - activeThreadId: string; - onThreadSelect: (threadId: string) => void; - onClose: () => void; -} - -export function ThreadsView({ - threads, - activeThreadId, - onThreadSelect, - onClose, -}: ThreadsViewProps) { - return ( -
- + + +
+ } + > + + +
); } diff --git a/apps/mesh/src/web/components/sidebar/items/layout.tsx b/apps/mesh/src/web/components/sidebar/items/layout.tsx index 4f0d652abb..288fea3e72 100644 --- a/apps/mesh/src/web/components/sidebar/items/layout.tsx +++ b/apps/mesh/src/web/components/sidebar/items/layout.tsx @@ -9,8 +9,11 @@ export function SidebarItemLayout({ children }: PropsWithChildren) { <> -
- Pinned Views +
+ + Pinned Views + +
{children} diff --git a/apps/mesh/src/web/components/sidebar/projects-section.tsx b/apps/mesh/src/web/components/sidebar/projects-section.tsx index 998d4b0911..d97fdb528f 100644 --- a/apps/mesh/src/web/components/sidebar/projects-section.tsx +++ b/apps/mesh/src/web/components/sidebar/projects-section.tsx @@ -9,6 +9,11 @@ import { SidebarSeparator, } from "@deco/ui/components/sidebar.tsx"; import { Skeleton } from "@deco/ui/components/skeleton.tsx"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@deco/ui/components/tooltip.tsx"; import { Collapsible, CollapsibleContent, @@ -32,14 +37,14 @@ function ProjectIcon({ ); } return (
@@ -69,7 +74,9 @@ function ProjectListItem({ }} tooltip={project.name} > - + + + {project.name} - {/* Section Header */} + {/* Section Header — text when expanded, dash when collapsed */} -
- +
+ {/* Expanded: label + chevron + add button */} +
+ + + - - +
+ {/* Collapsed: dash */} +
+ {/* Collapsed sidebar: show + button when no projects */} + {userProjects.length === 0 && ( + + + + setCreateDialogOpen(true)} + > + + Add project + + + Add project + + + )} + {/* Project List */} {userProjects.length === 0 ? ( - +
No projects yet
diff --git a/apps/mesh/src/web/components/sidebar/sidebar-group.tsx b/apps/mesh/src/web/components/sidebar/sidebar-group.tsx index 2ddf0171c0..8db560618d 100644 --- a/apps/mesh/src/web/components/sidebar/sidebar-group.tsx +++ b/apps/mesh/src/web/components/sidebar/sidebar-group.tsx @@ -30,13 +30,14 @@ export function SidebarCollapsibleGroup({ className="h-6 cursor-pointer select-none" onClick={() => setExpanded(!expanded)} > - + {label} +
diff --git a/apps/mesh/src/web/components/user/user.tsx b/apps/mesh/src/web/components/user/user.tsx index c078031352..949b0ff2ec 100644 --- a/apps/mesh/src/web/components/user/user.tsx +++ b/apps/mesh/src/web/components/user/user.tsx @@ -22,6 +22,10 @@ export interface UserProps { * Whether to show the email below the name (default: false) */ showEmail?: boolean; + /** + * Whether to show only the avatar without name/email (default: false) + */ + avatarOnly?: boolean; /** * Additional CSS classes */ @@ -37,6 +41,7 @@ export function User({ id, size = "xs", showEmail = false, + avatarOnly = false, className, }: UserProps) { const { data: user, isLoading, isError } = useUserById(id); @@ -46,12 +51,14 @@ export function User({ return (
-
-
- {showEmail && ( -
- )} -
+ {!avatarOnly && ( +
+
+ {showEmail && ( +
+ )} +
+ )}
); } @@ -61,9 +68,11 @@ export function User({ return (
-
-
Unknown User
-
+ {!avatarOnly && ( +
+
Unknown User
+
+ )}
); } @@ -77,12 +86,14 @@ export function User({ url={user.image ?? undefined} fallback={user.name} /> -
-
{user.name}
- {showEmail && ( -
{user.email}
- )} -
+ {!avatarOnly && ( +
+
{user.name}
+ {showEmail && ( +
{user.email}
+ )} +
+ )}
); } diff --git a/apps/mesh/src/web/hooks/use-task-data.ts b/apps/mesh/src/web/hooks/use-task-data.ts new file mode 100644 index 0000000000..a62fe25113 --- /dev/null +++ b/apps/mesh/src/web/hooks/use-task-data.ts @@ -0,0 +1,38 @@ +/** + * Shared hook for fetching task (thread) data. + * + * Used by both the /tasks/ page and the TaskListContent panel. + */ + +import { KEYS } from "@/web/lib/query-keys"; +import type { ThreadEntity } from "@/tools/thread/schema"; +import type { CollectionListOutput } from "@decocms/bindings/collections"; +import { + SELF_MCP_ALIAS_ID, + useMCPClient, + useProjectContext, +} from "@decocms/mesh-sdk"; +import { useSuspenseQuery } from "@tanstack/react-query"; + +export function useTaskData() { + const { org, locator } = useProjectContext(); + const client = useMCPClient({ + connectionId: SELF_MCP_ALIAS_ID, + orgId: org.id, + }); + + return useSuspenseQuery({ + queryKey: KEYS.taskThreads(locator), + queryFn: async () => { + if (!client) throw new Error("MCP client is not available"); + const result = (await client.callTool({ + name: "COLLECTION_THREADS_LIST", + arguments: { limit: 100, offset: 0 }, + })) as { structuredContent?: unknown }; + const payload = (result.structuredContent ?? + result) as CollectionListOutput; + return payload.items ?? []; + }, + staleTime: 30_000, + }); +} diff --git a/apps/mesh/src/web/layouts/shell-layout.tsx b/apps/mesh/src/web/layouts/shell-layout.tsx index eb199948a7..c2b99c03d8 100644 --- a/apps/mesh/src/web/layouts/shell-layout.tsx +++ b/apps/mesh/src/web/layouts/shell-layout.tsx @@ -35,6 +35,7 @@ import { import { useSuspenseQuery } from "@tanstack/react-query"; import { Outlet, useParams, useRouterState } from "@tanstack/react-router"; import { MessageChatSquare } from "@untitledui/icons"; +import { useSidebar } from "@deco/ui/components/sidebar.tsx"; import { PropsWithChildren, Suspense, useRef, useTransition } from "react"; import { KEYS } from "../lib/query-keys"; @@ -142,6 +143,39 @@ function PersistentSidebarProvider({ children }: PropsWithChildren) { ); } +/** + * Manages sidebar and chat panel state based on the current route. + * - Home: sidebar collapsed, chat disabled (tasks panel replaces it) + * - Other pages: sidebar expanded, chat panel open + */ +function RouteAwareSidebarManager({ isHomeRoute }: { isHomeRoute: boolean }) { + const { open, setOpen } = useSidebar(); + const [chatOpen, setChatOpen] = useDecoChatOpen(); + const prevIsHomeRef = useRef(null); + + // Render-phase state derivation (useEffect is banned — React 19 compiler + // handles memoization). Guards prevent no-op updates that would trigger + // unnecessary re-renders. + if (prevIsHomeRef.current === null) { + // First render — collapse sidebar on home + prevIsHomeRef.current = isHomeRoute; + if (isHomeRoute && open) { + setOpen(false); + } + } else if (prevIsHomeRef.current !== isHomeRoute) { + // Route changed + prevIsHomeRef.current = isHomeRoute; + if (isHomeRoute) { + if (open) setOpen(false); + } else { + if (!open) setOpen(true); + if (!chatOpen) setChatOpen(true); + } + } + + return null; +} + /** * This component renders the chat panel and the main content. * It's important to keep it like this to avoid unnecessary re-renders. @@ -267,6 +301,7 @@ function ShellLayoutContent() { } as Record } > + setCreateProjectDialogOpen(true)} /> diff --git a/apps/mesh/src/web/lib/task-status.ts b/apps/mesh/src/web/lib/task-status.ts new file mode 100644 index 0000000000..d6033472ca --- /dev/null +++ b/apps/mesh/src/web/lib/task-status.ts @@ -0,0 +1,69 @@ +/** + * Shared task status configuration. + * + * Used by both the /tasks/ page and the compact TaskListContent panel. + */ + +import type { ThreadEntity } from "@/tools/thread/schema"; +import { + CheckCircle, + Hourglass03, + Loading01, + Placeholder, + XCircle, +} from "@untitledui/icons"; + +export const STATUS_ORDER = [ + "in_progress", + "requires_action", + "failed", + "expired", + "completed", +] as const; + +export const STATUS_CONFIG: Record< + string, + { label: string; icon: typeof Loading01; iconClassName: string } +> = { + in_progress: { + label: "In Progress", + icon: Loading01, + iconClassName: "text-muted-foreground animate-spin", + }, + requires_action: { + label: "Need Action", + icon: Placeholder, + iconClassName: "text-orange-500", + }, + failed: { + label: "Failed", + icon: XCircle, + iconClassName: "text-destructive", + }, + expired: { + label: "Timed Out", + icon: Hourglass03, + iconClassName: "text-warning", + }, + completed: { + label: "Complete", + icon: CheckCircle, + iconClassName: "text-success", + }, +}; + +export function groupByStatus(tasks: ThreadEntity[]) { + const groups: Record = {}; + for (const task of tasks) { + const status = task.status ?? "completed"; + if (!groups[status]) groups[status] = []; + groups[status].push(task); + } + for (const group of Object.values(groups)) { + group.sort( + (a, b) => + new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime(), + ); + } + return groups; +} diff --git a/apps/mesh/src/web/routes/orgs/home/page.tsx b/apps/mesh/src/web/routes/orgs/home/page.tsx index 653ab4d97f..9dee0590d5 100644 --- a/apps/mesh/src/web/routes/orgs/home/page.tsx +++ b/apps/mesh/src/web/routes/orgs/home/page.tsx @@ -6,7 +6,7 @@ */ import { Chat, useChat } from "@/web/components/chat/index"; -import { ThreadsSidebar } from "@/web/components/chat/threads-sidebar.tsx"; +import { TasksPanel } from "@/web/components/chat/tasks-panel"; import { TypewriterTitle } from "@/web/components/chat/typewriter-title"; import { ErrorBoundary } from "@/web/components/error-boundary"; import { AgentsList } from "@/web/components/home/agents-list.tsx"; @@ -14,25 +14,12 @@ import { IntegrationIcon } from "@/web/components/integration-icon.tsx"; import { Page } from "@/web/components/page"; import { authClient } from "@/web/lib/auth-client"; import { Button } from "@deco/ui/components/button.tsx"; -import { - Tooltip, - TooltipContent, - TooltipTrigger, -} from "@deco/ui/components/tooltip.tsx"; import { getWellKnownDecopilotVirtualMCP, useProjectContext, } from "@decocms/mesh-sdk"; -import { - ClockRewind, - MessageChatSquare, - Pin01, - Plus, - Share07, - Users03, -} from "@untitledui/icons"; -import { Suspense, useState } from "react"; -import { toast } from "sonner"; +import { MessageChatSquare, Users03 } from "@untitledui/icons"; +import { Suspense } from "react"; /** * Get time-based greeting @@ -54,13 +41,10 @@ function HomeContent() { modelsConnections, isChatEmpty, activeThreadId, - createThread, - switchToThread, threads, selectedVirtualMcp, } = useChat(); const activeThread = threads.find((thread) => thread.id === activeThreadId); - const [isThreadsSidebarOpen, setIsThreadsSidebarOpen] = useState(false); const userName = session?.user?.name?.split(" ")[0] || "there"; const greeting = getTimeBasedGreeting(); @@ -72,157 +56,81 @@ function HomeContent() { // Show empty state when no LLM binding is found if (modelsConnections.length === 0) { return ( -
- +
+ +
+ +
); } return ( - - - - {activeThread?.title && ( - - )} - - - - - - - New chat - - - - - - Chat history - - - - - - Pin chat - - - - - - Share chat - - - - - {!isChatEmpty ? ( - <> - - - - - - - - ) : ( -
-
- {/* Agent Image */} -
- } - className="size-12 rounded-xl border border-stone-200/60 shadow-sm aspect-square transition-opacity duration-200" +
+ + + + + {activeThread?.title && ( + -
- - {/* Greeting */} -
-

- {greeting} {userName}, -

-

- What are we building today? -

-
- - {/* Chat Input */} -
+ )} + + + + + {!isChatEmpty ? ( + <> + + + + + + + ) : ( +
+
+ {/* Agent Image */} +
+ } + className="size-12 rounded-xl border border-stone-200/60 shadow-sm aspect-square transition-opacity duration-200" + /> +
+ + {/* Greeting */} +
+

+ {greeting} {userName}, +

+

+ What are we building today? +

+
+ + {/* Chat Input */} +
+ +
+ + {/* Ice breakers for selected agent */} +
- {/* Ice breakers for selected agent */} - -
- - {/* Agents List - Separate container to allow wider width */} -
- + {/* Agents List - Separate container to allow wider width */} +
+ +
-
- )} - - {/* Threads Sidebar */} - { - await switchToThread(threadId); - setIsThreadsSidebarOpen(false); - }} - /> - + )} + +
); } diff --git a/apps/mesh/src/web/routes/tasks.tsx b/apps/mesh/src/web/routes/tasks.tsx index 68d0d549ab..597b1b6c24 100644 --- a/apps/mesh/src/web/routes/tasks.tsx +++ b/apps/mesh/src/web/routes/tasks.tsx @@ -1,103 +1,127 @@ import { useChat } from "@/web/components/chat"; -import { CollectionDisplayButton } from "@/web/components/collections/collection-display-button.tsx"; import { CollectionSearch } from "@/web/components/collections/collection-search.tsx"; -import { CollectionTableWrapper } from "@/web/components/collections/collection-table-wrapper.tsx"; -import { type TableColumn } from "@/web/components/collections/collection-table.tsx"; import { EmptyState } from "@/web/components/empty-state.tsx"; import { ErrorBoundary } from "@/web/components/error-boundary"; import { Page } from "@/web/components/page"; import { User } from "@/web/components/user/user.tsx"; import { useListState } from "@/web/hooks/use-list-state"; +import { useTaskData } from "@/web/hooks/use-task-data"; import { formatTimeAgo } from "@/web/lib/format-time"; -import { KEYS } from "@/web/lib/query-keys"; -import type { ThreadEntity } from "@/tools/thread/schema"; -import type { CollectionListOutput } from "@decocms/bindings/collections"; import { - SELF_MCP_ALIAS_ID, - useMCPClient, - useProjectContext, -} from "@decocms/mesh-sdk"; + STATUS_ORDER, + STATUS_CONFIG, + groupByStatus, +} from "@/web/lib/task-status"; +import type { ThreadEntity } from "@/tools/thread/schema"; +import { useProjectContext } from "@decocms/mesh-sdk"; import { Breadcrumb, BreadcrumbItem, BreadcrumbList, BreadcrumbPage, } from "@deco/ui/components/breadcrumb.tsx"; -import { Badge } from "@deco/ui/components/badge.tsx"; -import { - CheckDone01, - Loading01, - Check, - AlertOctagon, - AlertCircle, - Clock, -} from "@untitledui/icons"; +import { CheckDone01, Loading01, ChevronRight } from "@untitledui/icons"; +import { cn } from "@deco/ui/lib/utils.ts"; import { useNavigate } from "@tanstack/react-router"; -import { useSuspenseQuery } from "@tanstack/react-query"; -import { Suspense } from "react"; +import { Suspense, useState } from "react"; + +// --- Components --- + +function StatusGroup({ + status, + tasks, + isOpen, + onToggle, + onRowClick, + isFirst, +}: { + status: string; + tasks: ThreadEntity[]; + isOpen: boolean; + onToggle: () => void; + onRowClick: (task: ThreadEntity) => void; + isFirst: boolean; +}) { + const config = STATUS_CONFIG[status]; + if (!config) return null; + const Icon = config.icon; + + return ( +
+ -function TaskStatusBadge({ status }: { status: string }) { - switch (status) { - case "in_progress": - return ( - - - Running - - ); - case "completed": - return ( - - - Completed - - ); - case "requires_action": - return ( - - - Waiting for input - - ); - case "failed": - return ( - - - Failed - - ); - case "expired": - return ( - - - Timed out - - ); - default: - return ( - - {status} - - ); - } + {isOpen && + tasks.map((task) => ( + + ))} +
+ ); } function TasksContent() { - const { org, project, locator } = useProjectContext(); - const client = useMCPClient({ - connectionId: SELF_MCP_ALIAS_ID, - orgId: org.id, - }); + const { org, project } = useProjectContext(); const navigate = useNavigate(); const { switchToThread } = useChat(); - // useListState and ThreadEntity both use snake_case for audit fields const listState = useListState({ namespace: org.slug, resource: "tasks", @@ -105,41 +129,24 @@ function TasksContent() { defaultViewMode: "table", }); - const { data } = useSuspenseQuery({ - queryKey: KEYS.taskThreads(locator), - queryFn: async () => { - if (!client) throw new Error("MCP client is not available"); - const result = (await client.callTool({ - name: "COLLECTION_THREADS_LIST", - arguments: { limit: 100, offset: 0 }, - })) as { structuredContent?: unknown }; - const payload = (result.structuredContent ?? - result) as CollectionListOutput; - return payload.items ?? []; - }, - staleTime: 30_000, - }); + const [collapsedGroups, setCollapsedGroups] = useState< + Record + >({}); + + const { data } = useTaskData(); - // 1. Filter hidden (defensive -- storage already filters WHERE hidden = false, - // but protects against optimistic cache updates from chat's hideThread) const visible = data.filter((t) => !t.hidden); - // 2. Filter by search const searched = listState.searchTerm ? visible.filter((t) => t.title.toLowerCase().includes(listState.searchTerm.toLowerCase()), ) : visible; - // 3. Sort by sortKey (ThreadEntity uses snake_case) - const threads = [...searched].sort((a, b) => { - const { sortKey, sortDirection } = listState; - if (!sortKey || !sortDirection) return 0; - const aVal = String((a as Record)[sortKey] ?? ""); - const bVal = String((b as Record)[sortKey] ?? ""); - const cmp = aVal.localeCompare(bVal); - return sortDirection === "asc" ? cmp : -cmp; - }); + const groups = groupByStatus(searched); + const activeStatuses = STATUS_ORDER.filter( + (s) => groups[s] && groups[s].length > 0, + ); const onRowClick = async (thread: ThreadEntity) => { await switchToThread(thread.id); @@ -149,45 +156,9 @@ function TasksContent() { }); }; - const columns: TableColumn[] = [ - { - id: "title", - header: "Title", - render: (thread) => ( - - {thread.title} - - ), - cellClassName: "flex-1 min-w-0", - sortable: true, - }, - { - id: "status", - header: "Status", - render: (thread) => ( - - ), - cellClassName: "w-40 shrink-0", - sortable: true, - }, - { - id: "created_by", - header: "Created by", - render: (thread) => , - cellClassName: "w-32 shrink-0", - }, - { - id: "updated_at", - header: "Updated", - render: (thread) => ( - - {thread.updated_at ? formatTimeAgo(new Date(thread.updated_at)) : "—"} - - ), - cellClassName: "max-w-24 w-24 shrink-0", - sortable: true, - }, - ]; + const toggleGroup = (status: string) => { + setCollapsedGroups((prev) => ({ ...prev, [status]: !prev[status] })); + }; return ( @@ -201,26 +172,12 @@ function TasksContent() { - - - { if (event.key === "Escape") { listState.setSearch(""); @@ -230,17 +187,10 @@ function TasksContent() { /> -
- + {searched.length === 0 ? ( +
+ {listState.search ? ( @@ -261,9 +211,21 @@ function TasksContent() { title="No tasks yet" description="Tasks will appear here when agents start processing work." /> - ) - } - /> + )} +
+ ) : ( + activeStatuses.map((status, idx) => ( + toggleGroup(status)} + onRowClick={onRowClick} + isFirst={idx === 0} + /> + )) + )}