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:
Abhimanyu Yadav
2026-03-06 20:29:03 +05:30
committed by GitHub
parent aa08063939
commit 0f813f1bf9
17 changed files with 2330 additions and 6 deletions

View File

@@ -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 #############
##############################################

View File

@@ -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(),

View File

@@ -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]:

View File

@@ -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(

View File

@@ -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(

View 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,
)

View File

@@ -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"

View File

@@ -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

View File

@@ -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

View File

@@ -10,6 +10,7 @@
"cssVariables": false,
"prefix": ""
},
"iconLibrary": "radix",
"aliases": {
"components": "@/components",
"utils": "@/lib/utils"

View File

@@ -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

View File

@@ -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>
);
}

View File

@@ -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…";
}
}

View File

@@ -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."

View File

@@ -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>
);
}

View 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 };

View 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 };