mirror of
https://github.com/Significant-Gravitas/AutoGPT.git
synced 2026-04-08 03:00:28 -04:00
feat(copilot): Add folder management tools to CoPilot (#12290)
Adds folder management capabilities to the CoPilot, allowing users to organize agents into folders directly from the chat interface. <img width="823" height="356" alt="Screenshot 2026-03-05 at 5 26 30 PM" src="https://github.com/user-attachments/assets/4c55f926-1e71-488f-9eb6-fca87c4ab01b" /> <img width="797" height="150" alt="Screenshot 2026-03-05 at 5 28 40 PM" src="https://github.com/user-attachments/assets/5c9c6f8b-57ac-4122-b17d-b9f091bb7c4e" /> <img width="763" height="196" alt="Screenshot 2026-03-05 at 5 28 36 PM" src="https://github.com/user-attachments/assets/d1b22b5d-921d-44ac-90e8-a5820bb3146d" /> <img width="756" height="199" alt="Screenshot 2026-03-05 at 5 30 17 PM" src="https://github.com/user-attachments/assets/40a59748-f42e-4521-bae0-cc786918a9b5" /> ### Changes **Backend -- 6 new CoPilot tools** (`manage_folders.py`): - `create_folder` -- Create folders with optional parent, icon, and color - `list_folders` -- List folder tree or children of a specific folder, with optional `include_agents` to show agents inside each folder - `update_folder` -- Rename or change icon/color - `move_folder` -- Reparent a folder or move to root - `delete_folder` -- Soft-delete (agents moved to root, not deleted) - `move_agents_to_folder` -- Bulk-move agents into a folder or back to root **Backend -- DatabaseManager RPC registration**: - Registered all 7 folder DB functions (`create_folder`, `list_folders`, `get_folder_tree`, `update_folder`, `move_folder`, `delete_folder`, `bulk_move_agents_to_folder`) in `DatabaseManager` and `DatabaseManagerAsyncClient` so they work via RPC in the CoPilotExecutor process - `manage_folders.py` uses `db_accessors.library_db()` pattern (consistent with all other copilot tools) instead of direct Prisma imports **Backend -- folder_id threading**: - `create_agent` and `customize_agent` tools accept optional `folder_id` to save agents directly into a folder - `save_agent_to_library` -> `create_graph_in_library` -> `create_library_agent` pipeline passes `folder_id` through - `create_library_agent` refactored from `asyncio.gather` to sequential loop to support conditional `folderId` assignment on the main graph only (not sub-graphs) **Backend -- system prompt and models**: - Added folder tool descriptions and usage guidance to Otto's system prompt - Added `FolderAgentSummary` model for lightweight agent info in folder listings - Added 6 `ResponseType` enum values and corresponding Pydantic response models (`FolderInfo`, `FolderTreeInfo`, `FolderCreatedResponse`, etc.) **Frontend -- FolderTool UI component**: - `FolderTool.tsx` -- Renders folder operations in chat using the `file-tree` molecule component for tree view, with `FileIcon` for agents and `FolderIcon` for folders (both `text-neutral-600`) - `helpers.ts` -- Type guards, output parsing, animation text helpers, and `FolderAgentSummary` type - `MessagePartRenderer.tsx` -- Routes 6 folder tool types to `FolderTool` component - Flat folder list view shows agents inside `FolderCard` when `include_agents` is set **Frontend -- file-tree molecule**: - Fixed 3 pre-existing lint errors in `file-tree.tsx` (unused `ref`, `handleSelect`, `className` params) - Updated tree indicator line color from `bg-neutral-100` to `bg-neutral-400` for visibility - Added `file-tree.stories.tsx` with 5 stories: Default, AllExpanded, FoldersOnly, WithInitialSelection, NoIndicator - Added `ui/scroll-area.tsx` (dependency of file-tree, was missing from non-legacy ui folder) ### Checklist #### For code changes: - [x] I have clearly listed my changes in the PR description - [x] I have made a test plan - [x] I have tested my changes according to the test plan: - [x] Create a folder via copilot chat ("create a folder called Marketing") - [x] List folders ("show me my folders") - [x] List folders with agents ("show me my folders and the agents in them") - [x] Update folder name/icon/color ("rename Marketing folder to Sales") - [x] Move folder to a different parent ("move Sales into the Projects folder") - [x] Delete a folder and verify agents move to root - [x] Move agents into a folder ("put my newsletter agent in the Marketing folder") - [x] Create agent with folder_id ("create a scraper agent and save it in my Tools folder") - [x] Verify FolderTool UI renders loading, success, error, and empty states correctly - [x] Verify folder tree renders nested folders with file-tree component - [x] Verify agents appear as FileIcon nodes in tree view when include_agents is true - [x] Verify file-tree storybook stories render correctly
This commit is contained in:
@@ -397,6 +397,7 @@ async def create_library_agent(
|
||||
hitl_safe_mode: bool = True,
|
||||
sensitive_action_safe_mode: bool = False,
|
||||
create_library_agents_for_sub_graphs: bool = True,
|
||||
folder_id: str | None = None,
|
||||
) -> list[library_model.LibraryAgent]:
|
||||
"""
|
||||
Adds an agent to the user's library (LibraryAgent table).
|
||||
@@ -419,6 +420,12 @@ async def create_library_agent(
|
||||
logger.info(
|
||||
f"Creating library agent for graph #{graph.id} v{graph.version}; user:<redacted>"
|
||||
)
|
||||
|
||||
# Authorization: FK only checks existence, not ownership.
|
||||
# Verify the folder belongs to this user to prevent cross-user nesting.
|
||||
if folder_id:
|
||||
await get_folder(folder_id, user_id)
|
||||
|
||||
graph_entries = (
|
||||
[graph, *graph.sub_graphs] if create_library_agents_for_sub_graphs else [graph]
|
||||
)
|
||||
@@ -431,7 +438,6 @@ async def create_library_agent(
|
||||
isCreatedByUser=(user_id == user_id),
|
||||
useGraphIsActiveVersion=True,
|
||||
User={"connect": {"id": user_id}},
|
||||
# Creator={"connect": {"id": user_id}},
|
||||
AgentGraph={
|
||||
"connect": {
|
||||
"graphVersionId": {
|
||||
@@ -447,6 +453,11 @@ async def create_library_agent(
|
||||
sensitive_action_safe_mode=sensitive_action_safe_mode,
|
||||
).model_dump()
|
||||
),
|
||||
**(
|
||||
{"Folder": {"connect": {"id": folder_id}}}
|
||||
if folder_id and graph_entry is graph
|
||||
else {}
|
||||
),
|
||||
),
|
||||
include=library_agent_include(
|
||||
user_id, include_nodes=False, include_executions=False
|
||||
@@ -528,6 +539,7 @@ async def update_agent_version_in_library(
|
||||
async def create_graph_in_library(
|
||||
graph: graph_db.Graph,
|
||||
user_id: str,
|
||||
folder_id: str | None = None,
|
||||
) -> tuple[graph_db.GraphModel, library_model.LibraryAgent]:
|
||||
"""Create a new graph and add it to the user's library."""
|
||||
graph.version = 1
|
||||
@@ -541,6 +553,7 @@ async def create_graph_in_library(
|
||||
user_id=user_id,
|
||||
sensitive_action_safe_mode=True,
|
||||
create_library_agents_for_sub_graphs=False,
|
||||
folder_id=folder_id,
|
||||
)
|
||||
|
||||
if created_graph.is_active:
|
||||
@@ -1480,6 +1493,67 @@ async def bulk_move_agents_to_folder(
|
||||
return [library_model.LibraryAgent.from_db(agent) for agent in agents]
|
||||
|
||||
|
||||
def collect_tree_ids(
|
||||
nodes: list[library_model.LibraryFolderTree],
|
||||
visited: set[str] | None = None,
|
||||
) -> list[str]:
|
||||
"""Collect all folder IDs from a folder tree."""
|
||||
if visited is None:
|
||||
visited = set()
|
||||
ids: list[str] = []
|
||||
for n in nodes:
|
||||
if n.id in visited:
|
||||
continue
|
||||
visited.add(n.id)
|
||||
ids.append(n.id)
|
||||
ids.extend(collect_tree_ids(n.children, visited))
|
||||
return ids
|
||||
|
||||
|
||||
async def get_folder_agent_summaries(
|
||||
user_id: str, folder_id: str
|
||||
) -> list[dict[str, str | None]]:
|
||||
"""Get a lightweight list of agents in a folder (id, name, description)."""
|
||||
all_agents: list[library_model.LibraryAgent] = []
|
||||
for page in itertools.count(1):
|
||||
resp = await list_library_agents(
|
||||
user_id=user_id, folder_id=folder_id, page=page
|
||||
)
|
||||
all_agents.extend(resp.agents)
|
||||
if page >= resp.pagination.total_pages:
|
||||
break
|
||||
return [
|
||||
{"id": a.id, "name": a.name, "description": a.description} for a in all_agents
|
||||
]
|
||||
|
||||
|
||||
async def get_root_agent_summaries(
|
||||
user_id: str,
|
||||
) -> list[dict[str, str | None]]:
|
||||
"""Get a lightweight list of root-level agents (folderId IS NULL)."""
|
||||
all_agents: list[library_model.LibraryAgent] = []
|
||||
for page in itertools.count(1):
|
||||
resp = await list_library_agents(
|
||||
user_id=user_id, include_root_only=True, page=page
|
||||
)
|
||||
all_agents.extend(resp.agents)
|
||||
if page >= resp.pagination.total_pages:
|
||||
break
|
||||
return [
|
||||
{"id": a.id, "name": a.name, "description": a.description} for a in all_agents
|
||||
]
|
||||
|
||||
|
||||
async def get_folder_agents_map(
|
||||
user_id: str, folder_ids: list[str]
|
||||
) -> dict[str, list[dict[str, str | None]]]:
|
||||
"""Get agent summaries for multiple folders concurrently."""
|
||||
results = await asyncio.gather(
|
||||
*(get_folder_agent_summaries(user_id, fid) for fid in folder_ids)
|
||||
)
|
||||
return dict(zip(folder_ids, results))
|
||||
|
||||
|
||||
##############################################
|
||||
########### Presets DB Functions #############
|
||||
##############################################
|
||||
|
||||
@@ -20,6 +20,14 @@ from .find_agent import FindAgentTool
|
||||
from .find_block import FindBlockTool
|
||||
from .find_library_agent import FindLibraryAgentTool
|
||||
from .get_doc_page import GetDocPageTool
|
||||
from .manage_folders import (
|
||||
CreateFolderTool,
|
||||
DeleteFolderTool,
|
||||
ListFoldersTool,
|
||||
MoveAgentsToFolderTool,
|
||||
MoveFolderTool,
|
||||
UpdateFolderTool,
|
||||
)
|
||||
from .run_agent import RunAgentTool
|
||||
from .run_block import RunBlockTool
|
||||
from .run_mcp_tool import RunMCPToolTool
|
||||
@@ -47,6 +55,13 @@ TOOL_REGISTRY: dict[str, BaseTool] = {
|
||||
"find_agent": FindAgentTool(),
|
||||
"find_block": FindBlockTool(),
|
||||
"find_library_agent": FindLibraryAgentTool(),
|
||||
# Folder management tools
|
||||
"create_folder": CreateFolderTool(),
|
||||
"list_folders": ListFoldersTool(),
|
||||
"update_folder": UpdateFolderTool(),
|
||||
"move_folder": MoveFolderTool(),
|
||||
"delete_folder": DeleteFolderTool(),
|
||||
"move_agents_to_folder": MoveAgentsToFolderTool(),
|
||||
"run_agent": RunAgentTool(),
|
||||
"run_block": RunBlockTool(),
|
||||
"run_mcp_tool": RunMCPToolTool(),
|
||||
|
||||
@@ -695,7 +695,10 @@ def json_to_graph(agent_json: dict[str, Any]) -> Graph:
|
||||
|
||||
|
||||
async def save_agent_to_library(
|
||||
agent_json: dict[str, Any], user_id: str, is_update: bool = False
|
||||
agent_json: dict[str, Any],
|
||||
user_id: str,
|
||||
is_update: bool = False,
|
||||
folder_id: str | None = None,
|
||||
) -> tuple[Graph, Any]:
|
||||
"""Save agent to database and user's library.
|
||||
|
||||
@@ -703,6 +706,7 @@ async def save_agent_to_library(
|
||||
agent_json: Agent JSON dict
|
||||
user_id: User ID
|
||||
is_update: Whether this is an update to an existing agent
|
||||
folder_id: Optional folder ID to place the agent in
|
||||
|
||||
Returns:
|
||||
Tuple of (created Graph, LibraryAgent)
|
||||
@@ -711,7 +715,7 @@ async def save_agent_to_library(
|
||||
db = library_db()
|
||||
if is_update:
|
||||
return await db.update_graph_in_library(graph, user_id)
|
||||
return await db.create_graph_in_library(graph, user_id)
|
||||
return await db.create_graph_in_library(graph, user_id, folder_id=folder_id)
|
||||
|
||||
|
||||
def graph_to_json(graph: Graph) -> dict[str, Any]:
|
||||
|
||||
@@ -84,6 +84,14 @@ class CreateAgentTool(BaseTool):
|
||||
),
|
||||
"default": True,
|
||||
},
|
||||
"folder_id": {
|
||||
"type": "string",
|
||||
"description": (
|
||||
"Optional folder ID to save the agent into. "
|
||||
"If not provided, the agent is saved at root level. "
|
||||
"Use list_folders to find available folders."
|
||||
),
|
||||
},
|
||||
},
|
||||
"required": ["description"],
|
||||
}
|
||||
@@ -105,6 +113,7 @@ class CreateAgentTool(BaseTool):
|
||||
context = kwargs.get("context", "")
|
||||
library_agent_ids = kwargs.get("library_agent_ids", [])
|
||||
save = kwargs.get("save", True)
|
||||
folder_id = kwargs.get("folder_id")
|
||||
session_id = session.session_id if session else None
|
||||
|
||||
logger.info(
|
||||
@@ -336,7 +345,7 @@ class CreateAgentTool(BaseTool):
|
||||
|
||||
try:
|
||||
created_graph, library_agent = await save_agent_to_library(
|
||||
agent_json, user_id
|
||||
agent_json, user_id, folder_id=folder_id
|
||||
)
|
||||
|
||||
logger.info(
|
||||
|
||||
@@ -80,6 +80,14 @@ class CustomizeAgentTool(BaseTool):
|
||||
),
|
||||
"default": True,
|
||||
},
|
||||
"folder_id": {
|
||||
"type": "string",
|
||||
"description": (
|
||||
"Optional folder ID to save the agent into. "
|
||||
"If not provided, the agent is saved at root level. "
|
||||
"Use list_folders to find available folders."
|
||||
),
|
||||
},
|
||||
},
|
||||
"required": ["agent_id", "modifications"],
|
||||
}
|
||||
@@ -102,6 +110,7 @@ class CustomizeAgentTool(BaseTool):
|
||||
modifications = kwargs.get("modifications", "").strip()
|
||||
context = kwargs.get("context", "")
|
||||
save = kwargs.get("save", True)
|
||||
folder_id = kwargs.get("folder_id")
|
||||
session_id = session.session_id if session else None
|
||||
|
||||
if not agent_id:
|
||||
@@ -310,7 +319,7 @@ class CustomizeAgentTool(BaseTool):
|
||||
# Save to user's library
|
||||
try:
|
||||
created_graph, library_agent = await save_agent_to_library(
|
||||
customized_agent, user_id, is_update=False
|
||||
customized_agent, user_id, is_update=False, folder_id=folder_id
|
||||
)
|
||||
|
||||
return AgentSavedResponse(
|
||||
|
||||
573
autogpt_platform/backend/backend/copilot/tools/manage_folders.py
Normal file
573
autogpt_platform/backend/backend/copilot/tools/manage_folders.py
Normal file
@@ -0,0 +1,573 @@
|
||||
"""Folder management tools for the copilot."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from backend.api.features.library import model as library_model
|
||||
from backend.api.features.library.db import collect_tree_ids
|
||||
from backend.copilot.model import ChatSession
|
||||
from backend.data.db_accessors import library_db
|
||||
|
||||
from .base import BaseTool
|
||||
from .models import (
|
||||
AgentsMovedToFolderResponse,
|
||||
ErrorResponse,
|
||||
FolderAgentSummary,
|
||||
FolderCreatedResponse,
|
||||
FolderDeletedResponse,
|
||||
FolderInfo,
|
||||
FolderListResponse,
|
||||
FolderMovedResponse,
|
||||
FolderTreeInfo,
|
||||
FolderUpdatedResponse,
|
||||
ToolResponseBase,
|
||||
)
|
||||
|
||||
|
||||
def _folder_to_info(
|
||||
folder: library_model.LibraryFolder,
|
||||
agents: list[FolderAgentSummary] | None = None,
|
||||
) -> FolderInfo:
|
||||
"""Convert a LibraryFolder DB model to a FolderInfo response model."""
|
||||
return FolderInfo(
|
||||
id=folder.id,
|
||||
name=folder.name,
|
||||
parent_id=folder.parent_id,
|
||||
icon=folder.icon,
|
||||
color=folder.color,
|
||||
agent_count=folder.agent_count,
|
||||
subfolder_count=folder.subfolder_count,
|
||||
agents=agents,
|
||||
)
|
||||
|
||||
|
||||
def _tree_to_info(
|
||||
tree: library_model.LibraryFolderTree,
|
||||
agents_map: dict[str, list[FolderAgentSummary]] | None = None,
|
||||
) -> FolderTreeInfo:
|
||||
"""Recursively convert a LibraryFolderTree to a FolderTreeInfo response."""
|
||||
return FolderTreeInfo(
|
||||
id=tree.id,
|
||||
name=tree.name,
|
||||
parent_id=tree.parent_id,
|
||||
icon=tree.icon,
|
||||
color=tree.color,
|
||||
agent_count=tree.agent_count,
|
||||
subfolder_count=tree.subfolder_count,
|
||||
children=[_tree_to_info(child, agents_map) for child in tree.children],
|
||||
agents=agents_map.get(tree.id) if agents_map else None,
|
||||
)
|
||||
|
||||
|
||||
def _to_agent_summaries(
|
||||
raw: list[dict[str, str | None]],
|
||||
) -> list[FolderAgentSummary]:
|
||||
"""Convert raw agent dicts to typed FolderAgentSummary models."""
|
||||
return [
|
||||
FolderAgentSummary(
|
||||
id=a["id"] or "",
|
||||
name=a["name"] or "",
|
||||
description=a["description"] or "",
|
||||
)
|
||||
for a in raw
|
||||
]
|
||||
|
||||
|
||||
def _to_agent_summaries_map(
|
||||
raw: dict[str, list[dict[str, str | None]]],
|
||||
) -> dict[str, list[FolderAgentSummary]]:
|
||||
"""Convert a folder-id-keyed dict of raw agents to typed summaries."""
|
||||
return {fid: _to_agent_summaries(agents) for fid, agents in raw.items()}
|
||||
|
||||
|
||||
class CreateFolderTool(BaseTool):
|
||||
"""Tool for creating a library folder."""
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "create_folder"
|
||||
|
||||
@property
|
||||
def description(self) -> str:
|
||||
return (
|
||||
"Create a new folder in the user's library to organize agents. "
|
||||
"Optionally nest it inside an existing folder using parent_id."
|
||||
)
|
||||
|
||||
@property
|
||||
def requires_auth(self) -> bool:
|
||||
return True
|
||||
|
||||
@property
|
||||
def parameters(self) -> dict[str, Any]:
|
||||
return {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "Name for the new folder (max 100 chars).",
|
||||
},
|
||||
"parent_id": {
|
||||
"type": "string",
|
||||
"description": (
|
||||
"ID of the parent folder to nest inside. "
|
||||
"Omit to create at root level."
|
||||
),
|
||||
},
|
||||
"icon": {
|
||||
"type": "string",
|
||||
"description": "Optional icon identifier for the folder.",
|
||||
},
|
||||
"color": {
|
||||
"type": "string",
|
||||
"description": "Optional hex color code (#RRGGBB).",
|
||||
},
|
||||
},
|
||||
"required": ["name"],
|
||||
}
|
||||
|
||||
async def _execute(
|
||||
self, user_id: str | None, session: ChatSession, **kwargs
|
||||
) -> ToolResponseBase:
|
||||
"""Create a folder with the given name and optional parent/icon/color."""
|
||||
assert user_id is not None # guaranteed by requires_auth
|
||||
name = (kwargs.get("name") or "").strip()
|
||||
parent_id = kwargs.get("parent_id")
|
||||
icon = kwargs.get("icon")
|
||||
color = kwargs.get("color")
|
||||
session_id = session.session_id if session else None
|
||||
|
||||
if not name:
|
||||
return ErrorResponse(
|
||||
message="Please provide a folder name.",
|
||||
error="missing_name",
|
||||
session_id=session_id,
|
||||
)
|
||||
|
||||
try:
|
||||
folder = await library_db().create_folder(
|
||||
user_id=user_id,
|
||||
name=name,
|
||||
parent_id=parent_id,
|
||||
icon=icon,
|
||||
color=color,
|
||||
)
|
||||
except Exception as e:
|
||||
return ErrorResponse(
|
||||
message=f"Failed to create folder: {e}",
|
||||
error="create_folder_failed",
|
||||
session_id=session_id,
|
||||
)
|
||||
|
||||
return FolderCreatedResponse(
|
||||
message=f"Folder '{folder.name}' created successfully!",
|
||||
folder=_folder_to_info(folder),
|
||||
session_id=session_id,
|
||||
)
|
||||
|
||||
|
||||
class ListFoldersTool(BaseTool):
|
||||
"""Tool for listing library folders."""
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "list_folders"
|
||||
|
||||
@property
|
||||
def description(self) -> str:
|
||||
return (
|
||||
"List the user's library folders. "
|
||||
"Omit parent_id to get the full folder tree. "
|
||||
"Provide parent_id to list only direct children of that folder. "
|
||||
"Set include_agents=true to also return the agents inside each folder "
|
||||
"and root-level agents not in any folder. Always set include_agents=true "
|
||||
"when the user asks about agents, wants to see what's in their folders, "
|
||||
"or mentions agents alongside folders."
|
||||
)
|
||||
|
||||
@property
|
||||
def requires_auth(self) -> bool:
|
||||
return True
|
||||
|
||||
@property
|
||||
def parameters(self) -> dict[str, Any]:
|
||||
return {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"parent_id": {
|
||||
"type": "string",
|
||||
"description": (
|
||||
"List children of this folder. "
|
||||
"Omit to get the full folder tree."
|
||||
),
|
||||
},
|
||||
"include_agents": {
|
||||
"type": "boolean",
|
||||
"description": (
|
||||
"Whether to include the list of agents inside each folder. "
|
||||
"Defaults to false."
|
||||
),
|
||||
},
|
||||
},
|
||||
"required": [],
|
||||
}
|
||||
|
||||
async def _execute(
|
||||
self, user_id: str | None, session: ChatSession, **kwargs
|
||||
) -> ToolResponseBase:
|
||||
"""List folders as a flat list (by parent) or full tree."""
|
||||
assert user_id is not None # guaranteed by requires_auth
|
||||
parent_id = kwargs.get("parent_id")
|
||||
include_agents = kwargs.get("include_agents", False)
|
||||
session_id = session.session_id if session else None
|
||||
|
||||
try:
|
||||
if parent_id:
|
||||
folders = await library_db().list_folders(
|
||||
user_id=user_id, parent_id=parent_id
|
||||
)
|
||||
raw_map = (
|
||||
await library_db().get_folder_agents_map(
|
||||
user_id, [f.id for f in folders]
|
||||
)
|
||||
if include_agents
|
||||
else None
|
||||
)
|
||||
agents_map = _to_agent_summaries_map(raw_map) if raw_map else None
|
||||
return FolderListResponse(
|
||||
message=f"Found {len(folders)} folder(s).",
|
||||
folders=[
|
||||
_folder_to_info(f, agents_map.get(f.id) if agents_map else None)
|
||||
for f in folders
|
||||
],
|
||||
count=len(folders),
|
||||
session_id=session_id,
|
||||
)
|
||||
else:
|
||||
tree = await library_db().get_folder_tree(user_id=user_id)
|
||||
all_ids = collect_tree_ids(tree)
|
||||
agents_map = None
|
||||
root_agents = None
|
||||
if include_agents:
|
||||
raw_map = await library_db().get_folder_agents_map(user_id, all_ids)
|
||||
agents_map = _to_agent_summaries_map(raw_map)
|
||||
root_agents = _to_agent_summaries(
|
||||
await library_db().get_root_agent_summaries(user_id)
|
||||
)
|
||||
return FolderListResponse(
|
||||
message=f"Found {len(all_ids)} folder(s) in your library.",
|
||||
tree=[_tree_to_info(t, agents_map) for t in tree],
|
||||
root_agents=root_agents,
|
||||
count=len(all_ids),
|
||||
session_id=session_id,
|
||||
)
|
||||
except Exception as e:
|
||||
return ErrorResponse(
|
||||
message=f"Failed to list folders: {e}",
|
||||
error="list_folders_failed",
|
||||
session_id=session_id,
|
||||
)
|
||||
|
||||
|
||||
class UpdateFolderTool(BaseTool):
|
||||
"""Tool for updating a folder's properties."""
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "update_folder"
|
||||
|
||||
@property
|
||||
def description(self) -> str:
|
||||
return "Update a folder's name, icon, or color."
|
||||
|
||||
@property
|
||||
def requires_auth(self) -> bool:
|
||||
return True
|
||||
|
||||
@property
|
||||
def parameters(self) -> dict[str, Any]:
|
||||
return {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"folder_id": {
|
||||
"type": "string",
|
||||
"description": "ID of the folder to update.",
|
||||
},
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "New name for the folder.",
|
||||
},
|
||||
"icon": {
|
||||
"type": "string",
|
||||
"description": "New icon identifier.",
|
||||
},
|
||||
"color": {
|
||||
"type": "string",
|
||||
"description": "New hex color code (#RRGGBB).",
|
||||
},
|
||||
},
|
||||
"required": ["folder_id"],
|
||||
}
|
||||
|
||||
async def _execute(
|
||||
self, user_id: str | None, session: ChatSession, **kwargs
|
||||
) -> ToolResponseBase:
|
||||
"""Update a folder's name, icon, or color."""
|
||||
assert user_id is not None # guaranteed by requires_auth
|
||||
folder_id = (kwargs.get("folder_id") or "").strip()
|
||||
name = kwargs.get("name")
|
||||
icon = kwargs.get("icon")
|
||||
color = kwargs.get("color")
|
||||
session_id = session.session_id if session else None
|
||||
|
||||
if not folder_id:
|
||||
return ErrorResponse(
|
||||
message="Please provide a folder_id.",
|
||||
error="missing_folder_id",
|
||||
session_id=session_id,
|
||||
)
|
||||
|
||||
try:
|
||||
folder = await library_db().update_folder(
|
||||
folder_id=folder_id,
|
||||
user_id=user_id,
|
||||
name=name,
|
||||
icon=icon,
|
||||
color=color,
|
||||
)
|
||||
except Exception as e:
|
||||
return ErrorResponse(
|
||||
message=f"Failed to update folder: {e}",
|
||||
error="update_folder_failed",
|
||||
session_id=session_id,
|
||||
)
|
||||
|
||||
return FolderUpdatedResponse(
|
||||
message=f"Folder updated to '{folder.name}'.",
|
||||
folder=_folder_to_info(folder),
|
||||
session_id=session_id,
|
||||
)
|
||||
|
||||
|
||||
class MoveFolderTool(BaseTool):
|
||||
"""Tool for moving a folder to a new parent."""
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "move_folder"
|
||||
|
||||
@property
|
||||
def description(self) -> str:
|
||||
return (
|
||||
"Move a folder to a different parent folder. "
|
||||
"Set target_parent_id to null to move to root level."
|
||||
)
|
||||
|
||||
@property
|
||||
def requires_auth(self) -> bool:
|
||||
return True
|
||||
|
||||
@property
|
||||
def parameters(self) -> dict[str, Any]:
|
||||
return {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"folder_id": {
|
||||
"type": "string",
|
||||
"description": "ID of the folder to move.",
|
||||
},
|
||||
"target_parent_id": {
|
||||
"type": ["string", "null"],
|
||||
"description": (
|
||||
"ID of the new parent folder. "
|
||||
"Use null to move to root level."
|
||||
),
|
||||
},
|
||||
},
|
||||
"required": ["folder_id"],
|
||||
}
|
||||
|
||||
async def _execute(
|
||||
self, user_id: str | None, session: ChatSession, **kwargs
|
||||
) -> ToolResponseBase:
|
||||
"""Move a folder to a new parent or to root level."""
|
||||
assert user_id is not None # guaranteed by requires_auth
|
||||
folder_id = (kwargs.get("folder_id") or "").strip()
|
||||
target_parent_id = kwargs.get("target_parent_id")
|
||||
session_id = session.session_id if session else None
|
||||
|
||||
if not folder_id:
|
||||
return ErrorResponse(
|
||||
message="Please provide a folder_id.",
|
||||
error="missing_folder_id",
|
||||
session_id=session_id,
|
||||
)
|
||||
|
||||
try:
|
||||
folder = await library_db().move_folder(
|
||||
folder_id=folder_id,
|
||||
user_id=user_id,
|
||||
target_parent_id=target_parent_id,
|
||||
)
|
||||
except Exception as e:
|
||||
return ErrorResponse(
|
||||
message=f"Failed to move folder: {e}",
|
||||
error="move_folder_failed",
|
||||
session_id=session_id,
|
||||
)
|
||||
|
||||
dest = "a subfolder" if target_parent_id else "root level"
|
||||
return FolderMovedResponse(
|
||||
message=f"Folder '{folder.name}' moved to {dest}.",
|
||||
folder=_folder_to_info(folder),
|
||||
target_parent_id=target_parent_id,
|
||||
session_id=session_id,
|
||||
)
|
||||
|
||||
|
||||
class DeleteFolderTool(BaseTool):
|
||||
"""Tool for deleting a folder."""
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "delete_folder"
|
||||
|
||||
@property
|
||||
def description(self) -> str:
|
||||
return (
|
||||
"Delete a folder from the user's library. "
|
||||
"Agents inside the folder are moved to root level (not deleted)."
|
||||
)
|
||||
|
||||
@property
|
||||
def requires_auth(self) -> bool:
|
||||
return True
|
||||
|
||||
@property
|
||||
def parameters(self) -> dict[str, Any]:
|
||||
return {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"folder_id": {
|
||||
"type": "string",
|
||||
"description": "ID of the folder to delete.",
|
||||
},
|
||||
},
|
||||
"required": ["folder_id"],
|
||||
}
|
||||
|
||||
async def _execute(
|
||||
self, user_id: str | None, session: ChatSession, **kwargs
|
||||
) -> ToolResponseBase:
|
||||
"""Soft-delete a folder; agents inside are moved to root level."""
|
||||
assert user_id is not None # guaranteed by requires_auth
|
||||
folder_id = (kwargs.get("folder_id") or "").strip()
|
||||
session_id = session.session_id if session else None
|
||||
|
||||
if not folder_id:
|
||||
return ErrorResponse(
|
||||
message="Please provide a folder_id.",
|
||||
error="missing_folder_id",
|
||||
session_id=session_id,
|
||||
)
|
||||
|
||||
try:
|
||||
await library_db().delete_folder(
|
||||
folder_id=folder_id,
|
||||
user_id=user_id,
|
||||
soft_delete=True,
|
||||
)
|
||||
except Exception as e:
|
||||
return ErrorResponse(
|
||||
message=f"Failed to delete folder: {e}",
|
||||
error="delete_folder_failed",
|
||||
session_id=session_id,
|
||||
)
|
||||
|
||||
return FolderDeletedResponse(
|
||||
message="Folder deleted. Any agents inside were moved to root level.",
|
||||
folder_id=folder_id,
|
||||
session_id=session_id,
|
||||
)
|
||||
|
||||
|
||||
class MoveAgentsToFolderTool(BaseTool):
|
||||
"""Tool for moving agents into a folder."""
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "move_agents_to_folder"
|
||||
|
||||
@property
|
||||
def description(self) -> str:
|
||||
return (
|
||||
"Move one or more agents to a folder. "
|
||||
"Set folder_id to null to move agents to root level."
|
||||
)
|
||||
|
||||
@property
|
||||
def requires_auth(self) -> bool:
|
||||
return True
|
||||
|
||||
@property
|
||||
def parameters(self) -> dict[str, Any]:
|
||||
return {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"agent_ids": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"description": "List of library agent IDs to move.",
|
||||
},
|
||||
"folder_id": {
|
||||
"type": ["string", "null"],
|
||||
"description": (
|
||||
"Target folder ID. Use null to move to root level."
|
||||
),
|
||||
},
|
||||
},
|
||||
"required": ["agent_ids"],
|
||||
}
|
||||
|
||||
async def _execute(
|
||||
self, user_id: str | None, session: ChatSession, **kwargs
|
||||
) -> ToolResponseBase:
|
||||
"""Move one or more agents to a folder or to root level."""
|
||||
assert user_id is not None # guaranteed by requires_auth
|
||||
agent_ids = kwargs.get("agent_ids", [])
|
||||
folder_id = kwargs.get("folder_id")
|
||||
session_id = session.session_id if session else None
|
||||
|
||||
if not agent_ids:
|
||||
return ErrorResponse(
|
||||
message="Please provide at least one agent ID.",
|
||||
error="missing_agent_ids",
|
||||
session_id=session_id,
|
||||
)
|
||||
|
||||
try:
|
||||
moved = await library_db().bulk_move_agents_to_folder(
|
||||
agent_ids=agent_ids,
|
||||
folder_id=folder_id,
|
||||
user_id=user_id,
|
||||
)
|
||||
except Exception as e:
|
||||
return ErrorResponse(
|
||||
message=f"Failed to move agents: {e}",
|
||||
error="move_agents_failed",
|
||||
session_id=session_id,
|
||||
)
|
||||
|
||||
moved_ids = [a.id for a in moved]
|
||||
agent_names = [a.name for a in moved]
|
||||
dest = "the folder" if folder_id else "root level"
|
||||
names_str = (
|
||||
", ".join(agent_names) if agent_names else f"{len(agent_ids)} agent(s)"
|
||||
)
|
||||
return AgentsMovedToFolderResponse(
|
||||
message=f"Moved {names_str} to {dest}.",
|
||||
agent_ids=moved_ids,
|
||||
agent_names=agent_names,
|
||||
folder_id=folder_id,
|
||||
count=len(moved),
|
||||
session_id=session_id,
|
||||
)
|
||||
@@ -0,0 +1,455 @@
|
||||
"""Tests for folder management copilot tools."""
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from backend.api.features.library import model as library_model
|
||||
from backend.copilot.tools.manage_folders import (
|
||||
CreateFolderTool,
|
||||
DeleteFolderTool,
|
||||
ListFoldersTool,
|
||||
MoveAgentsToFolderTool,
|
||||
MoveFolderTool,
|
||||
UpdateFolderTool,
|
||||
)
|
||||
from backend.copilot.tools.models import (
|
||||
AgentsMovedToFolderResponse,
|
||||
ErrorResponse,
|
||||
FolderCreatedResponse,
|
||||
FolderDeletedResponse,
|
||||
FolderListResponse,
|
||||
FolderMovedResponse,
|
||||
FolderUpdatedResponse,
|
||||
)
|
||||
|
||||
from ._test_data import make_session
|
||||
|
||||
_TEST_USER_ID = "test-user-folders"
|
||||
_NOW = datetime.now(UTC)
|
||||
|
||||
|
||||
def _make_folder(
|
||||
id: str = "folder-1",
|
||||
name: str = "My Folder",
|
||||
parent_id: str | None = None,
|
||||
icon: str | None = None,
|
||||
color: str | None = None,
|
||||
agent_count: int = 0,
|
||||
subfolder_count: int = 0,
|
||||
) -> library_model.LibraryFolder:
|
||||
return library_model.LibraryFolder(
|
||||
id=id,
|
||||
user_id=_TEST_USER_ID,
|
||||
name=name,
|
||||
icon=icon,
|
||||
color=color,
|
||||
parent_id=parent_id,
|
||||
created_at=_NOW,
|
||||
updated_at=_NOW,
|
||||
agent_count=agent_count,
|
||||
subfolder_count=subfolder_count,
|
||||
)
|
||||
|
||||
|
||||
def _make_tree(
|
||||
id: str = "folder-1",
|
||||
name: str = "Root",
|
||||
children: list[library_model.LibraryFolderTree] | None = None,
|
||||
) -> library_model.LibraryFolderTree:
|
||||
return library_model.LibraryFolderTree(
|
||||
id=id,
|
||||
user_id=_TEST_USER_ID,
|
||||
name=name,
|
||||
created_at=_NOW,
|
||||
updated_at=_NOW,
|
||||
children=children or [],
|
||||
)
|
||||
|
||||
|
||||
def _make_library_agent(id: str = "agent-1", name: str = "Test Agent"):
|
||||
agent = MagicMock()
|
||||
agent.id = id
|
||||
agent.name = name
|
||||
return agent
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def session():
|
||||
return make_session(_TEST_USER_ID)
|
||||
|
||||
|
||||
# ── CreateFolderTool ──
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def create_tool():
|
||||
return CreateFolderTool()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_folder_missing_name(create_tool, session):
|
||||
result = await create_tool._execute(user_id=_TEST_USER_ID, session=session, name="")
|
||||
assert isinstance(result, ErrorResponse)
|
||||
assert result.error == "missing_name"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_folder_none_name(create_tool, session):
|
||||
result = await create_tool._execute(
|
||||
user_id=_TEST_USER_ID, session=session, name=None
|
||||
)
|
||||
assert isinstance(result, ErrorResponse)
|
||||
assert result.error == "missing_name"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_folder_success(create_tool, session):
|
||||
folder = _make_folder(name="New Folder")
|
||||
with patch("backend.copilot.tools.manage_folders.library_db") as mock_lib:
|
||||
mock_lib.return_value.create_folder = AsyncMock(return_value=folder)
|
||||
result = await create_tool._execute(
|
||||
user_id=_TEST_USER_ID, session=session, name="New Folder"
|
||||
)
|
||||
|
||||
assert isinstance(result, FolderCreatedResponse)
|
||||
assert result.folder.name == "New Folder"
|
||||
assert "New Folder" in result.message
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_folder_db_error(create_tool, session):
|
||||
with patch("backend.copilot.tools.manage_folders.library_db") as mock_lib:
|
||||
mock_lib.return_value.create_folder = AsyncMock(
|
||||
side_effect=Exception("db down")
|
||||
)
|
||||
result = await create_tool._execute(
|
||||
user_id=_TEST_USER_ID, session=session, name="Folder"
|
||||
)
|
||||
|
||||
assert isinstance(result, ErrorResponse)
|
||||
assert result.error == "create_folder_failed"
|
||||
|
||||
|
||||
# ── ListFoldersTool ──
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def list_tool():
|
||||
return ListFoldersTool()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_folders_by_parent(list_tool, session):
|
||||
folders = [_make_folder(id="f1", name="A"), _make_folder(id="f2", name="B")]
|
||||
with patch("backend.copilot.tools.manage_folders.library_db") as mock_lib:
|
||||
mock_lib.return_value.list_folders = AsyncMock(return_value=folders)
|
||||
result = await list_tool._execute(
|
||||
user_id=_TEST_USER_ID, session=session, parent_id="parent-1"
|
||||
)
|
||||
|
||||
assert isinstance(result, FolderListResponse)
|
||||
assert result.count == 2
|
||||
assert len(result.folders) == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_folders_tree(list_tool, session):
|
||||
tree = [
|
||||
_make_tree(id="r1", name="Root", children=[_make_tree(id="c1", name="Child")])
|
||||
]
|
||||
with patch("backend.copilot.tools.manage_folders.library_db") as mock_lib:
|
||||
mock_lib.return_value.get_folder_tree = AsyncMock(return_value=tree)
|
||||
result = await list_tool._execute(user_id=_TEST_USER_ID, session=session)
|
||||
|
||||
assert isinstance(result, FolderListResponse)
|
||||
assert result.count == 2 # root + child
|
||||
assert result.tree is not None
|
||||
assert len(result.tree) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_folders_tree_with_agents_includes_root(list_tool, session):
|
||||
tree = [_make_tree(id="r1", name="Root")]
|
||||
raw_map = {"r1": [{"id": "a1", "name": "Foldered", "description": "In folder"}]}
|
||||
root_raw = [{"id": "a2", "name": "Loose Agent", "description": "At root"}]
|
||||
with patch("backend.copilot.tools.manage_folders.library_db") as mock_lib:
|
||||
mock_lib.return_value.get_folder_tree = AsyncMock(return_value=tree)
|
||||
mock_lib.return_value.get_folder_agents_map = AsyncMock(return_value=raw_map)
|
||||
mock_lib.return_value.get_root_agent_summaries = AsyncMock(
|
||||
return_value=root_raw
|
||||
)
|
||||
result = await list_tool._execute(
|
||||
user_id=_TEST_USER_ID, session=session, include_agents=True
|
||||
)
|
||||
|
||||
assert isinstance(result, FolderListResponse)
|
||||
assert result.root_agents is not None
|
||||
assert len(result.root_agents) == 1
|
||||
assert result.root_agents[0].name == "Loose Agent"
|
||||
assert result.tree is not None
|
||||
assert result.tree[0].agents is not None
|
||||
assert result.tree[0].agents[0].name == "Foldered"
|
||||
mock_lib.return_value.get_root_agent_summaries.assert_awaited_once_with(
|
||||
_TEST_USER_ID
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_folders_tree_without_agents_no_root(list_tool, session):
|
||||
tree = [_make_tree(id="r1", name="Root")]
|
||||
with patch("backend.copilot.tools.manage_folders.library_db") as mock_lib:
|
||||
mock_lib.return_value.get_folder_tree = AsyncMock(return_value=tree)
|
||||
result = await list_tool._execute(
|
||||
user_id=_TEST_USER_ID, session=session, include_agents=False
|
||||
)
|
||||
|
||||
assert isinstance(result, FolderListResponse)
|
||||
assert result.root_agents is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_folders_db_error(list_tool, session):
|
||||
with patch("backend.copilot.tools.manage_folders.library_db") as mock_lib:
|
||||
mock_lib.return_value.get_folder_tree = AsyncMock(
|
||||
side_effect=Exception("timeout")
|
||||
)
|
||||
result = await list_tool._execute(user_id=_TEST_USER_ID, session=session)
|
||||
|
||||
assert isinstance(result, ErrorResponse)
|
||||
assert result.error == "list_folders_failed"
|
||||
|
||||
|
||||
# ── UpdateFolderTool ──
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def update_tool():
|
||||
return UpdateFolderTool()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_folder_missing_id(update_tool, session):
|
||||
result = await update_tool._execute(
|
||||
user_id=_TEST_USER_ID, session=session, folder_id=""
|
||||
)
|
||||
assert isinstance(result, ErrorResponse)
|
||||
assert result.error == "missing_folder_id"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_folder_none_id(update_tool, session):
|
||||
result = await update_tool._execute(
|
||||
user_id=_TEST_USER_ID, session=session, folder_id=None
|
||||
)
|
||||
assert isinstance(result, ErrorResponse)
|
||||
assert result.error == "missing_folder_id"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_folder_success(update_tool, session):
|
||||
folder = _make_folder(name="Renamed")
|
||||
with patch("backend.copilot.tools.manage_folders.library_db") as mock_lib:
|
||||
mock_lib.return_value.update_folder = AsyncMock(return_value=folder)
|
||||
result = await update_tool._execute(
|
||||
user_id=_TEST_USER_ID, session=session, folder_id="folder-1", name="Renamed"
|
||||
)
|
||||
|
||||
assert isinstance(result, FolderUpdatedResponse)
|
||||
assert result.folder.name == "Renamed"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_folder_db_error(update_tool, session):
|
||||
with patch("backend.copilot.tools.manage_folders.library_db") as mock_lib:
|
||||
mock_lib.return_value.update_folder = AsyncMock(
|
||||
side_effect=Exception("not found")
|
||||
)
|
||||
result = await update_tool._execute(
|
||||
user_id=_TEST_USER_ID, session=session, folder_id="folder-1", name="X"
|
||||
)
|
||||
|
||||
assert isinstance(result, ErrorResponse)
|
||||
assert result.error == "update_folder_failed"
|
||||
|
||||
|
||||
# ── MoveFolderTool ──
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def move_tool():
|
||||
return MoveFolderTool()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_move_folder_missing_id(move_tool, session):
|
||||
result = await move_tool._execute(
|
||||
user_id=_TEST_USER_ID, session=session, folder_id=""
|
||||
)
|
||||
assert isinstance(result, ErrorResponse)
|
||||
assert result.error == "missing_folder_id"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_move_folder_to_parent(move_tool, session):
|
||||
folder = _make_folder(name="Moved")
|
||||
with patch("backend.copilot.tools.manage_folders.library_db") as mock_lib:
|
||||
mock_lib.return_value.move_folder = AsyncMock(return_value=folder)
|
||||
result = await move_tool._execute(
|
||||
user_id=_TEST_USER_ID,
|
||||
session=session,
|
||||
folder_id="folder-1",
|
||||
target_parent_id="parent-1",
|
||||
)
|
||||
|
||||
assert isinstance(result, FolderMovedResponse)
|
||||
assert "subfolder" in result.message
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_move_folder_to_root(move_tool, session):
|
||||
folder = _make_folder(name="Moved")
|
||||
with patch("backend.copilot.tools.manage_folders.library_db") as mock_lib:
|
||||
mock_lib.return_value.move_folder = AsyncMock(return_value=folder)
|
||||
result = await move_tool._execute(
|
||||
user_id=_TEST_USER_ID,
|
||||
session=session,
|
||||
folder_id="folder-1",
|
||||
target_parent_id=None,
|
||||
)
|
||||
|
||||
assert isinstance(result, FolderMovedResponse)
|
||||
assert "root level" in result.message
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_move_folder_db_error(move_tool, session):
|
||||
with patch("backend.copilot.tools.manage_folders.library_db") as mock_lib:
|
||||
mock_lib.return_value.move_folder = AsyncMock(side_effect=Exception("circular"))
|
||||
result = await move_tool._execute(
|
||||
user_id=_TEST_USER_ID, session=session, folder_id="folder-1"
|
||||
)
|
||||
|
||||
assert isinstance(result, ErrorResponse)
|
||||
assert result.error == "move_folder_failed"
|
||||
|
||||
|
||||
# ── DeleteFolderTool ──
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def delete_tool():
|
||||
return DeleteFolderTool()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_folder_missing_id(delete_tool, session):
|
||||
result = await delete_tool._execute(
|
||||
user_id=_TEST_USER_ID, session=session, folder_id=""
|
||||
)
|
||||
assert isinstance(result, ErrorResponse)
|
||||
assert result.error == "missing_folder_id"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_folder_success(delete_tool, session):
|
||||
with patch("backend.copilot.tools.manage_folders.library_db") as mock_lib:
|
||||
mock_lib.return_value.delete_folder = AsyncMock(return_value=None)
|
||||
result = await delete_tool._execute(
|
||||
user_id=_TEST_USER_ID, session=session, folder_id="folder-1"
|
||||
)
|
||||
|
||||
assert isinstance(result, FolderDeletedResponse)
|
||||
assert result.folder_id == "folder-1"
|
||||
assert "root level" in result.message
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_folder_db_error(delete_tool, session):
|
||||
with patch("backend.copilot.tools.manage_folders.library_db") as mock_lib:
|
||||
mock_lib.return_value.delete_folder = AsyncMock(
|
||||
side_effect=Exception("permission denied")
|
||||
)
|
||||
result = await delete_tool._execute(
|
||||
user_id=_TEST_USER_ID, session=session, folder_id="folder-1"
|
||||
)
|
||||
|
||||
assert isinstance(result, ErrorResponse)
|
||||
assert result.error == "delete_folder_failed"
|
||||
|
||||
|
||||
# ── MoveAgentsToFolderTool ──
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def move_agents_tool():
|
||||
return MoveAgentsToFolderTool()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_move_agents_missing_ids(move_agents_tool, session):
|
||||
result = await move_agents_tool._execute(
|
||||
user_id=_TEST_USER_ID, session=session, agent_ids=[]
|
||||
)
|
||||
assert isinstance(result, ErrorResponse)
|
||||
assert result.error == "missing_agent_ids"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_move_agents_success(move_agents_tool, session):
|
||||
agents = [
|
||||
_make_library_agent(id="a1", name="Agent Alpha"),
|
||||
_make_library_agent(id="a2", name="Agent Beta"),
|
||||
]
|
||||
with patch("backend.copilot.tools.manage_folders.library_db") as mock_lib:
|
||||
mock_lib.return_value.bulk_move_agents_to_folder = AsyncMock(
|
||||
return_value=agents
|
||||
)
|
||||
result = await move_agents_tool._execute(
|
||||
user_id=_TEST_USER_ID,
|
||||
session=session,
|
||||
agent_ids=["a1", "a2"],
|
||||
folder_id="folder-1",
|
||||
)
|
||||
|
||||
assert isinstance(result, AgentsMovedToFolderResponse)
|
||||
assert result.count == 2
|
||||
assert result.agent_names == ["Agent Alpha", "Agent Beta"]
|
||||
assert "Agent Alpha" in result.message
|
||||
assert "Agent Beta" in result.message
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_move_agents_to_root(move_agents_tool, session):
|
||||
agents = [_make_library_agent(id="a1", name="Agent One")]
|
||||
with patch("backend.copilot.tools.manage_folders.library_db") as mock_lib:
|
||||
mock_lib.return_value.bulk_move_agents_to_folder = AsyncMock(
|
||||
return_value=agents
|
||||
)
|
||||
result = await move_agents_tool._execute(
|
||||
user_id=_TEST_USER_ID,
|
||||
session=session,
|
||||
agent_ids=["a1"],
|
||||
folder_id=None,
|
||||
)
|
||||
|
||||
assert isinstance(result, AgentsMovedToFolderResponse)
|
||||
assert "root level" in result.message
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_move_agents_db_error(move_agents_tool, session):
|
||||
with patch("backend.copilot.tools.manage_folders.library_db") as mock_lib:
|
||||
mock_lib.return_value.bulk_move_agents_to_folder = AsyncMock(
|
||||
side_effect=Exception("folder not found")
|
||||
)
|
||||
result = await move_agents_tool._execute(
|
||||
user_id=_TEST_USER_ID,
|
||||
session=session,
|
||||
agent_ids=["a1"],
|
||||
folder_id="bad-folder",
|
||||
)
|
||||
|
||||
assert isinstance(result, ErrorResponse)
|
||||
assert result.error == "move_agents_failed"
|
||||
@@ -55,6 +55,13 @@ class ResponseType(str, Enum):
|
||||
# MCP tool types
|
||||
MCP_TOOLS_DISCOVERED = "mcp_tools_discovered"
|
||||
MCP_TOOL_OUTPUT = "mcp_tool_output"
|
||||
# Folder management types
|
||||
FOLDER_CREATED = "folder_created"
|
||||
FOLDER_LIST = "folder_list"
|
||||
FOLDER_UPDATED = "folder_updated"
|
||||
FOLDER_MOVED = "folder_moved"
|
||||
FOLDER_DELETED = "folder_deleted"
|
||||
AGENTS_MOVED_TO_FOLDER = "agents_moved_to_folder"
|
||||
|
||||
|
||||
# Base response model
|
||||
@@ -539,3 +546,82 @@ class BrowserScreenshotResponse(ToolResponseBase):
|
||||
type: ResponseType = ResponseType.BROWSER_SCREENSHOT
|
||||
file_id: str # Workspace file ID — use read_workspace_file to retrieve
|
||||
filename: str
|
||||
|
||||
|
||||
# Folder management models
|
||||
|
||||
|
||||
class FolderAgentSummary(BaseModel):
|
||||
"""Lightweight agent info for folder listings."""
|
||||
|
||||
id: str
|
||||
name: str
|
||||
description: str = ""
|
||||
|
||||
|
||||
class FolderInfo(BaseModel):
|
||||
"""Information about a folder."""
|
||||
|
||||
id: str
|
||||
name: str
|
||||
parent_id: str | None = None
|
||||
icon: str | None = None
|
||||
color: str | None = None
|
||||
agent_count: int = 0
|
||||
subfolder_count: int = 0
|
||||
agents: list[FolderAgentSummary] | None = None
|
||||
|
||||
|
||||
class FolderTreeInfo(FolderInfo):
|
||||
"""Folder with nested children for tree display."""
|
||||
|
||||
children: list["FolderTreeInfo"] = []
|
||||
|
||||
|
||||
class FolderCreatedResponse(ToolResponseBase):
|
||||
"""Response when a folder is created."""
|
||||
|
||||
type: ResponseType = ResponseType.FOLDER_CREATED
|
||||
folder: FolderInfo
|
||||
|
||||
|
||||
class FolderListResponse(ToolResponseBase):
|
||||
"""Response for listing folders."""
|
||||
|
||||
type: ResponseType = ResponseType.FOLDER_LIST
|
||||
folders: list[FolderInfo] = Field(default_factory=list)
|
||||
tree: list[FolderTreeInfo] | None = None
|
||||
root_agents: list[FolderAgentSummary] | None = None
|
||||
count: int = 0
|
||||
|
||||
|
||||
class FolderUpdatedResponse(ToolResponseBase):
|
||||
"""Response when a folder is updated."""
|
||||
|
||||
type: ResponseType = ResponseType.FOLDER_UPDATED
|
||||
folder: FolderInfo
|
||||
|
||||
|
||||
class FolderMovedResponse(ToolResponseBase):
|
||||
"""Response when a folder is moved."""
|
||||
|
||||
type: ResponseType = ResponseType.FOLDER_MOVED
|
||||
folder: FolderInfo
|
||||
target_parent_id: str | None = None
|
||||
|
||||
|
||||
class FolderDeletedResponse(ToolResponseBase):
|
||||
"""Response when a folder is deleted."""
|
||||
|
||||
type: ResponseType = ResponseType.FOLDER_DELETED
|
||||
folder_id: str
|
||||
|
||||
|
||||
class AgentsMovedToFolderResponse(ToolResponseBase):
|
||||
"""Response when agents are moved to a folder."""
|
||||
|
||||
type: ResponseType = ResponseType.AGENTS_MOVED_TO_FOLDER
|
||||
agent_ids: list[str]
|
||||
agent_names: list[str] = []
|
||||
folder_id: str | None = None
|
||||
count: int = 0
|
||||
|
||||
@@ -4,11 +4,20 @@ from typing import TYPE_CHECKING, Callable, Concatenate, ParamSpec, TypeVar, cas
|
||||
|
||||
from backend.api.features.library.db import (
|
||||
add_store_agent_to_library,
|
||||
bulk_move_agents_to_folder,
|
||||
create_folder,
|
||||
create_graph_in_library,
|
||||
create_library_agent,
|
||||
delete_folder,
|
||||
get_folder_agents_map,
|
||||
get_folder_tree,
|
||||
get_library_agent,
|
||||
get_library_agent_by_graph_id,
|
||||
get_root_agent_summaries,
|
||||
list_folders,
|
||||
list_library_agents,
|
||||
move_folder,
|
||||
update_folder,
|
||||
update_graph_in_library,
|
||||
)
|
||||
from backend.api.features.store.db import (
|
||||
@@ -260,6 +269,16 @@ class DatabaseManager(AppService):
|
||||
update_graph_in_library = _(update_graph_in_library)
|
||||
validate_graph_execution_permissions = _(validate_graph_execution_permissions)
|
||||
|
||||
create_folder = _(create_folder)
|
||||
list_folders = _(list_folders)
|
||||
get_folder_tree = _(get_folder_tree)
|
||||
update_folder = _(update_folder)
|
||||
move_folder = _(move_folder)
|
||||
delete_folder = _(delete_folder)
|
||||
bulk_move_agents_to_folder = _(bulk_move_agents_to_folder)
|
||||
get_folder_agents_map = _(get_folder_agents_map)
|
||||
get_root_agent_summaries = _(get_root_agent_summaries)
|
||||
|
||||
# ============ Onboarding ============ #
|
||||
increment_onboarding_runs = _(increment_onboarding_runs)
|
||||
|
||||
@@ -434,6 +453,17 @@ class DatabaseManagerAsyncClient(AppServiceClient):
|
||||
update_graph_in_library = d.update_graph_in_library
|
||||
validate_graph_execution_permissions = d.validate_graph_execution_permissions
|
||||
|
||||
# ============ Library Folders ============ #
|
||||
create_folder = d.create_folder
|
||||
list_folders = d.list_folders
|
||||
get_folder_tree = d.get_folder_tree
|
||||
update_folder = d.update_folder
|
||||
move_folder = d.move_folder
|
||||
delete_folder = d.delete_folder
|
||||
bulk_move_agents_to_folder = d.bulk_move_agents_to_folder
|
||||
get_folder_agents_map = d.get_folder_agents_map
|
||||
get_root_agent_summaries = d.get_root_agent_summaries
|
||||
|
||||
# ============ Onboarding ============ #
|
||||
increment_onboarding_runs = d.increment_onboarding_runs
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
"cssVariables": false,
|
||||
"prefix": ""
|
||||
},
|
||||
"iconLibrary": "radix",
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils"
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
SearchFeatureRequestsTool,
|
||||
} from "../../../tools/FeatureRequests/FeatureRequests";
|
||||
import { FindAgentsTool } from "../../../tools/FindAgents/FindAgents";
|
||||
import { FolderTool } from "../../../tools/FolderTool/FolderTool";
|
||||
import { FindBlocksTool } from "../../../tools/FindBlocks/FindBlocks";
|
||||
import { GenericTool } from "../../../tools/GenericTool/GenericTool";
|
||||
import { RunAgentTool } from "../../../tools/RunAgent/RunAgent";
|
||||
@@ -145,6 +146,13 @@ export function MessagePartRenderer({ part, messageID, partIndex }: Props) {
|
||||
return <SearchFeatureRequestsTool key={key} part={part as ToolUIPart} />;
|
||||
case "tool-create_feature_request":
|
||||
return <CreateFeatureRequestTool key={key} part={part as ToolUIPart} />;
|
||||
case "tool-create_folder":
|
||||
case "tool-list_folders":
|
||||
case "tool-update_folder":
|
||||
case "tool-move_folder":
|
||||
case "tool-delete_folder":
|
||||
case "tool-move_agents_to_folder":
|
||||
return <FolderTool key={key} part={part as ToolUIPart} />;
|
||||
default:
|
||||
// Render a generic tool indicator for SDK built-in
|
||||
// tools (Read, Glob, Grep, etc.) or any unrecognized tool
|
||||
|
||||
@@ -0,0 +1,296 @@
|
||||
"use client";
|
||||
|
||||
import type { ToolUIPart } from "ai";
|
||||
import {
|
||||
FileIcon,
|
||||
FolderIcon,
|
||||
FolderPlusIcon,
|
||||
FoldersIcon,
|
||||
TrashIcon,
|
||||
WarningDiamondIcon,
|
||||
} from "@phosphor-icons/react";
|
||||
import {
|
||||
File as TreeFile,
|
||||
Folder as TreeFolder,
|
||||
Tree,
|
||||
type TreeViewElement,
|
||||
} from "@/components/molecules/file-tree";
|
||||
import { MorphingTextAnimation } from "../../components/MorphingTextAnimation/MorphingTextAnimation";
|
||||
import { ToolAccordion } from "../../components/ToolAccordion/ToolAccordion";
|
||||
import {
|
||||
ContentCard,
|
||||
ContentCardHeader,
|
||||
ContentCardTitle,
|
||||
ContentGrid,
|
||||
ContentHint,
|
||||
ContentMessage,
|
||||
} from "../../components/ToolAccordion/AccordionContent";
|
||||
import { OrbitLoader } from "../../components/OrbitLoader/OrbitLoader";
|
||||
import {
|
||||
getAnimationText,
|
||||
getFolderToolOutput,
|
||||
isAgentsMoved,
|
||||
isErrorOutput,
|
||||
isFolderCreated,
|
||||
isFolderDeleted,
|
||||
isFolderList,
|
||||
isFolderMoved,
|
||||
isFolderUpdated,
|
||||
type FolderInfo,
|
||||
type FolderToolOutput,
|
||||
type FolderTreeInfo,
|
||||
} from "./helpers";
|
||||
|
||||
interface Props {
|
||||
part: ToolUIPart;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Icons */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
function ToolStatusIcon({
|
||||
isStreaming,
|
||||
isError,
|
||||
}: {
|
||||
isStreaming: boolean;
|
||||
isError: boolean;
|
||||
}) {
|
||||
if (isError) {
|
||||
return (
|
||||
<WarningDiamondIcon size={14} weight="regular" className="text-red-500" />
|
||||
);
|
||||
}
|
||||
if (isStreaming) {
|
||||
return <OrbitLoader size={14} />;
|
||||
}
|
||||
return <FolderIcon size={14} weight="regular" className="text-neutral-400" />;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Folder card */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
function FolderCard({ folder }: { folder: FolderInfo }) {
|
||||
return (
|
||||
<ContentCard>
|
||||
<ContentCardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
{folder.color ? (
|
||||
<span
|
||||
className="inline-block h-3 w-3 rounded-full"
|
||||
style={{ backgroundColor: folder.color }}
|
||||
/>
|
||||
) : (
|
||||
<FolderIcon size={14} weight="fill" className="text-neutral-600" />
|
||||
)}
|
||||
<ContentCardTitle>{folder.name}</ContentCardTitle>
|
||||
</div>
|
||||
</ContentCardHeader>
|
||||
<ContentHint>
|
||||
{folder.agent_count} agent{folder.agent_count !== 1 ? "s" : ""}
|
||||
{folder.subfolder_count > 0 &&
|
||||
` · ${folder.subfolder_count} subfolder${folder.subfolder_count !== 1 ? "s" : ""}`}
|
||||
</ContentHint>
|
||||
{folder.agents && folder.agents.length > 0 && (
|
||||
<div className="mt-2 space-y-1 border-t border-neutral-200 pt-2">
|
||||
{folder.agents.map((a) => (
|
||||
<div key={a.id} className="flex items-center gap-1.5">
|
||||
<FileIcon
|
||||
size={12}
|
||||
weight="duotone"
|
||||
className="text-neutral-600"
|
||||
/>
|
||||
<span className="text-xs text-zinc-600">{a.name}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</ContentCard>
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Tree renderer using file-tree component */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
type TreeNode = TreeViewElement & { isAgent?: boolean };
|
||||
|
||||
function folderTreeToElements(nodes: FolderTreeInfo[]): TreeNode[] {
|
||||
return nodes.map((node) => {
|
||||
const children: TreeNode[] = [
|
||||
...folderTreeToElements(node.children),
|
||||
...(node.agents ?? []).map((a) => ({
|
||||
id: a.id,
|
||||
name: a.name,
|
||||
isAgent: true,
|
||||
})),
|
||||
];
|
||||
return {
|
||||
id: node.id,
|
||||
name: `${node.name} (${node.agent_count} agent${node.agent_count !== 1 ? "s" : ""})`,
|
||||
children: children.length > 0 ? children : undefined,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function collectAllIDs(nodes: FolderTreeInfo[]): string[] {
|
||||
return nodes.flatMap((n) => [n.id, ...collectAllIDs(n.children)]);
|
||||
}
|
||||
|
||||
function FolderTreeView({ tree }: { tree: FolderTreeInfo[] }) {
|
||||
const elements = folderTreeToElements(tree);
|
||||
const allIDs = collectAllIDs(tree);
|
||||
|
||||
return (
|
||||
<Tree
|
||||
initialExpandedItems={allIDs}
|
||||
elements={elements}
|
||||
openIcon={
|
||||
<FolderIcon size={16} weight="fill" className="text-neutral-600" />
|
||||
}
|
||||
closeIcon={
|
||||
<FolderIcon size={16} weight="duotone" className="text-neutral-600" />
|
||||
}
|
||||
className="max-h-64"
|
||||
>
|
||||
{elements.map((el) => (
|
||||
<FolderTreeNodes key={el.id} element={el} />
|
||||
))}
|
||||
</Tree>
|
||||
);
|
||||
}
|
||||
|
||||
function FolderTreeNodes({ element }: { element: TreeNode }) {
|
||||
if (element.isAgent) {
|
||||
return (
|
||||
<TreeFile
|
||||
value={element.id}
|
||||
fileIcon={
|
||||
<FileIcon size={14} weight="duotone" className="text-neutral-600" />
|
||||
}
|
||||
>
|
||||
<span className="text-sm text-zinc-700">{element.name}</span>
|
||||
</TreeFile>
|
||||
);
|
||||
}
|
||||
|
||||
if (element.children && element.children.length > 0) {
|
||||
return (
|
||||
<TreeFolder value={element.id} element={element.name} isSelectable>
|
||||
{element.children.map((child) => (
|
||||
<FolderTreeNodes key={child.id} element={child as TreeNode} />
|
||||
))}
|
||||
</TreeFolder>
|
||||
);
|
||||
}
|
||||
|
||||
return <TreeFolder value={element.id} element={element.name} isSelectable />;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Accordion content per output type */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
function AccordionContent({ output }: { output: FolderToolOutput }) {
|
||||
if (isFolderCreated(output)) {
|
||||
return (
|
||||
<ContentGrid>
|
||||
<FolderCard folder={output.folder} />
|
||||
</ContentGrid>
|
||||
);
|
||||
}
|
||||
|
||||
if (isFolderList(output)) {
|
||||
if (output.tree && output.tree.length > 0) {
|
||||
return <FolderTreeView tree={output.tree} />;
|
||||
}
|
||||
if (output.folders && output.folders.length > 0) {
|
||||
return (
|
||||
<ContentGrid className="sm:grid-cols-2">
|
||||
{output.folders.map((folder) => (
|
||||
<FolderCard key={folder.id} folder={folder} />
|
||||
))}
|
||||
</ContentGrid>
|
||||
);
|
||||
}
|
||||
return <ContentMessage>No folders found.</ContentMessage>;
|
||||
}
|
||||
|
||||
if (isFolderUpdated(output) || isFolderMoved(output)) {
|
||||
return (
|
||||
<ContentGrid>
|
||||
<FolderCard folder={output.folder} />
|
||||
</ContentGrid>
|
||||
);
|
||||
}
|
||||
|
||||
if (isFolderDeleted(output)) {
|
||||
return <ContentMessage>{output.message}</ContentMessage>;
|
||||
}
|
||||
|
||||
if (isAgentsMoved(output)) {
|
||||
return <ContentMessage>{output.message}</ContentMessage>;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Main component */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
function getAccordionTitle(output: FolderToolOutput): string {
|
||||
if (isFolderCreated(output)) return `Created "${output.folder.name}"`;
|
||||
if (isFolderList(output))
|
||||
return `${output.count} folder${output.count !== 1 ? "s" : ""}`;
|
||||
if (isFolderUpdated(output)) return `Updated "${output.folder.name}"`;
|
||||
if (isFolderMoved(output)) return `Moved "${output.folder.name}"`;
|
||||
if (isFolderDeleted(output)) return "Folder deleted";
|
||||
if (isAgentsMoved(output))
|
||||
return `Moved ${output.count} agent${output.count !== 1 ? "s" : ""}`;
|
||||
return "Folder operation";
|
||||
}
|
||||
|
||||
function getAccordionIcon(output: FolderToolOutput) {
|
||||
if (isFolderCreated(output))
|
||||
return <FolderPlusIcon size={32} weight="light" />;
|
||||
if (isFolderList(output)) return <FoldersIcon size={32} weight="light" />;
|
||||
if (isFolderDeleted(output)) return <TrashIcon size={32} weight="light" />;
|
||||
return <FolderIcon size={32} weight="light" />;
|
||||
}
|
||||
|
||||
export function FolderTool({ part }: Props) {
|
||||
const text = getAnimationText(part);
|
||||
const output = getFolderToolOutput(part);
|
||||
|
||||
const isStreaming =
|
||||
part.state === "input-streaming" || part.state === "input-available";
|
||||
const isError =
|
||||
part.state === "output-error" || (!!output && isErrorOutput(output));
|
||||
|
||||
const hasContent =
|
||||
part.state === "output-available" && !!output && !isErrorOutput(output);
|
||||
|
||||
return (
|
||||
<div className="py-2">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<ToolStatusIcon isStreaming={isStreaming} isError={isError} />
|
||||
<MorphingTextAnimation
|
||||
text={text}
|
||||
className={isError ? "text-red-500" : undefined}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{hasContent && output && (
|
||||
<ToolAccordion
|
||||
icon={getAccordionIcon(output)}
|
||||
title={getAccordionTitle(output)}
|
||||
defaultExpanded={isFolderList(output)}
|
||||
>
|
||||
<AccordionContent output={output} />
|
||||
</ToolAccordion>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
import type { ToolUIPart } from "ai";
|
||||
|
||||
interface FolderAgentSummary {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
interface FolderInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
parent_id?: string | null;
|
||||
icon?: string | null;
|
||||
color?: string | null;
|
||||
agent_count: number;
|
||||
subfolder_count: number;
|
||||
agents?: FolderAgentSummary[] | null;
|
||||
}
|
||||
|
||||
interface FolderTreeInfo extends FolderInfo {
|
||||
children: FolderTreeInfo[];
|
||||
}
|
||||
|
||||
interface FolderCreatedOutput {
|
||||
type: "folder_created";
|
||||
message: string;
|
||||
folder: FolderInfo;
|
||||
}
|
||||
|
||||
interface FolderListOutput {
|
||||
type: "folder_list";
|
||||
message: string;
|
||||
folders?: FolderInfo[];
|
||||
tree?: FolderTreeInfo[];
|
||||
count: number;
|
||||
}
|
||||
|
||||
interface FolderUpdatedOutput {
|
||||
type: "folder_updated";
|
||||
message: string;
|
||||
folder: FolderInfo;
|
||||
}
|
||||
|
||||
interface FolderMovedOutput {
|
||||
type: "folder_moved";
|
||||
message: string;
|
||||
folder: FolderInfo;
|
||||
target_parent_id?: string | null;
|
||||
}
|
||||
|
||||
interface FolderDeletedOutput {
|
||||
type: "folder_deleted";
|
||||
message: string;
|
||||
folder_id: string;
|
||||
}
|
||||
|
||||
interface AgentsMovedOutput {
|
||||
type: "agents_moved_to_folder";
|
||||
message: string;
|
||||
agent_ids: string[];
|
||||
folder_id?: string | null;
|
||||
count: number;
|
||||
}
|
||||
|
||||
interface ErrorOutput {
|
||||
type: "error";
|
||||
message: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export type FolderToolOutput =
|
||||
| FolderCreatedOutput
|
||||
| FolderListOutput
|
||||
| FolderUpdatedOutput
|
||||
| FolderMovedOutput
|
||||
| FolderDeletedOutput
|
||||
| AgentsMovedOutput
|
||||
| ErrorOutput;
|
||||
|
||||
export type { FolderAgentSummary, FolderInfo, FolderTreeInfo };
|
||||
|
||||
function parseOutput(output: unknown): FolderToolOutput | null {
|
||||
if (!output) return null;
|
||||
if (typeof output === "string") {
|
||||
const trimmed = output.trim();
|
||||
if (!trimmed) return null;
|
||||
try {
|
||||
return parseOutput(JSON.parse(trimmed) as unknown);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
if (typeof output === "object") {
|
||||
const obj = output as Record<string, unknown>;
|
||||
if (typeof obj.type === "string") {
|
||||
return output as FolderToolOutput;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getFolderToolOutput(part: {
|
||||
output?: unknown;
|
||||
}): FolderToolOutput | null {
|
||||
return parseOutput(part.output);
|
||||
}
|
||||
|
||||
export function isFolderCreated(o: FolderToolOutput): o is FolderCreatedOutput {
|
||||
return o.type === "folder_created";
|
||||
}
|
||||
|
||||
export function isFolderList(o: FolderToolOutput): o is FolderListOutput {
|
||||
return o.type === "folder_list";
|
||||
}
|
||||
|
||||
export function isFolderUpdated(o: FolderToolOutput): o is FolderUpdatedOutput {
|
||||
return o.type === "folder_updated";
|
||||
}
|
||||
|
||||
export function isFolderMoved(o: FolderToolOutput): o is FolderMovedOutput {
|
||||
return o.type === "folder_moved";
|
||||
}
|
||||
|
||||
export function isFolderDeleted(o: FolderToolOutput): o is FolderDeletedOutput {
|
||||
return o.type === "folder_deleted";
|
||||
}
|
||||
|
||||
export function isAgentsMoved(o: FolderToolOutput): o is AgentsMovedOutput {
|
||||
return o.type === "agents_moved_to_folder";
|
||||
}
|
||||
|
||||
export function isErrorOutput(o: FolderToolOutput): o is ErrorOutput {
|
||||
return o.type === "error";
|
||||
}
|
||||
|
||||
export function getAnimationText(part: {
|
||||
type: string;
|
||||
state: ToolUIPart["state"];
|
||||
output?: unknown;
|
||||
}): string {
|
||||
const toolName = part.type.replace(/^tool-/, "");
|
||||
|
||||
switch (part.state) {
|
||||
case "input-streaming":
|
||||
case "input-available": {
|
||||
switch (toolName) {
|
||||
case "create_folder":
|
||||
return "Creating folder…";
|
||||
case "list_folders":
|
||||
return "Loading folders…";
|
||||
case "update_folder":
|
||||
return "Updating folder…";
|
||||
case "move_folder":
|
||||
return "Moving folder…";
|
||||
case "delete_folder":
|
||||
return "Deleting folder…";
|
||||
case "move_agents_to_folder":
|
||||
return "Moving agents…";
|
||||
default:
|
||||
return "Managing folders…";
|
||||
}
|
||||
}
|
||||
case "output-available": {
|
||||
const output = getFolderToolOutput(part);
|
||||
if (!output) return "Done";
|
||||
if (isErrorOutput(output)) return "Folder operation failed";
|
||||
return output.message;
|
||||
}
|
||||
case "output-error":
|
||||
return "Folder operation failed";
|
||||
default:
|
||||
return "Managing folders…";
|
||||
}
|
||||
}
|
||||
@@ -11544,7 +11544,13 @@
|
||||
"feature_request_created",
|
||||
"suggested_goal",
|
||||
"mcp_tools_discovered",
|
||||
"mcp_tool_output"
|
||||
"mcp_tool_output",
|
||||
"folder_created",
|
||||
"folder_list",
|
||||
"folder_updated",
|
||||
"folder_moved",
|
||||
"folder_deleted",
|
||||
"agents_moved_to_folder"
|
||||
],
|
||||
"title": "ResponseType",
|
||||
"description": "Types of tool responses."
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
import type { Meta } from "@storybook/nextjs";
|
||||
import type { ComponentProps } from "react";
|
||||
import { Tree, Folder, File, type TreeViewElement } from "./file-tree";
|
||||
|
||||
const meta: Meta<typeof Tree> = {
|
||||
title: "Molecules/FileTree",
|
||||
component: Tree,
|
||||
parameters: {
|
||||
layout: "centered",
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
const SIMPLE_ELEMENTS: TreeViewElement[] = [
|
||||
{
|
||||
id: "src",
|
||||
name: "src",
|
||||
children: [
|
||||
{ id: "app", name: "app", children: [{ id: "page", name: "page.tsx" }] },
|
||||
{
|
||||
id: "components",
|
||||
name: "components",
|
||||
children: [
|
||||
{ id: "button", name: "Button.tsx" },
|
||||
{ id: "input", name: "Input.tsx" },
|
||||
],
|
||||
},
|
||||
{ id: "utils", name: "utils.ts" },
|
||||
],
|
||||
},
|
||||
{ id: "package", name: "package.json" },
|
||||
{ id: "readme", name: "README.md" },
|
||||
];
|
||||
|
||||
function SimpleFileTree(props: Omit<ComponentProps<typeof Tree>, "children">) {
|
||||
return (
|
||||
<Tree {...props}>
|
||||
<Folder value="src" element="src">
|
||||
<Folder value="app" element="app">
|
||||
<File value="page">page.tsx</File>
|
||||
</Folder>
|
||||
<Folder value="components" element="components">
|
||||
<File value="button">Button.tsx</File>
|
||||
<File value="input">Input.tsx</File>
|
||||
</Folder>
|
||||
<File value="utils">utils.ts</File>
|
||||
</Folder>
|
||||
<File value="package">package.json</File>
|
||||
<File value="readme">README.md</File>
|
||||
</Tree>
|
||||
);
|
||||
}
|
||||
|
||||
export function Default() {
|
||||
return (
|
||||
<div className="w-72">
|
||||
<SimpleFileTree
|
||||
elements={SIMPLE_ELEMENTS}
|
||||
initialExpandedItems={["src"]}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AllExpanded() {
|
||||
return (
|
||||
<div className="w-72">
|
||||
<SimpleFileTree
|
||||
elements={SIMPLE_ELEMENTS}
|
||||
initialExpandedItems={["src", "app", "components"]}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function FoldersOnly() {
|
||||
return (
|
||||
<div className="w-72">
|
||||
<Tree
|
||||
initialExpandedItems={["marketing", "engineering"]}
|
||||
elements={[
|
||||
{
|
||||
id: "marketing",
|
||||
name: "Marketing",
|
||||
children: [{ id: "social", name: "Social Media" }],
|
||||
},
|
||||
{
|
||||
id: "engineering",
|
||||
name: "Engineering",
|
||||
children: [
|
||||
{ id: "backend", name: "Backend" },
|
||||
{ id: "frontend", name: "Frontend" },
|
||||
],
|
||||
},
|
||||
{ id: "sales", name: "Sales" },
|
||||
]}
|
||||
>
|
||||
<Folder value="marketing" element="Marketing">
|
||||
<Folder value="social" element="Social Media" />
|
||||
</Folder>
|
||||
<Folder value="engineering" element="Engineering">
|
||||
<Folder value="backend" element="Backend" />
|
||||
<Folder value="frontend" element="Frontend" />
|
||||
</Folder>
|
||||
<Folder value="sales" element="Sales" />
|
||||
</Tree>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function WithInitialSelection() {
|
||||
return (
|
||||
<div className="w-72">
|
||||
<SimpleFileTree
|
||||
elements={SIMPLE_ELEMENTS}
|
||||
initialSelectedId="button"
|
||||
initialExpandedItems={["src", "components"]}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function NoIndicator() {
|
||||
return (
|
||||
<div className="w-72">
|
||||
<SimpleFileTree
|
||||
elements={SIMPLE_ELEMENTS}
|
||||
indicator={false}
|
||||
initialExpandedItems={["src", "components"]}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
393
autogpt_platform/frontend/src/components/molecules/file-tree.tsx
Normal file
393
autogpt_platform/frontend/src/components/molecules/file-tree.tsx
Normal file
@@ -0,0 +1,393 @@
|
||||
"use client";
|
||||
|
||||
import React, {
|
||||
createContext,
|
||||
forwardRef,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useState,
|
||||
} from "react";
|
||||
import * as AccordionPrimitive from "@radix-ui/react-accordion";
|
||||
import {
|
||||
File as PhFileIcon,
|
||||
Folder as PhFolderIcon,
|
||||
FolderOpen as PhFolderOpenIcon,
|
||||
} from "@phosphor-icons/react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
|
||||
type TreeViewElement = {
|
||||
id: string;
|
||||
name: string;
|
||||
isSelectable?: boolean;
|
||||
children?: TreeViewElement[];
|
||||
};
|
||||
|
||||
type TreeContextProps = {
|
||||
selectedId: string | undefined;
|
||||
expandedItems: string[] | undefined;
|
||||
indicator: boolean;
|
||||
handleExpand: (id: string) => void;
|
||||
selectItem: (id: string) => void;
|
||||
setExpandedItems?: React.Dispatch<React.SetStateAction<string[] | undefined>>;
|
||||
openIcon?: React.ReactNode;
|
||||
closeIcon?: React.ReactNode;
|
||||
direction: "rtl" | "ltr";
|
||||
};
|
||||
|
||||
const TreeContext = createContext<TreeContextProps | null>(null);
|
||||
|
||||
const useTree = () => {
|
||||
const context = useContext(TreeContext);
|
||||
if (!context) {
|
||||
throw new Error("useTree must be used within a TreeProvider");
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
type Direction = "rtl" | "ltr" | undefined;
|
||||
|
||||
type TreeViewProps = {
|
||||
initialSelectedId?: string;
|
||||
indicator?: boolean;
|
||||
elements?: TreeViewElement[];
|
||||
initialExpandedItems?: string[];
|
||||
openIcon?: React.ReactNode;
|
||||
closeIcon?: React.ReactNode;
|
||||
} & React.HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
const Tree = forwardRef<HTMLDivElement, TreeViewProps>(
|
||||
(
|
||||
{
|
||||
className,
|
||||
elements,
|
||||
initialSelectedId,
|
||||
initialExpandedItems,
|
||||
children,
|
||||
indicator = true,
|
||||
openIcon,
|
||||
closeIcon,
|
||||
dir,
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const [selectedId, setSelectedId] = useState<string | undefined>(
|
||||
initialSelectedId,
|
||||
);
|
||||
const [expandedItems, setExpandedItems] = useState<string[] | undefined>(
|
||||
initialExpandedItems,
|
||||
);
|
||||
|
||||
const selectItem = useCallback((id: string) => {
|
||||
setSelectedId(id);
|
||||
}, []);
|
||||
|
||||
const handleExpand = useCallback((id: string) => {
|
||||
setExpandedItems((prev) => {
|
||||
if (prev?.includes(id)) {
|
||||
return prev.filter((item) => item !== id);
|
||||
}
|
||||
return [...(prev ?? []), id];
|
||||
});
|
||||
}, []);
|
||||
|
||||
const expandSpecificTargetedElements = useCallback(
|
||||
(elements?: TreeViewElement[], selectId?: string) => {
|
||||
if (!elements || !selectId) return;
|
||||
const findParent = (
|
||||
currentElement: TreeViewElement,
|
||||
currentPath: string[] = [],
|
||||
) => {
|
||||
const isSelectable = currentElement.isSelectable ?? true;
|
||||
const newPath = [...currentPath, currentElement.id];
|
||||
if (currentElement.id === selectId) {
|
||||
if (isSelectable) {
|
||||
setExpandedItems((prev) => [...(prev ?? []), ...newPath]);
|
||||
} else {
|
||||
if (newPath.includes(currentElement.id)) {
|
||||
newPath.pop();
|
||||
setExpandedItems((prev) => [...(prev ?? []), ...newPath]);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (
|
||||
isSelectable &&
|
||||
currentElement.children &&
|
||||
currentElement.children.length > 0
|
||||
) {
|
||||
currentElement.children.forEach((child) => {
|
||||
findParent(child, newPath);
|
||||
});
|
||||
}
|
||||
};
|
||||
elements.forEach((element) => {
|
||||
findParent(element);
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (initialSelectedId) {
|
||||
expandSpecificTargetedElements(elements, initialSelectedId);
|
||||
}
|
||||
}, [initialSelectedId, elements]);
|
||||
|
||||
const direction = dir === "rtl" ? "rtl" : "ltr";
|
||||
|
||||
return (
|
||||
<TreeContext.Provider
|
||||
value={{
|
||||
selectedId,
|
||||
expandedItems,
|
||||
handleExpand,
|
||||
selectItem,
|
||||
setExpandedItems,
|
||||
indicator,
|
||||
openIcon,
|
||||
closeIcon,
|
||||
direction,
|
||||
}}
|
||||
>
|
||||
<div className={cn("size-full", className)}>
|
||||
<ScrollArea
|
||||
ref={ref}
|
||||
className="relative h-full px-2"
|
||||
dir={dir as Direction}
|
||||
>
|
||||
<AccordionPrimitive.Root
|
||||
{...props}
|
||||
type="multiple"
|
||||
defaultValue={expandedItems}
|
||||
value={expandedItems}
|
||||
className="flex flex-col gap-1"
|
||||
onValueChange={(value) =>
|
||||
setExpandedItems((prev) => [...(prev ?? []), value[0]])
|
||||
}
|
||||
dir={dir as Direction}
|
||||
>
|
||||
{children}
|
||||
</AccordionPrimitive.Root>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</TreeContext.Provider>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
Tree.displayName = "Tree";
|
||||
|
||||
const TreeIndicator = forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => {
|
||||
const { direction } = useTree();
|
||||
|
||||
return (
|
||||
<div
|
||||
dir={direction}
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"absolute left-1.5 h-full w-px rounded-md bg-neutral-400 py-3 duration-300 ease-in-out hover:bg-neutral-600 dark:bg-neutral-500 rtl:right-1.5",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
TreeIndicator.displayName = "TreeIndicator";
|
||||
|
||||
type FolderProps = {
|
||||
expandedItems?: string[];
|
||||
element: string;
|
||||
isSelectable?: boolean;
|
||||
isSelect?: boolean;
|
||||
} & React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item>;
|
||||
|
||||
const Folder = forwardRef<
|
||||
HTMLDivElement,
|
||||
FolderProps & React.HTMLAttributes<HTMLDivElement>
|
||||
>(
|
||||
(
|
||||
{
|
||||
className,
|
||||
element,
|
||||
value,
|
||||
isSelectable = true,
|
||||
isSelect,
|
||||
children,
|
||||
...props
|
||||
},
|
||||
_ref,
|
||||
) => {
|
||||
const {
|
||||
direction,
|
||||
handleExpand,
|
||||
expandedItems,
|
||||
indicator,
|
||||
setExpandedItems,
|
||||
openIcon,
|
||||
closeIcon,
|
||||
} = useTree();
|
||||
|
||||
return (
|
||||
<AccordionPrimitive.Item
|
||||
{...props}
|
||||
value={value}
|
||||
className="relative h-full overflow-hidden"
|
||||
>
|
||||
<AccordionPrimitive.Trigger
|
||||
className={cn(
|
||||
`flex items-center gap-1 rounded-md text-sm`,
|
||||
className,
|
||||
{
|
||||
"rounded-md bg-neutral-100 dark:bg-neutral-800":
|
||||
isSelect && isSelectable,
|
||||
"cursor-pointer": isSelectable,
|
||||
"cursor-not-allowed opacity-50": !isSelectable,
|
||||
},
|
||||
)}
|
||||
disabled={!isSelectable}
|
||||
onClick={() => handleExpand(value)}
|
||||
>
|
||||
{expandedItems?.includes(value)
|
||||
? (openIcon ?? <PhFolderOpenIcon className="size-4" />)
|
||||
: (closeIcon ?? <PhFolderIcon className="size-4" />)}
|
||||
<span>{element}</span>
|
||||
</AccordionPrimitive.Trigger>
|
||||
<AccordionPrimitive.Content className="relative h-full overflow-hidden text-sm data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down">
|
||||
{element && indicator && <TreeIndicator aria-hidden="true" />}
|
||||
<AccordionPrimitive.Root
|
||||
dir={direction}
|
||||
type="multiple"
|
||||
className="ml-5 flex flex-col gap-1 py-1 rtl:mr-5"
|
||||
defaultValue={expandedItems}
|
||||
value={expandedItems}
|
||||
onValueChange={(value) => {
|
||||
setExpandedItems?.((prev) => [...(prev ?? []), value[0]]);
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</AccordionPrimitive.Root>
|
||||
</AccordionPrimitive.Content>
|
||||
</AccordionPrimitive.Item>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
Folder.displayName = "Folder";
|
||||
|
||||
const File = forwardRef<
|
||||
HTMLButtonElement,
|
||||
{
|
||||
value: string;
|
||||
handleSelect?: (id: string) => void;
|
||||
isSelectable?: boolean;
|
||||
isSelect?: boolean;
|
||||
fileIcon?: React.ReactNode;
|
||||
} & React.ButtonHTMLAttributes<HTMLButtonElement>
|
||||
>(
|
||||
(
|
||||
{
|
||||
value,
|
||||
className,
|
||||
handleSelect: _handleSelect,
|
||||
isSelectable = true,
|
||||
isSelect,
|
||||
fileIcon,
|
||||
children,
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const { direction, selectedId, selectItem } = useTree();
|
||||
const isSelected = isSelect ?? selectedId === value;
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
type="button"
|
||||
disabled={!isSelectable}
|
||||
className={cn(
|
||||
"flex w-fit items-center gap-1 rounded-md pr-1 text-sm duration-200 ease-in-out rtl:pl-1 rtl:pr-0",
|
||||
{
|
||||
"bg-neutral-100 dark:bg-neutral-800": isSelected && isSelectable,
|
||||
},
|
||||
isSelectable ? "cursor-pointer" : "cursor-not-allowed opacity-50",
|
||||
direction === "rtl" ? "rtl" : "ltr",
|
||||
className,
|
||||
)}
|
||||
onClick={() => selectItem(value)}
|
||||
{...props}
|
||||
>
|
||||
{fileIcon ?? <PhFileIcon className="size-4" />}
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
File.displayName = "File";
|
||||
|
||||
const CollapseButton = forwardRef<
|
||||
HTMLButtonElement,
|
||||
{
|
||||
elements: TreeViewElement[];
|
||||
expandAll?: boolean;
|
||||
} & React.HTMLAttributes<HTMLButtonElement>
|
||||
>(
|
||||
(
|
||||
{ className: _className, elements, expandAll = false, children, ...props },
|
||||
ref,
|
||||
) => {
|
||||
const { expandedItems, setExpandedItems } = useTree();
|
||||
|
||||
const expendAllTree = useCallback((elements: TreeViewElement[]) => {
|
||||
const expandTree = (element: TreeViewElement) => {
|
||||
const isSelectable = element.isSelectable ?? true;
|
||||
if (isSelectable && element.children && element.children.length > 0) {
|
||||
setExpandedItems?.((prev) => [...(prev ?? []), element.id]);
|
||||
element.children.forEach(expandTree);
|
||||
}
|
||||
};
|
||||
|
||||
elements.forEach(expandTree);
|
||||
}, []);
|
||||
|
||||
const closeAll = useCallback(() => {
|
||||
setExpandedItems?.([]);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (expandAll) {
|
||||
expendAllTree(elements);
|
||||
}
|
||||
}, [expandAll]);
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant={"ghost"}
|
||||
className="absolute bottom-1 right-2 h-8 w-fit p-1"
|
||||
onClick={
|
||||
expandedItems && expandedItems.length > 0
|
||||
? closeAll
|
||||
: () => expendAllTree(elements)
|
||||
}
|
||||
ref={ref}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<span className="sr-only">Toggle</span>
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
CollapseButton.displayName = "CollapseButton";
|
||||
|
||||
export { CollapseButton, File, Folder, Tree, type TreeViewElement };
|
||||
57
autogpt_platform/frontend/src/components/ui/scroll-area.tsx
Normal file
57
autogpt_platform/frontend/src/components/ui/scroll-area.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const ScrollArea = React.forwardRef<
|
||||
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root> & {
|
||||
orientation?: "vertical" | "horizontal" | "both";
|
||||
}
|
||||
>(({ className, children, orientation = "vertical", ...props }, ref) => (
|
||||
<ScrollAreaPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn("relative", className)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.Viewport
|
||||
className="h-full w-full rounded-[inherit]"
|
||||
style={{
|
||||
overflowX: orientation === "vertical" ? "hidden" : "scroll",
|
||||
overflowY: orientation === "horizontal" ? "hidden" : "scroll",
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</ScrollAreaPrimitive.Viewport>
|
||||
{orientation !== "horizontal" && <ScrollBar />}
|
||||
{orientation !== "vertical" && <ScrollBar orientation="horizontal" />}
|
||||
<ScrollAreaPrimitive.Corner />
|
||||
</ScrollAreaPrimitive.Root>
|
||||
));
|
||||
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName;
|
||||
|
||||
const ScrollBar = React.forwardRef<
|
||||
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
|
||||
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
>(({ className, orientation = "vertical", ...props }, ref) => (
|
||||
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
||||
ref={ref}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"flex touch-none select-none transition-colors",
|
||||
orientation === "vertical" &&
|
||||
"h-full w-2.5 border-l border-l-transparent p-[1px]",
|
||||
orientation === "horizontal" &&
|
||||
"h-2.5 flex-col border-t border-t-transparent p-[1px]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-neutral-200 dark:bg-neutral-800" />
|
||||
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
));
|
||||
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName;
|
||||
|
||||
export { ScrollArea, ScrollBar };
|
||||
Reference in New Issue
Block a user