diff --git a/apps/mesh/public/demo/vo/dz-01.m4a b/apps/mesh/public/demo/vo/dz-01.m4a new file mode 100644 index 0000000000..540490a46e Binary files /dev/null and b/apps/mesh/public/demo/vo/dz-01.m4a differ diff --git a/apps/mesh/public/demo/vo/dz-02.m4a b/apps/mesh/public/demo/vo/dz-02.m4a new file mode 100644 index 0000000000..3f025f011d Binary files /dev/null and b/apps/mesh/public/demo/vo/dz-02.m4a differ diff --git a/apps/mesh/public/demo/vo/dz-03.m4a b/apps/mesh/public/demo/vo/dz-03.m4a new file mode 100644 index 0000000000..2ad1bbdc9c Binary files /dev/null and b/apps/mesh/public/demo/vo/dz-03.m4a differ diff --git a/apps/mesh/public/demo/vo/dz-04.m4a b/apps/mesh/public/demo/vo/dz-04.m4a new file mode 100644 index 0000000000..e988dba0ce Binary files /dev/null and b/apps/mesh/public/demo/vo/dz-04.m4a differ diff --git a/apps/mesh/public/demo/vo/dz-05.m4a b/apps/mesh/public/demo/vo/dz-05.m4a new file mode 100644 index 0000000000..e169d5fee3 Binary files /dev/null and b/apps/mesh/public/demo/vo/dz-05.m4a differ diff --git a/apps/mesh/public/demo/vo/dz-06.m4a b/apps/mesh/public/demo/vo/dz-06.m4a new file mode 100644 index 0000000000..92abb79b6a Binary files /dev/null and b/apps/mesh/public/demo/vo/dz-06.m4a differ diff --git a/apps/mesh/public/demo/vo/dz-07.m4a b/apps/mesh/public/demo/vo/dz-07.m4a new file mode 100644 index 0000000000..3ba225a4db Binary files /dev/null and b/apps/mesh/public/demo/vo/dz-07.m4a differ diff --git a/apps/mesh/public/demo/vo/dz-08.m4a b/apps/mesh/public/demo/vo/dz-08.m4a new file mode 100644 index 0000000000..947e38a4f0 Binary files /dev/null and b/apps/mesh/public/demo/vo/dz-08.m4a differ diff --git a/apps/mesh/public/demo/vo/dz-09.m4a b/apps/mesh/public/demo/vo/dz-09.m4a new file mode 100644 index 0000000000..c1a38a68dc Binary files /dev/null and b/apps/mesh/public/demo/vo/dz-09.m4a differ diff --git a/apps/mesh/public/demo/vo/vo-01.m4a b/apps/mesh/public/demo/vo/vo-01.m4a new file mode 100644 index 0000000000..bfe5a96a38 Binary files /dev/null and b/apps/mesh/public/demo/vo/vo-01.m4a differ diff --git a/apps/mesh/public/demo/vo/vo-02.m4a b/apps/mesh/public/demo/vo/vo-02.m4a new file mode 100644 index 0000000000..fe3d74bb80 Binary files /dev/null and b/apps/mesh/public/demo/vo/vo-02.m4a differ diff --git a/apps/mesh/public/demo/vo/vo-03.m4a b/apps/mesh/public/demo/vo/vo-03.m4a new file mode 100644 index 0000000000..649cb7c253 Binary files /dev/null and b/apps/mesh/public/demo/vo/vo-03.m4a differ diff --git a/apps/mesh/public/demo/vo/vo-04.m4a b/apps/mesh/public/demo/vo/vo-04.m4a new file mode 100644 index 0000000000..662ee31f16 Binary files /dev/null and b/apps/mesh/public/demo/vo/vo-04.m4a differ diff --git a/apps/mesh/public/demo/vo/vo-05.m4a b/apps/mesh/public/demo/vo/vo-05.m4a new file mode 100644 index 0000000000..0d49d3a3bd Binary files /dev/null and b/apps/mesh/public/demo/vo/vo-05.m4a differ diff --git a/apps/mesh/public/demo/vo/vo-06.m4a b/apps/mesh/public/demo/vo/vo-06.m4a new file mode 100644 index 0000000000..e0f5e91267 Binary files /dev/null and b/apps/mesh/public/demo/vo/vo-06.m4a differ diff --git a/apps/mesh/public/demo/vo/vo-07.m4a b/apps/mesh/public/demo/vo/vo-07.m4a new file mode 100644 index 0000000000..e4c764f874 Binary files /dev/null and b/apps/mesh/public/demo/vo/vo-07.m4a differ diff --git a/apps/mesh/public/demo/vo/vo-08.m4a b/apps/mesh/public/demo/vo/vo-08.m4a new file mode 100644 index 0000000000..a50f11cc4c Binary files /dev/null and b/apps/mesh/public/demo/vo/vo-08.m4a differ diff --git a/apps/mesh/public/demo/vo/vo-09.m4a b/apps/mesh/public/demo/vo/vo-09.m4a new file mode 100644 index 0000000000..83cc287f60 Binary files /dev/null and b/apps/mesh/public/demo/vo/vo-09.m4a differ diff --git a/apps/mesh/public/demo/vo/vo-10.m4a b/apps/mesh/public/demo/vo/vo-10.m4a new file mode 100644 index 0000000000..917a2e13fc Binary files /dev/null and b/apps/mesh/public/demo/vo/vo-10.m4a differ diff --git a/apps/mesh/public/demo/vo/vo-11.m4a b/apps/mesh/public/demo/vo/vo-11.m4a new file mode 100644 index 0000000000..8e3422b672 Binary files /dev/null and b/apps/mesh/public/demo/vo/vo-11.m4a differ diff --git a/apps/mesh/public/demo/vo/vo-12.m4a b/apps/mesh/public/demo/vo/vo-12.m4a new file mode 100644 index 0000000000..d0614b5331 Binary files /dev/null and b/apps/mesh/public/demo/vo/vo-12.m4a differ diff --git a/apps/mesh/public/demo/vo/vo-13.m4a b/apps/mesh/public/demo/vo/vo-13.m4a new file mode 100644 index 0000000000..9b7dd486c8 Binary files /dev/null and b/apps/mesh/public/demo/vo/vo-13.m4a differ diff --git a/apps/mesh/public/demo/vo/vo-14.m4a b/apps/mesh/public/demo/vo/vo-14.m4a new file mode 100644 index 0000000000..fe00bc5539 Binary files /dev/null and b/apps/mesh/public/demo/vo/vo-14.m4a differ diff --git a/apps/mesh/public/demo/vo/vo-15.m4a b/apps/mesh/public/demo/vo/vo-15.m4a new file mode 100644 index 0000000000..bd6590ab5d Binary files /dev/null and b/apps/mesh/public/demo/vo/vo-15.m4a differ diff --git a/apps/mesh/public/demo/vo/vo-16.m4a b/apps/mesh/public/demo/vo/vo-16.m4a new file mode 100644 index 0000000000..18f96311ab Binary files /dev/null and b/apps/mesh/public/demo/vo/vo-16.m4a differ diff --git a/apps/mesh/public/demo/vo/vo-17.m4a b/apps/mesh/public/demo/vo/vo-17.m4a new file mode 100644 index 0000000000..7e232b091c Binary files /dev/null and b/apps/mesh/public/demo/vo/vo-17.m4a differ diff --git a/apps/mesh/public/demo/vo/vo-18.m4a b/apps/mesh/public/demo/vo/vo-18.m4a new file mode 100644 index 0000000000..b9b6813f71 Binary files /dev/null and b/apps/mesh/public/demo/vo/vo-18.m4a differ diff --git a/apps/mesh/src/web/components/chat/chat-context.tsx b/apps/mesh/src/web/components/chat/chat-context.tsx index dff6d91f20..57067ca276 100644 --- a/apps/mesh/src/web/components/chat/chat-context.tsx +++ b/apps/mesh/src/web/components/chat/chat-context.tsx @@ -328,6 +328,13 @@ interface TaskProviderInternals { // ============================================================================ const ChatStreamCtx = createContext(null); +/** + * Exposed so Demo Mode (`web/demo/`) can supply a scripted `ChatStreamContextValue` + * directly — feeding the real chat renderers a recorded stream without the live + * `ActiveTaskProvider` (MCP transport, SSE, etc.). Not for app use; prefer the + * hooks. See `web/demo/demo-chat-stream.tsx`. + */ +export { ChatStreamCtx as DemoChatStreamContext }; const ChatTaskCtx = createContext(null); const ChatPrefsCtx = createContext(null); diff --git a/apps/mesh/src/web/components/chat/message/assistant.tsx b/apps/mesh/src/web/components/chat/message/assistant.tsx index 9e21359a2e..2d55b06ee3 100644 --- a/apps/mesh/src/web/components/chat/message/assistant.tsx +++ b/apps/mesh/src/web/components/chat/message/assistant.tsx @@ -10,6 +10,7 @@ import { import type { ToolUIPart } from "ai"; import { type ReactNode, Suspense, useEffect, useState } from "react"; import { ToolCallShell } from "./parts/tool-call-part/common.tsx"; +import { getPartRenderer as getExtraPartRenderer } from "./parts/extra-part-renderers.ts"; import type { ChatMessage } from "../types.ts"; import { MessageStatsBar } from "../usage-stats.tsx"; import { MessageTextPart } from "./parts/text-part.tsx"; @@ -518,6 +519,11 @@ function MessagePart({ const getSubtaskMeta = (toolCallId: string) => dataParts.toolSubtaskMetadata.get(toolCallId); + // Custom part renderers (e.g. Demo Mode) take precedence over the built-in + // switch. Empty registry in normal use — see extra-part-renderers.ts. + const extraRenderer = getExtraPartRenderer(part.type); + if (extraRenderer) return <>{extraRenderer(part as { type: string })}; + switch (part.type) { case "dynamic-tool": return ( diff --git a/apps/mesh/src/web/components/chat/message/parts/extra-part-renderers.ts b/apps/mesh/src/web/components/chat/message/parts/extra-part-renderers.ts new file mode 100644 index 0000000000..986387cc2f --- /dev/null +++ b/apps/mesh/src/web/components/chat/message/parts/extra-part-renderers.ts @@ -0,0 +1,21 @@ +/** + * Tiny extension point for rendering custom message parts inline in the chat. + * + * The core renderer (`message/assistant.tsx`) consults this registry before its + * built-in switch, so non-core surfaces (e.g. Demo Mode) can render their own + * part types in the real chat flow WITHOUT the core importing them. The registry + * is empty in normal use; a consumer registers a renderer at module load. + */ +import type { ReactNode } from "react"; + +type PartRenderer = (part: { type: string; output?: unknown }) => ReactNode; + +const registry = new Map(); + +export function registerPartRenderer(type: string, render: PartRenderer): void { + registry.set(type, render); +} + +export function getPartRenderer(type: string): PartRenderer | undefined { + return registry.get(type); +} diff --git a/apps/mesh/src/web/components/sidebar/task-groups/agent-row.tsx b/apps/mesh/src/web/components/sidebar/task-groups/agent-row.tsx new file mode 100644 index 0000000000..7419109ed5 --- /dev/null +++ b/apps/mesh/src/web/components/sidebar/task-groups/agent-row.tsx @@ -0,0 +1,116 @@ +import { Plus } from "@untitledui/icons"; +import type { DraggableAttributes } from "@dnd-kit/core"; +import type { SyntheticListenerMap } from "@dnd-kit/core/dist/hooks/utilities"; +import { useVirtualMCP } from "@decocms/mesh-sdk"; +import { cn } from "@deco/ui/lib/utils.ts"; +import { + ContextMenu, + ContextMenuContent, + ContextMenuTrigger, +} from "@deco/ui/components/context-menu.tsx"; +import { AgentAvatar } from "@/web/components/agent-icon"; +import { AgentGroupContextMenuItems } from "./agent-group-context-menu-items"; + +export interface AgentRowProps { + virtualMcpId: string; + /** Highlighted when the active thread belongs to this agent. */ + isActive: boolean; + /** The current user's thread count for this agent (shown at rest). */ + threadCount?: number; + /** Click the row → open the agent (its most recent thread, or a new one). */ + onOpen: (virtualMcpId: string) => void; + /** The hover "+" and context-menu "New task" → start a fresh thread. */ + onNewTask: (virtualMcpId: string) => void; + onShowSettings: (virtualMcpId: string) => void; + onHideGroup: (virtualMcpId: string) => void; + isOrgPinned?: boolean; + canManageOrgPin?: boolean; + onToggleOrgPin?: (virtualMcpId: string, pinned: boolean) => void; + /** When set, the row is draggable for reordering. */ + sortable?: { + attributes: DraggableAttributes; + listeners: SyntheticListenerMap | undefined; + isDragging: boolean; + }; +} + +export function AgentRow({ + virtualMcpId, + isActive, + threadCount, + onOpen, + onNewTask, + onShowSettings, + onHideGroup, + isOrgPinned = false, + canManageOrgPin = false, + onToggleOrgPin, + sortable, +}: AgentRowProps) { + const entity = useVirtualMCP(virtualMcpId); + const isDragging = sortable?.isDragging ?? false; + const title = entity?.title ?? "Agent"; + + const row = ( +
onOpen(virtualMcpId)} + onKeyDown={(e) => { + if (e.target !== e.currentTarget) return; + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + onOpen(virtualMcpId); + } + }} + className={cn( + "group/group flex items-center gap-2 px-2 py-1.5 rounded-md text-sm font-medium transition-colors", + "cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50", + sortable && "touch-none active:cursor-grabbing", + isActive + ? "bg-accent text-accent-foreground" + : "text-foreground hover:bg-accent/60", + isDragging && "shadow-md bg-accent/40 cursor-grabbing", + )} + > + + {title} +
+ {typeof threadCount === "number" && threadCount > 0 && ( + + {threadCount} + + )} + +
+
+ ); + + return ( + + {row} + + {})} + onHideGroup={onHideGroup} + /> + + + ); +} diff --git a/apps/mesh/src/web/components/sidebar/task-groups/my-threads-section.tsx b/apps/mesh/src/web/components/sidebar/task-groups/my-threads-section.tsx new file mode 100644 index 0000000000..3a83ad57d3 --- /dev/null +++ b/apps/mesh/src/web/components/sidebar/task-groups/my-threads-section.tsx @@ -0,0 +1,75 @@ +import { TaskRow } from "@/web/layouts/tasks-panel/task-row"; +import type { Task } from "@/web/components/chat/task/types"; +import { groupThreadsByStatus } from "./group-threads"; +import { StatusGroup } from "./task-group"; +import { ShowMoreButton } from "./show-more-button"; +import type { SidebarFilters } from "./next-page-offset"; + +/** + * The current user's threads, all agents mixed. Two renderings toggled by the + * sidebar's group-by control: + * - "flat": a single chronological list (each row shows its agent icon). + * - "status": collapsible groups by thread status. + * + * In flat mode, pagination piggybacks on the global thread store's + * `fetchNextPage` (the same feed the Team section reads) via the Show more + * button. Status mode keeps its existing per-status server pagination. + */ +export function MyThreadsSection({ + threads, + groupBy, + activeTaskId, + onSelectTask, + onArchiveTask, + filters, + hasMore, + isFetchingMore, + onLoadMore, +}: { + threads: Task[]; + groupBy: "flat" | "status"; + activeTaskId: string | null; + onSelectTask: (task: Task) => void; + onArchiveTask: (task: Task) => void; + filters: SidebarFilters; + hasMore: boolean; + isFetchingMore: boolean; + onLoadMore: () => void; +}) { + if (groupBy === "status") { + return ( + <> + {groupThreadsByStatus(threads).map((group) => ( + + ))} + + ); + } + + return ( + <> + {threads.map((task) => ( + onSelectTask(task)} + onArchive={() => onArchiveTask(task)} + showAutomationBadge={Boolean(task.trigger_id)} + showAgentIcon + /> + ))} + {(hasMore || isFetchingMore) && ( + + )} + + ); +} diff --git a/apps/mesh/src/web/components/sidebar/task-groups/sidebar-section-header.tsx b/apps/mesh/src/web/components/sidebar/task-groups/sidebar-section-header.tsx new file mode 100644 index 0000000000..1cc68939d7 --- /dev/null +++ b/apps/mesh/src/web/components/sidebar/task-groups/sidebar-section-header.tsx @@ -0,0 +1,52 @@ +import type { ReactNode } from "react"; +import { ChevronDown, ChevronRight } from "@untitledui/icons"; + +/** + * Small uppercase section label that doubles as an accordion toggle. Shared by + * the sidebar's Team threads / My threads / Agents sections so they read as + * peers. Optional trailing `action` (e.g. "See all") reveals on header hover. + */ +export function SidebarSectionHeader({ + label, + open, + onToggle, + count, + action, +}: { + label: string; + open: boolean; + onToggle: () => void; + count?: number; + action?: { label: string; icon?: ReactNode; onClick: () => void }; +}) { + return ( +
+ + {action && ( + + )} +
+ ); +} diff --git a/apps/mesh/src/web/components/sidebar/task-groups/sortable-task-groups.tsx b/apps/mesh/src/web/components/sidebar/task-groups/sortable-agent-rows.tsx similarity index 50% rename from apps/mesh/src/web/components/sidebar/task-groups/sortable-task-groups.tsx rename to apps/mesh/src/web/components/sidebar/task-groups/sortable-agent-rows.tsx index 0a897df56a..60655deea1 100644 --- a/apps/mesh/src/web/components/sidebar/task-groups/sortable-task-groups.tsx +++ b/apps/mesh/src/web/components/sidebar/task-groups/sortable-agent-rows.tsx @@ -1,26 +1,31 @@ -import type { TaskGroupProps } from "./task-group"; -import { TaskGroup } from "./task-group"; import type { TaskGroupData } from "./group-threads"; import type { SidebarOrderScope } from "./stable-order"; import { AgentGroupsSortableLayout } from "./sortable-agent-groups-layout"; +import { AgentRow, type AgentRowProps } from "./agent-row"; -export interface SortableTaskGroupsProps { +export interface SortableAgentRowsProps { groups: TaskGroupData[]; orderScope: SidebarOrderScope; decopilotId: string; orgPinnedIds: string[]; onReorder: () => void; - renderGroup: (group: TaskGroupData) => TaskGroupProps; + renderGroup: (group: TaskGroupData) => AgentRowProps; } -export function SortableTaskGroups({ +/** + * Flat, draggable list of agents (one row per agent). Reuses the shared + * `AgentGroupsSortableLayout` so decopilot-pinning, org-pinned/user sections, + * and drag-to-reorder behave exactly as they did for the old grouped view — + * only the rendered item changed from a thread group to a single agent row. + */ +export function SortableAgentRows({ groups, orderScope, decopilotId, orgPinnedIds, onReorder, renderGroup, -}: SortableTaskGroupsProps) { +}: SortableAgentRowsProps) { return ( } + renderFixed={(props) => } renderSortable={(props, sortable) => ( - + )} /> ); diff --git a/apps/mesh/src/web/components/sidebar/task-groups/task-group.tsx b/apps/mesh/src/web/components/sidebar/task-groups/task-group.tsx index dcc51d3a9b..c353160d44 100644 --- a/apps/mesh/src/web/components/sidebar/task-groups/task-group.tsx +++ b/apps/mesh/src/web/components/sidebar/task-groups/task-group.tsx @@ -1,21 +1,7 @@ -import { ChevronDown, ChevronRight, Code02, Plus } from "@untitledui/icons"; +import { ChevronDown, ChevronRight } from "@untitledui/icons"; import { useState } from "react"; -import type { DraggableAttributes } from "@dnd-kit/core"; -import type { SyntheticListenerMap } from "@dnd-kit/core/dist/hooks/utilities"; -import { useVirtualMCP } from "@decocms/mesh-sdk"; -import { AgentAvatar } from "@/web/components/agent-icon"; -import { cn } from "@deco/ui/lib/utils.ts"; -import { - ContextMenu, - ContextMenuContent, - ContextMenuTrigger, -} from "@deco/ui/components/context-menu.tsx"; -import { AgentGroupContextMenuItems } from "./agent-group-context-menu-items"; import { TaskRow } from "@/web/layouts/tasks-panel/task-row"; -import { - TOOL_CALL_RUNS_GROUP_KEY, - type StatusGroupData, -} from "./group-threads"; +import type { StatusGroupData } from "./group-threads"; import { useGroupExpanded } from "./use-group-expanded"; import type { Task } from "@/web/components/chat/task/types"; import { STATUS_CONFIG } from "@/web/lib/task-status"; @@ -23,322 +9,6 @@ import { ShowMoreButton } from "./show-more-button"; import type { SidebarFilters } from "./next-page-offset"; import { useGroupShowMore } from "./use-group-show-more"; -export interface TaskGroupProps { - virtualMcpId: string; - threads: Task[]; - activeTaskId: string | null; - onSelectTask: (task: Task) => void; - onArchiveTask: (task: Task) => void; - onNewTaskInGroup: (virtualMcpId: string) => void; - onShowSettings: (virtualMcpId: string) => void; - onHideGroup: (virtualMcpId: string) => void; - isOrgPinned?: boolean; - canManageOrgPin?: boolean; - onToggleOrgPin?: (virtualMcpId: string, pinned: boolean) => void; - /** Precomputed visible count for this group (avoids O(T) scan per hook). */ - groupVisibleCount?: number; - /** When this live group is paired with a dev agent, the dev agent's id. Its - * threads (carried in `threads`, keyed by this id) render in a "Develop" - * sub-section instead of a standalone sidebar group. */ - devPartnerId?: string | null; - /** Precomputed visible count for the dev partner's threads. */ - devGroupVisibleCount?: number; - filters: SidebarFilters; - /** When set, the group header is draggable for reordering. */ - sortable?: { - attributes: DraggableAttributes; - listeners: SyntheticListenerMap | undefined; - isDragging: boolean; - }; -} - -export function TaskGroup({ - virtualMcpId, - threads, - activeTaskId, - onSelectTask, - onArchiveTask, - onNewTaskInGroup, - onShowSettings, - onHideGroup, - isOrgPinned = false, - canManageOrgPin = false, - onToggleOrgPin, - filters, - groupVisibleCount, - devPartnerId = null, - devGroupVisibleCount, - sortable, -}: TaskGroupProps) { - const [expanded, setExpanded] = useGroupExpanded(virtualMcpId, false); - const isToolCallRuns = virtualMcpId === TOOL_CALL_RUNS_GROUP_KEY; - const isDragging = sortable?.isDragging ?? false; - - // Threads carry both the live agent's and (folded-in) dev agent's threads, - // keyed by virtual_mcp_id. Split so the dev ones render in a "Develop" - // sub-section with their own pagination (keyed by the dev agent id). - const devThreads = devPartnerId - ? threads.filter((t) => t.virtual_mcp_id === devPartnerId) - : []; - const liveThreads = devPartnerId - ? threads.filter((t) => t.virtual_mcp_id !== devPartnerId) - : threads; - const showDevSection = - !!devPartnerId && - (devThreads.length > 0 || (devGroupVisibleCount ?? 0) > 0); - - function handleToggleExpanded() { - const next = !expanded; - setExpanded(next); - } - - const header = ( -
{ - if (e.target !== e.currentTarget) return; - if (e.key === "Enter" || e.key === " ") { - e.preventDefault(); - handleToggleExpanded(); - } - }} - className={cn( - "group/group flex items-center gap-2 px-2 py-1.5 rounded-md text-sm font-medium text-foreground", - "cursor-pointer hover:bg-accent/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50 transition-colors", - sortable && !isToolCallRuns && "touch-none active:cursor-grabbing", - isDragging && "shadow-md bg-accent/40 cursor-grabbing", - )} - > -
- - - - - {expanded ? : } - -
- - - - {!isToolCallRuns && ( - - )} -
- ); - - return ( -
- {isToolCallRuns ? ( - header - ) : ( - - {header} - - {})} - onHideGroup={onHideGroup} - /> - - - )} - {expanded && ( -
- {isToolCallRuns ? ( - threads.map((task) => ( - onSelectTask(task)} - onArchive={() => onArchiveTask(task)} - showAutomationBadge={Boolean(task.trigger_id)} - indented - /> - )) - ) : ( - <> - - {showDevSection && devPartnerId && ( -
-
- - Develop -
- -
- )} - - )} -
- )} -
- ); -} - -function TaskGroupAvatar({ - virtualMcpId, - isToolCallRuns, -}: { - virtualMcpId: string; - isToolCallRuns: boolean; -}) { - if (isToolCallRuns) { - return ; - } - return ; -} - -function TaskGroupAvatarInner({ virtualMcpId }: { virtualMcpId: string }) { - const entity = useVirtualMCP(virtualMcpId); - if (!entity) return ; - return ( - - ); -} - -function TaskGroupLabel({ - virtualMcpId, - isToolCallRuns, -}: { - virtualMcpId: string; - isToolCallRuns: boolean; -}) { - if (isToolCallRuns) return <>Automation runs; - return ; -} - -function TaskGroupLabelInner({ virtualMcpId }: { virtualMcpId: string }) { - const entity = useVirtualMCP(virtualMcpId); - return <>{entity?.title ?? "Agent"}; -} - -function AgentExpandedBody({ - virtualMcpId, - threads, - activeTaskId, - onSelectTask, - onArchiveTask, - onNewTaskInGroup, - filters, - groupVisibleCount, -}: { - virtualMcpId: string; - threads: Task[]; - activeTaskId: string | null; - onSelectTask: (task: Task) => void; - onArchiveTask: (task: Task) => void; - onNewTaskInGroup: (virtualMcpId: string) => void; - filters: SidebarFilters; - groupVisibleCount?: number; -}) { - const showMore = useGroupShowMore( - "agent", - virtualMcpId, - filters, - groupVisibleCount, - ); - const { isFetching, isProbing, loadMore, serverHasMore } = showMore; - - const [autoLoaded, setAutoLoaded] = useState(false); - if ( - !autoLoaded && - threads.length === 0 && - serverHasMore === true && - !isFetching && - !isProbing - ) { - setAutoLoaded(true); - queueMicrotask(() => { - void loadMore(); - }); - } - const { hasMore } = showMore; - - return ( - <> - {threads.length === 0 ? ( - - ) : ( - <> - {threads.map((task) => ( - onSelectTask(task)} - onArchive={() => onArchiveTask(task)} - showAutomationBadge={Boolean(task.trigger_id)} - indented - /> - ))} - {(hasMore || isFetching) && ( - void loadMore()} - isFetching={isFetching} - indented - /> - )} - - )} - - ); -} - function StatusExpandedBody({ status, threads, diff --git a/apps/mesh/src/web/components/sidebar/task-groups/task-groups-list.tsx b/apps/mesh/src/web/components/sidebar/task-groups/task-groups-list.tsx index 46c4ae50d2..dad3ddfef4 100644 --- a/apps/mesh/src/web/components/sidebar/task-groups/task-groups-list.tsx +++ b/apps/mesh/src/web/components/sidebar/task-groups/task-groups-list.tsx @@ -1,5 +1,5 @@ import { useState, type ReactNode } from "react"; -import { Activity, FilterLines, SearchSm, Users01 } from "@untitledui/icons"; +import { Activity, FilterLines, Rows01, SearchSm } from "@untitledui/icons"; import { Popover, PopoverContent, @@ -44,20 +44,21 @@ import { SortableCollapsedTaskGroups } from "./sortable-collapsed-task-groups"; import { foldDevGroupsIntoLive, groupThreadsByVirtualMcp, - groupThreadsByStatus, TOOL_CALL_RUNS_GROUP_KEY, } from "./group-threads"; import { getLiveDevAgentMaps } from "@/web/lib/agent-capabilities"; import { removeGroupFromOrder, syncOrdersOnOrgPinToggle } from "./stable-order"; -import { SortableTaskGroups } from "./sortable-task-groups"; -import { StatusGroup } from "./task-group"; +import { SortableAgentRows } from "./sortable-agent-rows"; +import type { AgentRowProps } from "./agent-row"; +import { TeamThreadsSection } from "./team-threads-section"; +import { MyThreadsSection } from "./my-threads-section"; +import { SidebarSectionHeader } from "./sidebar-section-header"; import type { SidebarFilters } from "./next-page-offset"; import { buildGroupThreadCounts } from "./next-page-offset"; import { useSidebarGroupOrder } from "./use-sidebar-group-order"; type TypeFilter = "all" | "manual" | "automation"; -type MemberFilter = "all" | "mine"; -type GroupBy = "agent" | "status"; +type GroupBy = "flat" | "status"; const TYPE_LABELS: Record = { all: "All tasks", @@ -65,16 +66,6 @@ const TYPE_LABELS: Record = { automation: "Automation", }; -const MEMBER_LABELS: Record = { - all: "All members", - mine: "Mine only", -}; - -const GROUP_BY_LABELS: Record = { - agent: "Agent", - status: "Status", -}; - export function TaskGroupsList({ onNavigate, }: { @@ -125,16 +116,24 @@ export function TaskGroupsList({ })(); const orgPinnedSet = new Set(orgPinnedIds); - const { threads: allThreads } = useThreads(); + const { + threads: allThreads, + hasMore, + isFetchingMore, + fetchNextPage, + } = useThreads(); const visibleThreads = allThreads.filter((thread) => !thread.hidden); const { hide } = useThreadActions(); const navigate = useNavigate(); + const navigateToAgent = useNavigateToAgent(); const { setTaskId, createNewTask } = usePanelActions(); const params = useParams({ strict: false }) as { taskId?: string; }; const activeTaskId = params.taskId ?? null; + const activeAgentId = + allThreads.find((t) => t.id === activeTaskId)?.virtual_mcp_id ?? null; const closeAfterNavigation = () => { onNavigate?.(); }; @@ -144,8 +143,9 @@ export function TaskGroupsList({ ); const [typeFilter, setTypeFilter] = useState("all"); - const [memberFilter, setMemberFilter] = useState("mine"); - const [groupBy, setGroupBy] = useState("agent"); + const [groupBy, setGroupBy] = useState("flat"); + const [myThreadsOpen, setMyThreadsOpen] = useState(true); + const [agentsOpen, setAgentsOpen] = useState(true); const [searchOpen, setSearchOpen] = useState(false); const [searchEverOpened, setSearchEverOpened] = useState(false); const [localOrderRevision, setLocalOrderRevision] = useState(0); @@ -153,9 +153,11 @@ export function TaskGroupsList({ const orderRevision = localOrderRevision + contextOrderRevision; const orderScope = { orgId: org.id, userId: sidebarUserId }; + // Member scope is now structural (Team vs My sections), so per-group/per-status + // server pagination stays scoped to the current user via `member: "mine"`. const filters: SidebarFilters = { type: typeFilter, - member: memberFilter, + member: "mine", currentUserId: currentUserId ?? null, }; @@ -167,8 +169,7 @@ export function TaskGroupsList({ // Dev agents are hidden from agent PICKERS (browse popover, @-mention, add // to home). Their thread groups are folded into the live counterpart's group - // (rendered as a "Develop" sub-section) so dev sessions stay navigable under - // one entry instead of a confusing standalone group. + // so dev sessions stay under one entry instead of a confusing standalone one. const groups = useSidebarGroupOrder( orderScope, foldDevGroupsIntoLive( @@ -179,9 +180,14 @@ export function TaskGroupsList({ orgPinnedIds, orderRevision, ); + // The Agents section lists real agents only — threads without an agent + // (tool-call runs) surface in the thread lists, not as an "agent". + const agentGroups = groups.filter( + (g) => g.virtualMcpId !== TOOL_CALL_RUNS_GROUP_KEY, + ); - const memberFiltered = (threads: Task[]) => - memberFilter === "mine" && currentUserId + const mineFiltered = (threads: Task[]) => + currentUserId ? threads.filter((t) => t.created_by === currentUserId) : threads; @@ -195,18 +201,22 @@ export function TaskGroupsList({ return threads; }; + // Mine, in recency order — used both for display and to resolve an agent's + // most-recent thread on click (ignores the type filter so the agent always + // opens its latest thread). + const myThreadsAll = mineFiltered(sortedThreads); + const myThreads = typeFiltered(myThreadsAll); + const teamThreads = typeFiltered( + sortedThreads.filter((t) => t.created_by && t.created_by !== currentUserId), + ); + const handleArchive = (task: Task) => { const wasActive = task.id === activeTaskId; hide(task.id); if (!wasActive) return; - // Land only on the caller's own threads. The panel lists org-wide threads - // under the "All members" filter, so an unscoped fallback would teleport - // the user into another member's chat. - const next = sortedThreads.find( - (t) => - t.id !== task.id && - t.virtual_mcp_id === task.virtual_mcp_id && - t.created_by === currentUserId, + // Land only on the caller's own threads — never teleport into a teammate's. + const next = myThreadsAll.find( + (t) => t.id !== task.id && t.virtual_mcp_id === task.virtual_mcp_id, ); closeAfterNavigation(); if (next) { @@ -216,13 +226,33 @@ export function TaskGroupsList({ } }; + const handleSelectTask = (t: Task) => { + closeAfterNavigation(); + setTaskId(t.id, t.virtual_mcp_id); + }; + const handleNewInGroup = (virtualMcpId: string) => { track("sidebar_group_new_clicked", { virtual_mcp_id: virtualMcpId }); closeAfterNavigation(); createNewTask(virtualMcpId); }; - const navigateToAgent = useNavigateToAgent(); + // Click an agent → open its most recent thread; if it has none, start a new + // one (which also adds it to the personal sidebar order). + const handleOpenAgent = (virtualMcpId: string) => { + const last = myThreadsAll.find((t) => t.virtual_mcp_id === virtualMcpId); + track("sidebar_agent_opened", { + virtual_mcp_id: virtualMcpId, + had_thread: Boolean(last), + }); + closeAfterNavigation(); + if (last) { + setTaskId(last.id, virtualMcpId); + } else { + navigateToAgent(virtualMcpId); + } + }; + const handleShowSettings = (virtualMcpId: string) => { track("sidebar_group_settings_clicked", { virtual_mcp_id: virtualMcpId }); closeAfterNavigation(); @@ -275,8 +305,23 @@ export function TaskGroupsList({ }; }; - const buildAgentGroupRenderProps = (group: (typeof groups)[number]) => { - const filtered = typeFiltered(memberFiltered(group.threads)); + const buildAgentRowProps = ( + group: (typeof agentGroups)[number], + ): AgentRowProps => ({ + virtualMcpId: group.virtualMcpId, + isActive: activeAgentId === group.virtualMcpId, + threadCount: agentThreadCounts.get(group.virtualMcpId) ?? 0, + onOpen: handleOpenAgent, + onNewTask: handleNewInGroup, + onShowSettings: handleShowSettings, + onHideGroup: handleHideGroup, + ...groupContextMenuProps(group.virtualMcpId), + }); + + // The collapsed rail still renders per-agent thread popovers, so it keeps the + // richer group-render props (with nested, mine-scoped threads). + const buildCollapsedGroupProps = (group: (typeof groups)[number]) => { + const filtered = typeFiltered(mineFiltered(group.threads)); const devPartnerId = liveToDev.get(group.virtualMcpId) ?? null; return { virtualMcpId: group.virtualMcpId, @@ -288,10 +333,7 @@ export function TaskGroupsList({ activeTaskId, filters, groupVisibleCount: agentThreadCounts.get(group.virtualMcpId) ?? 0, - onSelectTask: (t: Task) => { - closeAfterNavigation(); - setTaskId(t.id, t.virtual_mcp_id); - }, + onSelectTask: handleSelectTask, onArchiveTask: handleArchive, onNewTaskInGroup: handleNewInGroup, onShowSettings: handleShowSettings, @@ -300,22 +342,20 @@ export function TaskGroupsList({ }; }; - const filtersActive = typeFilter !== "all" || memberFilter !== "mine"; + const filtersActive = typeFilter !== "all"; const { state: sidebarState, isMobile } = useSidebar(); const isCollapsed = sidebarState === "collapsed" && !isMobile; if (isCollapsed) { const visibleGroups = groups.filter((group) => { - const filtered = typeFiltered(memberFiltered(group.threads)); + const filtered = typeFiltered(mineFiltered(group.threads)); return !(filtersActive && filtered.length === 0); }); return ( <> - + setLocalOrderRevision((n) => n + 1)} - renderGroup={(group) => buildAgentGroupRenderProps(group)} + renderGroup={(group) => buildCollapsedGroupProps(group)} /> @@ -332,9 +372,7 @@ export function TaskGroupsList({ return (
- +
{ - const next: GroupBy = groupBy === "agent" ? "status" : "agent"; + const next: GroupBy = groupBy === "flat" ? "status" : "flat"; track("tasks_panel_group_by_changed", { to_value: next }); setGroupBy(next); }} > - {groupBy === "agent" ? ( - - ) : ( - - )} + {groupBy === "flat" ? : } @@ -378,33 +414,6 @@ export function TaskGroupsList({ className="w-72 p-3" >
- - -