Compare commits

..

6 Commits

Author SHA1 Message Date
Lluis Agusti
1090f90d95 chore: clean ups 2026-02-23 20:56:08 +08:00
Lluis Agusti
a7c9a3c5ae fix(frontend): address CodeRabbit review comments
- MarkdownRenderer: add null guard for empty image src
- GenericTool: use composite key for todo items
- ViewAgentOutput/BlockOutputCard: use parent key + index instead of bare index
- SidebarItemCard: extract onKeyDown to named function declaration

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 20:49:21 +08:00
Lluis Agusti
ec06c1278a ci(frontend): add react-doctor CI job with score threshold + fix remaining warnings
Add react-doctor to frontend CI pipeline with a minimum score threshold of 90.
Fix additional a11y and React pattern warnings (autoFocus removal, missing
roles/keyboard handlers) to bring score from 93 to 95.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 19:33:37 +08:00
Lluis Agusti
e2525cb8a8 Merge 'dev' into 'chore/react-doctor' 2026-02-23 19:09:31 +08:00
Lluis Agusti
02a3a163e7 fix: restore autoFocus attributes
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 22:54:55 +08:00
Lluis Agusti
d9d24dcfe6 chore: wip 2026-02-19 22:14:37 +08:00
68 changed files with 1114 additions and 4658 deletions

View File

@@ -83,6 +83,65 @@ jobs:
- name: Run lint
run: pnpm lint
react-doctor:
runs-on: ubuntu-latest
needs: setup
steps:
- name: Checkout repository
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Enable corepack
run: corepack enable
- name: Set up Node
uses: actions/setup-node@v6
with:
node-version: "22.18.0"
cache: "pnpm"
cache-dependency-path: autogpt_platform/frontend/pnpm-lock.yaml
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Run React Doctor
id: react-doctor
continue-on-error: true
run: |
OUTPUT=$(pnpm react-doctor:diff 2>&1) || true
echo "$OUTPUT"
SCORE=$(echo "$OUTPUT" | grep -oP '\d+(?= / 100)' | head -1)
echo "score=${SCORE:-0}" >> "$GITHUB_OUTPUT"
- name: Check React Doctor score
env:
RD_SCORE: ${{ steps.react-doctor.outputs.score }}
MIN_SCORE: "90"
run: |
echo "React Doctor score: ${RD_SCORE}/100 (minimum: ${MIN_SCORE})"
if [ "${RD_SCORE}" -lt "${MIN_SCORE}" ]; then
echo "::error::React Doctor score ${RD_SCORE} is below the minimum threshold of ${MIN_SCORE}."
echo ""
echo "=========================================="
echo " React Doctor score too low!"
echo "=========================================="
echo ""
echo "To fix these issues, run Claude Code locally:"
echo ""
echo " cd autogpt_platform/frontend"
echo " claude"
echo ""
echo "Then ask Claude to run react-doctor and fix the issues."
echo "You can also run it manually:"
echo ""
echo " pnpm react-doctor # scan all files"
echo " pnpm react-doctor:diff # scan only changed files"
echo ""
exit 1
fi
chromatic:
runs-on: ubuntu-latest
needs: setup

File diff suppressed because it is too large Load Diff

View File

@@ -144,7 +144,6 @@ async def test_add_agent_to_library(mocker):
)
mock_library_agent = mocker.patch("prisma.models.LibraryAgent.prisma")
mock_library_agent.return_value.find_first = mocker.AsyncMock(return_value=None)
mock_library_agent.return_value.find_unique = mocker.AsyncMock(return_value=None)
mock_library_agent.return_value.create = mocker.AsyncMock(
return_value=mock_library_agent_data
@@ -179,6 +178,7 @@ async def test_add_agent_to_library(mocker):
"agentGraphVersion": 1,
}
},
include={"AgentGraph": True},
)
# Check that create was called with the expected data including settings
create_call_args = mock_library_agent.return_value.create.call_args

View File

@@ -1,10 +0,0 @@
class FolderValidationError(Exception):
"""Raised when folder operations fail validation."""
pass
class FolderAlreadyExistsError(FolderValidationError):
"""Raised when a folder with the same name already exists in the location."""
pass

View File

@@ -26,95 +26,6 @@ class LibraryAgentStatus(str, Enum):
ERROR = "ERROR"
# === Folder Models ===
class LibraryFolder(pydantic.BaseModel):
"""Represents a folder for organizing library agents."""
id: str
user_id: str
name: str
icon: str | None = None
color: str | None = None
parent_id: str | None = None
created_at: datetime.datetime
updated_at: datetime.datetime
agent_count: int = 0 # Direct agents in folder
subfolder_count: int = 0 # Direct child folders
@staticmethod
def from_db(
folder: prisma.models.LibraryFolder,
agent_count: int = 0,
subfolder_count: int = 0,
) -> "LibraryFolder":
"""Factory method that constructs a LibraryFolder from a Prisma model."""
return LibraryFolder(
id=folder.id,
user_id=folder.userId,
name=folder.name,
icon=folder.icon,
color=folder.color,
parent_id=folder.parentId,
created_at=folder.createdAt,
updated_at=folder.updatedAt,
agent_count=agent_count,
subfolder_count=subfolder_count,
)
class LibraryFolderTree(LibraryFolder):
"""Folder with nested children for tree view."""
children: list["LibraryFolderTree"] = []
class FolderCreateRequest(pydantic.BaseModel):
"""Request model for creating a folder."""
name: str = pydantic.Field(..., min_length=1, max_length=100)
icon: str | None = None
color: str | None = pydantic.Field(
None, pattern=r"^#[0-9A-Fa-f]{6}$", description="Hex color code (#RRGGBB)"
)
parent_id: str | None = None
class FolderUpdateRequest(pydantic.BaseModel):
"""Request model for updating a folder."""
name: str | None = pydantic.Field(None, min_length=1, max_length=100)
icon: str | None = None
color: str | None = None
class FolderMoveRequest(pydantic.BaseModel):
"""Request model for moving a folder to a new parent."""
target_parent_id: str | None = None # None = move to root
class BulkMoveAgentsRequest(pydantic.BaseModel):
"""Request model for moving multiple agents to a folder."""
agent_ids: list[str]
folder_id: str | None = None # None = move to root
class FolderListResponse(pydantic.BaseModel):
"""Response schema for a list of folders."""
folders: list[LibraryFolder]
pagination: Pagination
class FolderTreeResponse(pydantic.BaseModel):
"""Response schema for folder tree structure."""
tree: list[LibraryFolderTree]
class MarketplaceListingCreator(pydantic.BaseModel):
"""Creator information for a marketplace listing."""
@@ -209,9 +120,6 @@ class LibraryAgent(pydantic.BaseModel):
can_access_graph: bool
is_latest_version: bool
is_favorite: bool
folder_id: str | None = None
folder_name: str | None = None # Denormalized for display
recommended_schedule_cron: str | None = None
settings: GraphSettings = pydantic.Field(default_factory=GraphSettings)
marketplace_listing: Optional["MarketplaceListing"] = None
@@ -351,8 +259,6 @@ class LibraryAgent(pydantic.BaseModel):
can_access_graph=can_access_graph,
is_latest_version=is_latest_version,
is_favorite=agent.isFavorite,
folder_id=agent.folderId,
folder_name=agent.Folder.name if agent.Folder else None,
recommended_schedule_cron=agent.AgentGraph.recommendedScheduleCron,
settings=_parse_settings(agent.settings),
marketplace_listing=marketplace_listing_data,
@@ -564,7 +470,3 @@ class LibraryAgentUpdateRequest(pydantic.BaseModel):
settings: Optional[GraphSettings] = pydantic.Field(
default=None, description="User-specific settings for this library agent"
)
folder_id: Optional[str] = pydantic.Field(
default=None,
description="Folder ID to move agent to (None to move to root)",
)

View File

@@ -1,11 +1,9 @@
import fastapi
from .agents import router as agents_router
from .folders import router as folders_router
from .presets import router as presets_router
router = fastapi.APIRouter()
router.include_router(presets_router)
router.include_router(folders_router)
router.include_router(agents_router)

View File

@@ -41,14 +41,6 @@ async def list_library_agents(
ge=1,
description="Number of agents per page (must be >= 1)",
),
folder_id: Optional[str] = Query(
None,
description="Filter by folder ID",
),
include_root_only: bool = Query(
False,
description="Only return agents without a folder (root-level agents)",
),
) -> library_model.LibraryAgentResponse:
"""
Get all agents in the user's library (both created and saved).
@@ -59,8 +51,6 @@ async def list_library_agents(
sort_by=sort_by,
page=page,
page_size=page_size,
folder_id=folder_id,
include_root_only=include_root_only,
)
@@ -178,7 +168,6 @@ async def update_library_agent(
is_favorite=payload.is_favorite,
is_archived=payload.is_archived,
settings=payload.settings,
folder_id=payload.folder_id,
)

View File

@@ -1,287 +0,0 @@
from typing import Optional
import autogpt_libs.auth as autogpt_auth_lib
from fastapi import APIRouter, Query, Security, status
from fastapi.responses import Response
from .. import db as library_db
from .. import model as library_model
router = APIRouter(
prefix="/folders",
tags=["library", "folders", "private"],
dependencies=[Security(autogpt_auth_lib.requires_user)],
)
@router.get(
"",
summary="List Library Folders",
response_model=library_model.FolderListResponse,
responses={
200: {"description": "List of folders"},
500: {"description": "Server error"},
},
)
async def list_folders(
user_id: str = Security(autogpt_auth_lib.get_user_id),
parent_id: Optional[str] = Query(
None,
description="Filter by parent folder ID. If not provided, returns root-level folders.",
),
include_relations: bool = Query(
True,
description="Include agent and subfolder relations (for counts)",
),
) -> library_model.FolderListResponse:
"""
List folders for the authenticated user.
Args:
user_id: ID of the authenticated user.
parent_id: Optional parent folder ID to filter by.
include_relations: Whether to include agent and subfolder relations for counts.
Returns:
A FolderListResponse containing folders.
"""
folders = await library_db.list_folders(
user_id=user_id,
parent_id=parent_id,
include_relations=include_relations,
)
return library_model.FolderListResponse(
folders=folders,
pagination=library_model.Pagination(
total_items=len(folders),
total_pages=1,
current_page=1,
page_size=len(folders),
),
)
@router.get(
"/tree",
summary="Get Folder Tree",
response_model=library_model.FolderTreeResponse,
responses={
200: {"description": "Folder tree structure"},
500: {"description": "Server error"},
},
)
async def get_folder_tree(
user_id: str = Security(autogpt_auth_lib.get_user_id),
) -> library_model.FolderTreeResponse:
"""
Get the full folder tree for the authenticated user.
Args:
user_id: ID of the authenticated user.
Returns:
A FolderTreeResponse containing the nested folder structure.
"""
tree = await library_db.get_folder_tree(user_id=user_id)
return library_model.FolderTreeResponse(tree=tree)
@router.get(
"/{folder_id}",
summary="Get Folder",
response_model=library_model.LibraryFolder,
responses={
200: {"description": "Folder details"},
404: {"description": "Folder not found"},
500: {"description": "Server error"},
},
)
async def get_folder(
folder_id: str,
user_id: str = Security(autogpt_auth_lib.get_user_id),
) -> library_model.LibraryFolder:
"""
Get a specific folder.
Args:
folder_id: ID of the folder to retrieve.
user_id: ID of the authenticated user.
Returns:
The requested LibraryFolder.
"""
return await library_db.get_folder(folder_id=folder_id, user_id=user_id)
@router.post(
"",
summary="Create Folder",
status_code=status.HTTP_201_CREATED,
response_model=library_model.LibraryFolder,
responses={
201: {"description": "Folder created successfully"},
400: {"description": "Validation error"},
404: {"description": "Parent folder not found"},
409: {"description": "Folder name conflict"},
500: {"description": "Server error"},
},
)
async def create_folder(
payload: library_model.FolderCreateRequest,
user_id: str = Security(autogpt_auth_lib.get_user_id),
) -> library_model.LibraryFolder:
"""
Create a new folder.
Args:
payload: The folder creation request.
user_id: ID of the authenticated user.
Returns:
The created LibraryFolder.
"""
return await library_db.create_folder(
user_id=user_id,
name=payload.name,
parent_id=payload.parent_id,
icon=payload.icon,
color=payload.color,
)
@router.patch(
"/{folder_id}",
summary="Update Folder",
response_model=library_model.LibraryFolder,
responses={
200: {"description": "Folder updated successfully"},
400: {"description": "Validation error"},
404: {"description": "Folder not found"},
409: {"description": "Folder name conflict"},
500: {"description": "Server error"},
},
)
async def update_folder(
folder_id: str,
payload: library_model.FolderUpdateRequest,
user_id: str = Security(autogpt_auth_lib.get_user_id),
) -> library_model.LibraryFolder:
"""
Update a folder's properties.
Args:
folder_id: ID of the folder to update.
payload: The folder update request.
user_id: ID of the authenticated user.
Returns:
The updated LibraryFolder.
"""
return await library_db.update_folder(
folder_id=folder_id,
user_id=user_id,
name=payload.name,
icon=payload.icon,
color=payload.color,
)
@router.post(
"/{folder_id}/move",
summary="Move Folder",
response_model=library_model.LibraryFolder,
responses={
200: {"description": "Folder moved successfully"},
400: {"description": "Validation error (circular reference)"},
404: {"description": "Folder or target parent not found"},
409: {"description": "Folder name conflict in target location"},
500: {"description": "Server error"},
},
)
async def move_folder(
folder_id: str,
payload: library_model.FolderMoveRequest,
user_id: str = Security(autogpt_auth_lib.get_user_id),
) -> library_model.LibraryFolder:
"""
Move a folder to a new parent.
Args:
folder_id: ID of the folder to move.
payload: The move request with target parent.
user_id: ID of the authenticated user.
Returns:
The moved LibraryFolder.
"""
return await library_db.move_folder(
folder_id=folder_id,
user_id=user_id,
target_parent_id=payload.target_parent_id,
)
@router.delete(
"/{folder_id}",
summary="Delete Folder",
status_code=status.HTTP_204_NO_CONTENT,
responses={
204: {"description": "Folder deleted successfully"},
404: {"description": "Folder not found"},
500: {"description": "Server error"},
},
)
async def delete_folder(
folder_id: str,
user_id: str = Security(autogpt_auth_lib.get_user_id),
) -> Response:
"""
Soft-delete a folder and all its contents.
Args:
folder_id: ID of the folder to delete.
user_id: ID of the authenticated user.
Returns:
204 No Content if successful.
"""
await library_db.delete_folder(
folder_id=folder_id,
user_id=user_id,
soft_delete=True,
)
return Response(status_code=status.HTTP_204_NO_CONTENT)
# === Bulk Agent Operations ===
@router.post(
"/agents/bulk-move",
summary="Bulk Move Agents",
response_model=list[library_model.LibraryAgent],
responses={
200: {"description": "Agents moved successfully"},
404: {"description": "Folder not found"},
500: {"description": "Server error"},
},
)
async def bulk_move_agents(
payload: library_model.BulkMoveAgentsRequest,
user_id: str = Security(autogpt_auth_lib.get_user_id),
) -> list[library_model.LibraryAgent]:
"""
Move multiple agents to a folder.
Args:
payload: The bulk move request with agent IDs and target folder.
user_id: ID of the authenticated user.
Returns:
The updated LibraryAgents.
"""
return await library_db.bulk_move_agents_to_folder(
agent_ids=payload.agent_ids,
folder_id=payload.folder_id,
user_id=user_id,
)

View File

@@ -115,8 +115,6 @@ async def test_get_library_agents_success(
sort_by=library_model.LibraryAgentSort.UPDATED_AT,
page=1,
page_size=15,
folder_id=None,
include_root_only=False,
)

View File

@@ -41,10 +41,6 @@ import backend.data.user
import backend.integrations.webhooks.utils
import backend.util.service
import backend.util.settings
from backend.api.features.library.exceptions import (
FolderAlreadyExistsError,
FolderValidationError,
)
from backend.blocks.llm import DEFAULT_LLM_MODEL
from backend.copilot.completion_consumer import (
start_completion_consumer,
@@ -281,10 +277,6 @@ async def validation_error_handler(
app.add_exception_handler(PrismaError, handle_internal_http_error(500))
app.add_exception_handler(
FolderAlreadyExistsError, handle_internal_http_error(409, False)
)
app.add_exception_handler(FolderValidationError, handle_internal_http_error(400, False))
app.add_exception_handler(NotFoundError, handle_internal_http_error(404, False))
app.add_exception_handler(NotAuthorizedError, handle_internal_http_error(403, False))
app.add_exception_handler(RequestValidationError, validation_error_handler)

View File

@@ -79,12 +79,6 @@ INTEGRATION_WEBHOOK_INCLUDE: prisma.types.IntegrationWebhookInclude = {
}
LIBRARY_FOLDER_INCLUDE: prisma.types.LibraryFolderInclude = {
"LibraryAgents": {"where": {"isDeleted": False}},
"Children": {"where": {"isDeleted": False}},
}
def library_agent_include(
user_id: str,
include_nodes: bool = True,
@@ -111,7 +105,6 @@ def library_agent_include(
"""
result: prisma.types.LibraryAgentInclude = {
"Creator": True, # Always needed for creator info
"Folder": True, # Always needed for folder info
}
# Build AgentGraph include based on requested options

View File

@@ -1,33 +0,0 @@
-- AlterTable
ALTER TABLE "LibraryAgent" ADD COLUMN "folderId" TEXT;
-- CreateTable
CREATE TABLE "LibraryFolder" (
"id" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"userId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"icon" TEXT,
"color" TEXT,
"parentId" TEXT,
"isDeleted" BOOLEAN NOT NULL DEFAULT false,
CONSTRAINT "LibraryFolder_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "LibraryFolder_userId_parentId_name_key" ON "LibraryFolder"("userId", "parentId", "name");
-- CreateIndex
CREATE INDEX "LibraryAgent_folderId_idx" ON "LibraryAgent"("folderId");
-- AddForeignKey
ALTER TABLE "LibraryAgent" ADD CONSTRAINT "LibraryAgent_folderId_fkey" FOREIGN KEY ("folderId") REFERENCES "LibraryFolder"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "LibraryFolder" ADD CONSTRAINT "LibraryFolder_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "LibraryFolder" ADD CONSTRAINT "LibraryFolder_parentId_fkey" FOREIGN KEY ("parentId") REFERENCES "LibraryFolder"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -51,7 +51,6 @@ model User {
ChatSessions ChatSession[]
AgentPresets AgentPreset[]
LibraryAgents LibraryAgent[]
LibraryFolders LibraryFolder[]
Profile Profile[]
UserOnboarding UserOnboarding?
@@ -396,9 +395,6 @@ model LibraryAgent {
creatorId String?
Creator Profile? @relation(fields: [creatorId], references: [id])
folderId String?
Folder LibraryFolder? @relation(fields: [folderId], references: [id], onDelete: Restrict)
useGraphIsActiveVersion Boolean @default(false)
isFavorite Boolean @default(false)
@@ -411,30 +407,6 @@ model LibraryAgent {
@@unique([userId, agentGraphId, agentGraphVersion])
@@index([agentGraphId, agentGraphVersion])
@@index([creatorId])
@@index([folderId])
}
model LibraryFolder {
id String @id @default(uuid())
createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt
userId String
User User @relation(fields: [userId], references: [id], onDelete: Cascade)
name String
icon String?
color String?
parentId String?
Parent LibraryFolder? @relation("FolderHierarchy", fields: [parentId], references: [id], onDelete: Cascade)
Children LibraryFolder[] @relation("FolderHierarchy")
isDeleted Boolean @default(false)
LibraryAgents LibraryAgent[]
@@unique([userId, parentId, name]) // Name unique per parent per user
}
////////////////////////////////////////////////////////////

View File

@@ -38,8 +38,6 @@
"can_access_graph": true,
"is_latest_version": true,
"is_favorite": false,
"folder_id": null,
"folder_name": null,
"recommended_schedule_cron": null,
"settings": {
"human_in_the_loop_safe_mode": true,
@@ -85,8 +83,6 @@
"can_access_graph": false,
"is_latest_version": true,
"is_favorite": false,
"folder_id": null,
"folder_name": null,
"recommended_schedule_cron": null,
"settings": {
"human_in_the_loop_safe_mode": true,

View File

@@ -23,6 +23,8 @@
"build-storybook": "storybook build",
"test-storybook": "test-storybook",
"test-storybook:ci": "concurrently -k -s first -n \"SB,TEST\" -c \"magenta,blue\" \"pnpm run build-storybook -- --quiet && npx http-server storybook-static --port 6006 --silent\" \"wait-on tcp:6006 && pnpm run test-storybook\"",
"react-doctor": "npx -y react-doctor@latest . --verbose",
"react-doctor:diff": "npx -y react-doctor@latest . --verbose --diff",
"generate:api": "npx --yes tsx ./scripts/generate-api-queries.ts && orval --config ./orval.config.ts",
"generate:api:force": "npx --yes tsx ./scripts/generate-api-queries.ts --force && orval --config ./orval.config.ts"
},
@@ -32,7 +34,6 @@
"dependencies": {
"@ai-sdk/react": "3.0.61",
"@faker-js/faker": "10.0.0",
"@ferrucc-io/emoji-picker": "0.0.48",
"@hookform/resolvers": "5.2.2",
"@next/third-parties": "15.4.6",
"@phosphor-icons/react": "2.1.10",

View File

@@ -18,9 +18,6 @@ importers:
'@faker-js/faker':
specifier: 10.0.0
version: 10.0.0
'@ferrucc-io/emoji-picker':
specifier: 0.0.48
version: 0.0.48(@babel/core@7.28.5)(@babel/template@7.27.2)(@types/react@18.3.17)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(tailwindcss@3.4.17)
'@hookform/resolvers':
specifier: 5.2.2
version: 5.2.2(react-hook-form@7.66.0(react@18.3.1))
@@ -1510,14 +1507,6 @@ packages:
resolution: {integrity: sha512-UollFEUkVXutsaP+Vndjxar40Gs5JL2HeLcl8xO1QAjJgOdhc3OmBFWyEylS+RddWaaBiAzH+5/17PLQJwDiLw==}
engines: {node: ^20.19.0 || ^22.13.0 || ^23.5.0 || >=24.0.0, npm: '>=10'}
'@ferrucc-io/emoji-picker@0.0.48':
resolution: {integrity: sha512-DJ5u+6VLF9OK7x+S/luwrVb5CHC6W16jL5b8vBUYNpxKWSuFgyliDHVtw1SGe6+dr5RUbf8WQwPJdKZmU3Ittg==}
engines: {node: '>=18'}
peerDependencies:
react: ^18.2.0 || ^19.0.0
react-dom: ^18.2.0 || ^19.0.0
tailwindcss: '>=3.0.0'
'@floating-ui/core@1.7.3':
resolution: {integrity: sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==}
@@ -3125,10 +3114,6 @@ packages:
'@shikijs/vscode-textmate@10.0.2':
resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==}
'@sindresorhus/is@4.6.0':
resolution: {integrity: sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==}
engines: {node: '>=10'}
'@standard-schema/spec@1.0.0':
resolution: {integrity: sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==}
@@ -3391,19 +3376,10 @@ packages:
react: '>=16.8'
react-dom: '>=16.8'
'@tanstack/react-virtual@3.13.18':
resolution: {integrity: sha512-dZkhyfahpvlaV0rIKnvQiVoWPyURppl6w4m9IwMDpuIjcJ1sD9YGWrt0wISvgU7ewACXx2Ct46WPgI6qAD4v6A==}
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
'@tanstack/table-core@8.21.3':
resolution: {integrity: sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==}
engines: {node: '>=12'}
'@tanstack/virtual-core@3.13.18':
resolution: {integrity: sha512-Mx86Hqu1k39icq2Zusq+Ey2J6dDWTjDvEv43PJtRCoEYTLyfaPnxIQ6iy7YAOK0NV/qOEmZQ/uCufrppZxTgcg==}
'@testing-library/dom@10.4.1':
resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==}
engines: {node: '>=18'}
@@ -4397,10 +4373,6 @@ packages:
resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
engines: {node: '>=10'}
char-regex@1.0.2:
resolution: {integrity: sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==}
engines: {node: '>=10'}
character-entities-html4@2.1.0:
resolution: {integrity: sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==}
@@ -5018,9 +4990,6 @@ packages:
emoji-regex@9.2.2:
resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==}
emojilib@2.4.0:
resolution: {integrity: sha512-5U0rVMU5Y2n2+ykNLQqMoqklN9ICBT/KsvC1Gz6vqHbz2AXXGkG+Pm5rMWk/8Vjrr/mY9985Hi8DYzn1F09Nyw==}
emojis-list@3.0.0:
resolution: {integrity: sha512-/kyM18EfinwXZbno9FyUGeFh87KC8HRQBQGildHZbEuRyWFOmv1U10o9BBp8XVZDVNNuQKyIGIu5ZYAAXJ0V2Q==}
engines: {node: '>= 4'}
@@ -6001,24 +5970,6 @@ packages:
resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==}
hasBin: true
jotai@2.17.1:
resolution: {integrity: sha512-TFNZZDa/0ewCLQyRC/Sq9crtixNj/Xdf/wmj9631xxMuKToVJZDbqcHIYN0OboH+7kh6P6tpIK7uKWClj86PKw==}
engines: {node: '>=12.20.0'}
peerDependencies:
'@babel/core': '>=7.0.0'
'@babel/template': '>=7.0.0'
'@types/react': '>=17.0.0'
react: '>=17.0.0'
peerDependenciesMeta:
'@babel/core':
optional: true
'@babel/template':
optional: true
'@types/react':
optional: true
react:
optional: true
js-tokens@4.0.0:
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
@@ -6637,10 +6588,6 @@ packages:
node-abort-controller@3.1.1:
resolution: {integrity: sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==}
node-emoji@2.2.0:
resolution: {integrity: sha512-Z3lTE9pLaJF47NyMhd4ww1yFTAP8YhYI8SleJiHzM46Fgpm5cnNzSl9XfzFNqbaz+VlJrIj3fXQ4DeN1Rjm6cw==}
engines: {node: '>=18'}
node-fetch-h2@2.3.0:
resolution: {integrity: sha512-ofRW94Ab0T4AOh5Fk8t0h8OBWrmjb0SSB20xh1H8YnPV9EJ+f5AMoYSUQ2zgJ4Iq2HAK0I2l5/Nequ8YzFS3Hg==}
engines: {node: 4.x || >=6.0.0}
@@ -7739,10 +7686,6 @@ packages:
resolution: {integrity: sha512-LH7FpTAkeD+y5xQC4fzS+tFtaNlvt3Ib1zKzvhjv/Y+cioV4zIuw4IZr2yhRLu67CWL7FR9/6KXKnjRoZTvGGQ==}
engines: {node: '>=12'}
skin-tone@2.0.0:
resolution: {integrity: sha512-kUMbT1oBJCpgrnKoSr0o6wPtvRWT9W9UKvGLwfJYO2WuahZRHOpEyL1ckyMGgMWh0UdpmaoFqKKD29WTomNEGA==}
engines: {node: '>=8'}
slash@3.0.0:
resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==}
engines: {node: '>=8'}
@@ -8220,13 +8163,6 @@ packages:
resolution: {integrity: sha512-dA8WbNeb2a6oQzAQ55YlT5vQAWGV9WXOsi3SskE3bcCdM0P4SDd+24zS/OCacdRq5BkdsRj9q3Pg6YyQoxIGqg==}
engines: {node: '>=4'}
unicode-emoji-json@0.8.0:
resolution: {integrity: sha512-3wDXXvp6YGoKGhS2O2H7+V+bYduOBydN1lnI0uVfr1cIdY02uFFiEH1i3kE5CCE4l6UqbLKVmEFW9USxTAMD1g==}
unicode-emoji-modifier-base@1.0.0:
resolution: {integrity: sha512-yLSH4py7oFH3oG/9K+XWrz1pSi3dfUrWEnInbxMfArOfc1+33BlGPQtLsOYwvdMy11AwUBetYuaRxSPqgkq+8g==}
engines: {node: '>=4'}
unicode-match-property-ecmascript@2.0.0:
resolution: {integrity: sha512-5kaZCrbp5mmbz5ulBkDkbY0SsPOjKqVS35VpL9ulMPfSl0J0Xsm+9Evphv9CoIZFwre7aJoa94AY6seMKGVN5Q==}
engines: {node: '>=4'}
@@ -9836,22 +9772,6 @@ snapshots:
'@faker-js/faker@10.0.0': {}
'@ferrucc-io/emoji-picker@0.0.48(@babel/core@7.28.5)(@babel/template@7.27.2)(@types/react@18.3.17)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(tailwindcss@3.4.17)':
dependencies:
'@tanstack/react-virtual': 3.13.18(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
clsx: 2.1.1
jotai: 2.17.1(@babel/core@7.28.5)(@babel/template@7.27.2)(@types/react@18.3.17)(react@18.3.1)
node-emoji: 2.2.0
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
tailwind-merge: 2.6.0
tailwindcss: 3.4.17
unicode-emoji-json: 0.8.0
transitivePeerDependencies:
- '@babel/core'
- '@babel/template'
- '@types/react'
'@floating-ui/core@1.7.3':
dependencies:
'@floating-ui/utils': 0.2.10
@@ -11613,8 +11533,6 @@ snapshots:
'@shikijs/vscode-textmate@10.0.2': {}
'@sindresorhus/is@4.6.0': {}
'@standard-schema/spec@1.0.0': {}
'@standard-schema/spec@1.1.0': {}
@@ -12083,16 +12001,8 @@ snapshots:
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
'@tanstack/react-virtual@3.13.18(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
'@tanstack/virtual-core': 3.13.18
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
'@tanstack/table-core@8.21.3': {}
'@tanstack/virtual-core@3.13.18': {}
'@testing-library/dom@10.4.1':
dependencies:
'@babel/code-frame': 7.27.1
@@ -13184,8 +13094,6 @@ snapshots:
ansi-styles: 4.3.0
supports-color: 7.2.0
char-regex@1.0.2: {}
character-entities-html4@2.1.0: {}
character-entities-legacy@3.0.0: {}
@@ -13829,8 +13737,6 @@ snapshots:
emoji-regex@9.2.2: {}
emojilib@2.4.0: {}
emojis-list@3.0.0: {}
endent@2.1.0:
@@ -15112,13 +15018,6 @@ snapshots:
jiti@2.6.1: {}
jotai@2.17.1(@babel/core@7.28.5)(@babel/template@7.27.2)(@types/react@18.3.17)(react@18.3.1):
optionalDependencies:
'@babel/core': 7.28.5
'@babel/template': 7.27.2
'@types/react': 18.3.17
react: 18.3.1
js-tokens@4.0.0: {}
js-yaml@4.1.0:
@@ -15987,13 +15886,6 @@ snapshots:
node-abort-controller@3.1.1: {}
node-emoji@2.2.0:
dependencies:
'@sindresorhus/is': 4.6.0
char-regex: 1.0.2
emojilib: 2.4.0
skin-tone: 2.0.0
node-fetch-h2@2.3.0:
dependencies:
http2-client: 1.3.5
@@ -17294,10 +17186,6 @@ snapshots:
dependencies:
jsep: 1.4.0
skin-tone@2.0.0:
dependencies:
unicode-emoji-modifier-base: 1.0.0
slash@3.0.0: {}
sonner@2.0.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
@@ -17813,10 +17701,6 @@ snapshots:
unicode-canonical-property-names-ecmascript@2.0.1: {}
unicode-emoji-json@0.8.0: {}
unicode-emoji-modifier-base@1.0.0: {}
unicode-match-property-ecmascript@2.0.0:
dependencies:
unicode-canonical-property-names-ecmascript: 2.0.1

View File

@@ -0,0 +1,13 @@
"use client";
import { ReactFlowProvider } from "@xyflow/react";
import { Flow } from "./components/FlowEditor/Flow/Flow";
export function BuilderContent() {
return (
<div className="relative h-full w-full">
<ReactFlowProvider>
<Flow />
</ReactFlowProvider>
</div>
);
}

View File

@@ -7,7 +7,7 @@ import {
TooltipTrigger,
} from "@/components/atoms/Tooltip/BaseTooltip";
import { beautifyString, cn } from "@/lib/utils";
import { useState } from "react";
import { useCallback, useState } from "react";
import { CustomNodeData } from "../CustomNode";
import { NodeBadges } from "./NodeBadges";
import { NodeContextMenu } from "./NodeContextMenu";
@@ -25,6 +25,9 @@ export const NodeHeader = ({ data, nodeId }: Props) => {
const [isEditingTitle, setIsEditingTitle] = useState(false);
const [editedTitle, setEditedTitle] = useState(title);
const titleInputRef = useCallback((node: HTMLInputElement | null) => {
node?.focus();
}, []);
const handleTitleEdit = () => {
updateNodeData(nodeId, {
@@ -52,10 +55,10 @@ export const NodeHeader = ({ data, nodeId }: Props) => {
>
{isEditingTitle ? (
<input
ref={titleInputRef}
id="node-title-input"
value={editedTitle}
onChange={(e) => setEditedTitle(e.target.value)}
autoFocus
className={cn(
"m-0 h-fit w-full border-none bg-transparent p-0 focus:outline-none focus:ring-0",
"font-sans text-[1rem] font-semibold leading-[1.5rem] text-zinc-800",

View File

@@ -300,7 +300,6 @@ export function MCPToolDialog({
value={serverUrl}
onChange={(e) => setServerUrl(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleDiscoverTools()}
autoFocus
/>
</div>
@@ -327,7 +326,6 @@ export function MCPToolDialog({
value={manualToken}
onChange={(e) => setManualToken(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleDiscoverTools()}
autoFocus
/>
</div>
)}

View File

@@ -52,7 +52,7 @@ export const HorizontalScroll: React.FC<HorizontalScrollAreaProps> = ({
return;
}
const handleScroll = () => updateScrollState();
element.addEventListener("scroll", handleScroll);
element.addEventListener("scroll", handleScroll, { passive: true });
window.addEventListener("resize", handleScroll);
return () => {
element.removeEventListener("scroll", handleScroll);

View File

@@ -85,12 +85,20 @@ export const GraphSearchContent: React.FC<GraphSearchContentProps> = ({
<Tooltip delayDuration={300}>
<TooltipTrigger asChild>
<div
role="button"
tabIndex={0}
className={`mx-4 my-2 flex h-20 cursor-pointer rounded-lg border border-zinc-200 bg-white ${
index === selectedIndex
? "border-zinc-400 shadow-md"
: "hover:border-zinc-300 hover:shadow-sm"
}`}
onClick={() => onNodeSelect(node.id)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onNodeSelect(node.id);
}
}}
onMouseEnter={() => {
setSelectedIndex(index);
onNodeHover?.(node.id);

View File

@@ -140,10 +140,7 @@ export function AgentRunDraftView({
),
[agentInputSchema],
);
const agentCredentialsInputFields = useMemo(
() => graph.credentials_input_schema.properties,
[graph],
);
const agentCredentialsInputFields = graph.credentials_input_schema.properties;
const credentialFields = useMemo(
function getCredentialFields() {
return Object.entries(agentCredentialsInputFields);

View File

@@ -1,13 +1,11 @@
"use client";
import { ReactFlowProvider } from "@xyflow/react";
import { Flow } from "./components/FlowEditor/Flow/Flow";
import type { Metadata } from "next";
import { BuilderContent } from "./BuilderContent";
export const metadata: Metadata = {
title: "Build",
description: "Build your agent",
};
export default function BuilderPage() {
return (
<div className="relative h-full w-full">
<ReactFlowProvider>
<Flow />
</ReactFlowProvider>
</div>
);
return <BuilderContent />;
}

View File

@@ -1,7 +1,7 @@
"use client";
import { ChatInput } from "@/app/(platform)/copilot/components/ChatInput/ChatInput";
import { UIDataTypes, UIMessage, UITools } from "ai";
import { LayoutGroup, motion } from "framer-motion";
import { LayoutGroup, LazyMotion, domAnimation, m } from "framer-motion";
import { ReactNode } from "react";
import { ChatMessagesContainer } from "../ChatMessagesContainer/ChatMessagesContainer";
import { CopilotChatActionsProvider } from "../CopilotChatActionsProvider/CopilotChatActionsProvider";
@@ -38,45 +38,47 @@ export const ChatContainer = ({
const inputLayoutId = "copilot-2-chat-input";
return (
<CopilotChatActionsProvider onSend={onSend}>
<LayoutGroup id="copilot-2-chat-layout">
<div className="flex h-full min-h-0 w-full flex-col bg-[#f8f8f9] px-2 lg:px-0">
{sessionId ? (
<div className="mx-auto flex h-full min-h-0 w-full max-w-3xl flex-col">
<ChatMessagesContainer
messages={messages}
status={status}
error={error}
isLoading={isLoadingSession}
headerSlot={headerSlot}
/>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.3 }}
className="relative px-3 pb-2 pt-2"
>
<div className="pointer-events-none absolute left-0 right-0 top-[-18px] z-10 h-6 bg-gradient-to-b from-transparent to-[#f8f8f9]" />
<ChatInput
inputId="chat-input-session"
onSend={onSend}
disabled={isBusy}
isStreaming={isBusy}
onStop={onStop}
placeholder="What else can I help with?"
<LazyMotion features={domAnimation}>
<CopilotChatActionsProvider onSend={onSend}>
<LayoutGroup id="copilot-2-chat-layout">
<div className="flex h-full min-h-0 w-full flex-col bg-[#f8f8f9] px-2 lg:px-0">
{sessionId ? (
<div className="mx-auto flex h-full min-h-0 w-full max-w-3xl flex-col">
<ChatMessagesContainer
messages={messages}
status={status}
error={error}
isLoading={isLoadingSession}
headerSlot={headerSlot}
/>
</motion.div>
</div>
) : (
<EmptySession
inputLayoutId={inputLayoutId}
isCreatingSession={isCreatingSession}
onCreateSession={onCreateSession}
onSend={onSend}
/>
)}
</div>
</LayoutGroup>
</CopilotChatActionsProvider>
<m.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.3 }}
className="relative px-3 pb-2 pt-2"
>
<div className="pointer-events-none absolute left-0 right-0 top-[-18px] z-10 h-6 bg-gradient-to-b from-transparent to-[#f8f8f9]" />
<ChatInput
inputId="chat-input-session"
onSend={onSend}
disabled={isBusy}
isStreaming={isBusy}
onStop={onStop}
placeholder="What else can I help with?"
/>
</m.div>
</div>
) : (
<EmptySession
inputLayoutId={inputLayoutId}
isCreatingSession={isCreatingSession}
onCreateSession={onCreateSession}
onSend={onSend}
/>
)}
</div>
</LayoutGroup>
</CopilotChatActionsProvider>
</LazyMotion>
);
};

View File

@@ -117,6 +117,7 @@ export function AudioWaveform({
{bars.map((height, i) => {
const barHeight = Math.max(minBarHeight, height);
return (
// eslint-disable-next-line react/no-array-index-key
<div
key={i}
className="relative"

View File

@@ -12,6 +12,7 @@ import {
import { LoadingSpinner } from "@/components/atoms/LoadingSpinner/LoadingSpinner";
import { toast } from "@/components/molecules/Toast/use-toast";
import { ToolUIPart, UIDataTypes, UIMessage, UITools } from "ai";
import Image from "next/image";
import { useEffect, useRef, useState } from "react";
import { CreateAgentTool } from "../../tools/CreateAgent/CreateAgent";
import { EditAgentTool } from "../../tools/EditAgent/EditAgent";
@@ -57,7 +58,7 @@ function resolveWorkspaceUrls(text: string): string {
* Falls back to <video> when an <img> fails to load for workspace files.
*/
function WorkspaceMediaImage(props: React.JSX.IntrinsicElements["img"]) {
const { src, alt, ...rest } = props;
const { src, alt } = props;
const [imgFailed, setImgFailed] = useState(false);
const isWorkspace = src?.includes("/workspace/files/") ?? false;
@@ -79,16 +80,17 @@ function WorkspaceMediaImage(props: React.JSX.IntrinsicElements["img"]) {
}
return (
// eslint-disable-next-line @next/next/no-img-element
<img
<Image
src={src}
alt={alt || "Image"}
className="h-auto max-w-full rounded-md border border-zinc-200"
loading="lazy"
width={0}
height={0}
sizes="100vw"
className="h-auto w-full rounded-md border border-zinc-200"
unoptimized
onError={() => {
if (isWorkspace) setImgFailed(true);
}}
{...rest}
/>
);
}
@@ -195,12 +197,12 @@ export const ChatMessagesContainer = ({
"group-[.is-assistant]:bg-transparent group-[.is-assistant]:text-slate-900"
}
>
{message.parts.map((part, i) => {
{message.parts.map((part) => {
switch (part.type) {
case "text":
return (
<MessageResponse
key={`${message.id}-${i}`}
key={`${message.id}-text`}
components={STREAMDOWN_COMPONENTS}
>
{resolveWorkspaceUrls(part.text)}
@@ -209,7 +211,7 @@ export const ChatMessagesContainer = ({
case "tool-find_block":
return (
<FindBlocksTool
key={`${message.id}-${i}`}
key={(part as ToolUIPart).toolCallId}
part={part as ToolUIPart}
/>
);
@@ -217,7 +219,7 @@ export const ChatMessagesContainer = ({
case "tool-find_library_agent":
return (
<FindAgentsTool
key={`${message.id}-${i}`}
key={(part as ToolUIPart).toolCallId}
part={part as ToolUIPart}
/>
);
@@ -225,14 +227,14 @@ export const ChatMessagesContainer = ({
case "tool-get_doc_page":
return (
<SearchDocsTool
key={`${message.id}-${i}`}
key={(part as ToolUIPart).toolCallId}
part={part as ToolUIPart}
/>
);
case "tool-run_block":
return (
<RunBlockTool
key={`${message.id}-${i}`}
key={(part as ToolUIPart).toolCallId}
part={part as ToolUIPart}
/>
);
@@ -240,42 +242,42 @@ export const ChatMessagesContainer = ({
case "tool-schedule_agent":
return (
<RunAgentTool
key={`${message.id}-${i}`}
key={(part as ToolUIPart).toolCallId}
part={part as ToolUIPart}
/>
);
case "tool-create_agent":
return (
<CreateAgentTool
key={`${message.id}-${i}`}
key={(part as ToolUIPart).toolCallId}
part={part as ToolUIPart}
/>
);
case "tool-edit_agent":
return (
<EditAgentTool
key={`${message.id}-${i}`}
key={(part as ToolUIPart).toolCallId}
part={part as ToolUIPart}
/>
);
case "tool-view_agent_output":
return (
<ViewAgentOutputTool
key={`${message.id}-${i}`}
key={(part as ToolUIPart).toolCallId}
part={part as ToolUIPart}
/>
);
case "tool-search_feature_requests":
return (
<SearchFeatureRequestsTool
key={`${message.id}-${i}`}
key={(part as ToolUIPart).toolCallId}
part={part as ToolUIPart}
/>
);
case "tool-create_feature_request":
return (
<CreateFeatureRequestTool
key={`${message.id}-${i}`}
key={(part as ToolUIPart).toolCallId}
part={part as ToolUIPart}
/>
);
@@ -285,7 +287,7 @@ export const ChatMessagesContainer = ({
if (part.type.startsWith("tool-")) {
return (
<GenericTool
key={`${message.id}-${i}`}
key={(part as ToolUIPart).toolCallId}
part={part as ToolUIPart}
/>
);

View File

@@ -25,7 +25,7 @@ import {
import { cn } from "@/lib/utils";
import { DotsThree, PlusCircleIcon, PlusIcon } from "@phosphor-icons/react";
import { useQueryClient } from "@tanstack/react-query";
import { motion } from "framer-motion";
import { LazyMotion, domAnimation, m } from "framer-motion";
import { parseAsString, useQueryState } from "nuqs";
import { useState } from "react";
import { DeleteChatDialog } from "../DeleteChatDialog/DeleteChatDialog";
@@ -129,7 +129,7 @@ export function ChatSidebar() {
}
return (
<>
<LazyMotion features={domAnimation}>
<Sidebar
variant="inset"
collapsible="icon"
@@ -144,7 +144,7 @@ export function ChatSidebar() {
: "flex-row items-center justify-between",
)}
>
<motion.div
<m.div
key={isCollapsed ? "header-collapsed" : "header-expanded"}
className="flex flex-col items-center gap-3 pt-4"
initial={{ opacity: 0, filter: "blur(3px)" }}
@@ -162,12 +162,12 @@ export function ChatSidebar() {
<span className="sr-only">New Chat</span>
</Button>
</div>
</motion.div>
</m.div>
</SidebarHeader>
)}
<SidebarContent className="gap-4 overflow-y-auto px-4 py-4 [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
{!isCollapsed && (
<motion.div
<m.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.2, delay: 0.1 }}
@@ -179,11 +179,11 @@ export function ChatSidebar() {
<div className="relative left-6">
<SidebarTrigger />
</div>
</motion.div>
</m.div>
)}
{!isCollapsed && (
<motion.div
<m.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.2, delay: 0.15 }}
@@ -256,12 +256,12 @@ export function ChatSidebar() {
</div>
))
)}
</motion.div>
</m.div>
)}
</SidebarContent>
{!isCollapsed && sessionId && (
<SidebarFooter className="shrink-0 bg-zinc-50 p-3 pb-1 shadow-[0_-4px_6px_-1px_rgba(0,0,0,0.05)]">
<motion.div
<m.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.2, delay: 0.2 }}
@@ -275,7 +275,7 @@ export function ChatSidebar() {
>
New Chat
</Button>
</motion.div>
</m.div>
</SidebarFooter>
)}
</Sidebar>
@@ -286,6 +286,6 @@ export function ChatSidebar() {
onConfirm={handleConfirmDelete}
onCancel={handleCancelDelete}
/>
</>
</LazyMotion>
);
}

View File

@@ -5,7 +5,7 @@ import { Button } from "@/components/atoms/Button/Button";
import { Text } from "@/components/atoms/Text/Text";
import { useSupabase } from "@/lib/supabase/hooks/useSupabase";
import { SpinnerGapIcon } from "@phosphor-icons/react";
import { motion } from "framer-motion";
import { LazyMotion, domAnimation, m } from "framer-motion";
import { useEffect, useState } from "react";
import {
getGreetingName,
@@ -29,7 +29,7 @@ export function EmptySession({
const greetingName = getGreetingName(user);
const quickActions = getQuickActions();
const [loadingAction, setLoadingAction] = useState<string | null>(null);
const [inputPlaceholder, setInputPlaceholder] = useState(
const [inputPlaceholder, setInputPlaceholder] = useState(() =>
getInputPlaceholder(),
);
@@ -49,63 +49,65 @@ export function EmptySession({
}
return (
<div className="flex h-full flex-1 items-center justify-center overflow-y-auto bg-[#f8f8f9] px-0 py-5 md:px-6 md:py-10">
<motion.div
className="w-full max-w-3xl text-center"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.3 }}
>
<div className="mx-auto max-w-3xl">
<Text variant="h3" className="mb-1 !text-[1.375rem] text-zinc-700">
Hey, <span className="text-violet-600">{greetingName}</span>
</Text>
<Text variant="h3" className="mb-8 !font-normal">
Tell me about your work I&apos;ll find what to automate.
</Text>
<LazyMotion features={domAnimation}>
<div className="flex h-full flex-1 items-center justify-center overflow-y-auto bg-[#f8f8f9] px-0 py-5 md:px-6 md:py-10">
<m.div
className="w-full max-w-3xl text-center"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.3 }}
>
<div className="mx-auto max-w-3xl">
<Text variant="h3" className="mb-1 !text-[1.375rem] text-zinc-700">
Hey, <span className="text-violet-600">{greetingName}</span>
</Text>
<Text variant="h3" className="mb-8 !font-normal">
Tell me about your work I&apos;ll find what to automate.
</Text>
<div className="mb-6">
<motion.div
layoutId={inputLayoutId}
transition={{ type: "spring", bounce: 0.2, duration: 0.65 }}
className="w-full px-2"
>
<ChatInput
inputId="chat-input-empty"
onSend={onSend}
disabled={isCreatingSession}
placeholder={inputPlaceholder}
className="w-full"
/>
</motion.div>
<div className="mb-6">
<m.div
layoutId={inputLayoutId}
transition={{ type: "spring", bounce: 0.2, duration: 0.65 }}
className="w-full px-2"
>
<ChatInput
inputId="chat-input-empty"
onSend={onSend}
disabled={isCreatingSession}
placeholder={inputPlaceholder}
className="w-full"
/>
</m.div>
</div>
</div>
</div>
<div className="flex flex-wrap items-center justify-center gap-3 overflow-x-auto [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
{quickActions.map((action) => (
<Button
key={action}
type="button"
variant="outline"
size="small"
onClick={() => void handleQuickActionClick(action)}
disabled={isCreatingSession || loadingAction !== null}
aria-busy={loadingAction === action}
leftIcon={
loadingAction === action ? (
<SpinnerGapIcon
className="h-4 w-4 animate-spin"
weight="bold"
/>
) : null
}
className="h-auto shrink-0 border-zinc-300 px-3 py-2 text-[.9rem] text-zinc-600"
>
{action}
</Button>
))}
</div>
</motion.div>
</div>
<div className="flex flex-wrap items-center justify-center gap-3 overflow-x-auto [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
{quickActions.map((action) => (
<Button
key={action}
type="button"
variant="outline"
size="small"
onClick={() => void handleQuickActionClick(action)}
disabled={isCreatingSession || loadingAction !== null}
aria-busy={loadingAction === action}
leftIcon={
loadingAction === action ? (
<SpinnerGapIcon
className="h-4 w-4 animate-spin"
weight="bold"
/>
) : null
}
className="h-auto shrink-0 border-zinc-300 px-3 py-2 text-[.9rem] text-zinc-600"
>
{action}
</Button>
))}
</div>
</m.div>
</div>
</LazyMotion>
);
}

View File

@@ -1,5 +1,5 @@
import { cn } from "@/lib/utils";
import { AnimatePresence, motion } from "framer-motion";
import { AnimatePresence, LazyMotion, domAnimation, m } from "framer-motion";
interface Props {
text: string;
@@ -10,45 +10,47 @@ export function MorphingTextAnimation({ text, className }: Props) {
const letters = text.split("");
return (
<div className={cn(className)}>
<AnimatePresence mode="popLayout" initial={false}>
<motion.div key={text} className="whitespace-nowrap">
<motion.span className="inline-flex overflow-hidden">
{letters.map((char, index) => (
<motion.span
key={`${text}-${index}`}
initial={{
opacity: 0,
y: 8,
rotateX: "80deg",
filter: "blur(6px)",
}}
animate={{
opacity: 1,
y: 0,
rotateX: "0deg",
filter: "blur(0px)",
}}
exit={{
opacity: 0,
y: -8,
rotateX: "-80deg",
filter: "blur(6px)",
}}
style={{ willChange: "transform" }}
transition={{
delay: 0.015 * index,
type: "spring",
bounce: 0.5,
}}
className="inline-block"
>
{char === " " ? "\u00A0" : char}
</motion.span>
))}
</motion.span>
</motion.div>
</AnimatePresence>
</div>
<LazyMotion features={domAnimation}>
<div className={cn(className)}>
<AnimatePresence mode="popLayout" initial={false}>
<m.div key={text} className="whitespace-nowrap">
<m.span className="inline-flex overflow-hidden">
{letters.map((char, index) => (
// eslint-disable-next-line react/no-array-index-key
<m.span
key={`${text}-${index}`}
initial={{
opacity: 0,
y: 8,
rotateX: "80deg",
filter: "blur(6px)",
}}
animate={{
opacity: 1,
y: 0,
rotateX: "0deg",
filter: "blur(0px)",
}}
exit={{
opacity: 0,
y: -8,
rotateX: "-80deg",
filter: "blur(6px)",
}}
transition={{
delay: 0.015 * index,
type: "spring",
bounce: 0.5,
}}
className="inline-block"
>
{char === " " ? "\u00A0" : char}
</m.span>
))}
</m.span>
</m.div>
</AnimatePresence>
</div>
</LazyMotion>
);
}

View File

@@ -2,7 +2,13 @@
import { cn } from "@/lib/utils";
import { CaretDownIcon } from "@phosphor-icons/react";
import { AnimatePresence, motion, useReducedMotion } from "framer-motion";
import {
AnimatePresence,
LazyMotion,
domAnimation,
m,
useReducedMotion,
} from "framer-motion";
import { useId } from "react";
import { useToolAccordion } from "./useToolAccordion";
@@ -38,65 +44,66 @@ export function ToolAccordion({
});
return (
<div
className={cn(
"mt-2 w-full rounded-lg border border-slate-200 bg-slate-100 px-3 py-2",
className,
)}
>
<button
type="button"
aria-expanded={isExpanded}
aria-controls={contentId}
onClick={toggle}
className="flex w-full items-center justify-between gap-3 py-1 text-left"
>
<div className="flex min-w-0 items-center gap-3">
<span className="flex shrink-0 items-center text-gray-800">
{icon}
</span>
<div className="min-w-0">
<p
className={cn(
"truncate text-sm font-medium text-gray-800",
titleClassName,
)}
>
{title}
</p>
{description && (
<p className="truncate text-xs text-slate-800">{description}</p>
)}
</div>
</div>
<CaretDownIcon
className={cn(
"h-4 w-4 shrink-0 text-slate-500 transition-transform",
isExpanded && "rotate-180",
)}
weight="bold"
/>
</button>
<AnimatePresence initial={false}>
{isExpanded && (
<motion.div
id={contentId}
initial={{ height: 0, opacity: 0, filter: "blur(10px)" }}
animate={{ height: "auto", opacity: 1, filter: "blur(0px)" }}
exit={{ height: 0, opacity: 0, filter: "blur(10px)" }}
transition={
shouldReduceMotion
? { duration: 0 }
: { type: "spring", bounce: 0.35, duration: 0.55 }
}
className="overflow-hidden"
style={{ willChange: "height, opacity, filter" }}
>
<div className="pb-2 pt-3">{children}</div>
</motion.div>
<LazyMotion features={domAnimation}>
<div
className={cn(
"mt-2 w-full rounded-lg border border-slate-200 bg-slate-100 px-3 py-2",
className,
)}
</AnimatePresence>
</div>
>
<button
type="button"
aria-expanded={isExpanded}
aria-controls={contentId}
onClick={toggle}
className="flex w-full items-center justify-between gap-3 py-1 text-left"
>
<div className="flex min-w-0 items-center gap-3">
<span className="flex shrink-0 items-center text-gray-800">
{icon}
</span>
<div className="min-w-0">
<p
className={cn(
"truncate text-sm font-medium text-gray-800",
titleClassName,
)}
>
{title}
</p>
{description && (
<p className="truncate text-xs text-slate-800">{description}</p>
)}
</div>
</div>
<CaretDownIcon
className={cn(
"h-4 w-4 shrink-0 text-slate-500 transition-transform",
isExpanded && "rotate-180",
)}
weight="bold"
/>
</button>
<AnimatePresence initial={false}>
{isExpanded && (
<m.div
id={contentId}
initial={{ height: 0, opacity: 0, filter: "blur(10px)" }}
animate={{ height: "auto", opacity: 1, filter: "blur(0px)" }}
exit={{ height: 0, opacity: 0, filter: "blur(10px)" }}
transition={
shouldReduceMotion
? { duration: 0 }
: { type: "spring", bounce: 0.35, duration: 0.55 }
}
className="overflow-hidden"
>
<div className="pb-2 pt-3">{children}</div>
</m.div>
)}
</AnimatePresence>
</div>
</LazyMotion>
);
}

View File

@@ -0,0 +1,14 @@
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Copilot",
description: "Chat with your AI copilot",
};
export default function CopilotLayout({
children,
}: {
children: React.ReactNode;
}) {
return children;
}

View File

@@ -0,0 +1,14 @@
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Copilot Styleguide",
description: "Copilot UI component styleguide",
};
export default function StyleguideLayout({
children,
}: {
children: React.ReactNode;
}) {
return children;
}

View File

@@ -161,7 +161,7 @@ export function ClarificationQuestionsCard({
return (
<div
key={`${q.keyword}-${index}`}
key={q.keyword}
className={cn(
"relative rounded-lg border p-3",
isAnswered

View File

@@ -557,8 +557,11 @@ function getTodoAccordionData(input: unknown): AccordionData {
description: `${completed}/${total} completed`,
content: (
<div className="space-y-1 py-1">
{todos.map((todo, i) => (
<div key={i} className="flex items-start gap-2 text-xs">
{todos.map((todo, idx) => (
<div
key={`${todo.status}:${todo.content}:${idx}`}
className="flex items-start gap-2 text-xs"
>
<span className="mt-0.5 flex-shrink-0">
{todo.status === "completed" ? (
<CheckCircleIcon

View File

@@ -4,7 +4,7 @@ import type { AgentDetailsResponse } from "@/app/api/__generated__/models/agentD
import { Button } from "@/components/atoms/Button/Button";
import { Text } from "@/components/atoms/Text/Text";
import { FormRenderer } from "@/components/renderers/InputRenderer/FormRenderer";
import { AnimatePresence, motion } from "framer-motion";
import { AnimatePresence, LazyMotion, domAnimation, m } from "framer-motion";
import { useState } from "react";
import { useCopilotChatActions } from "../../../../components/CopilotChatActionsProvider/useCopilotChatActions";
import { ContentMessage } from "../../../../components/ToolAccordion/AccordionContent";
@@ -39,78 +39,83 @@ export function AgentDetailsCard({ output }: Props) {
}
return (
<div className="grid gap-2">
<ContentMessage>
Run this agent with example values or your own inputs.
</ContentMessage>
<LazyMotion features={domAnimation}>
<div className="grid gap-2">
<ContentMessage>
Run this agent with example values or your own inputs.
</ContentMessage>
<div className="flex gap-2 pt-4">
<Button size="small" className="w-fit" onClick={handleRunWithExamples}>
Run with example values
</Button>
<Button
variant="outline"
size="small"
className="w-fit"
onClick={() => setShowInputForm((prev) => !prev)}
>
Run with my inputs
</Button>
</div>
<AnimatePresence initial={false}>
{showInputForm && buildInputSchema(output.agent.inputs) && (
<motion.div
initial={{ height: 0, opacity: 0, filter: "blur(6px)" }}
animate={{ height: "auto", opacity: 1, filter: "blur(0px)" }}
exit={{ height: 0, opacity: 0, filter: "blur(6px)" }}
transition={{
height: { type: "spring", bounce: 0.15, duration: 0.5 },
opacity: { duration: 0.25 },
filter: { duration: 0.2 },
}}
className="overflow-hidden"
style={{ willChange: "height, opacity, filter" }}
<div className="flex gap-2 pt-4">
<Button
size="small"
className="w-fit"
onClick={handleRunWithExamples}
>
<div className="mt-4 rounded-2xl border bg-background p-3 pt-4">
<Text variant="body-medium">Enter your inputs</Text>
<FormRenderer
jsonSchema={buildInputSchema(output.agent.inputs)!}
handleChange={(v) => setInputValues(v.formData ?? {})}
uiSchema={{
"ui:submitButtonOptions": { norender: true },
}}
initialValues={inputValues}
formContext={{
showHandles: false,
size: "small",
}}
/>
<div className="-mt-8 flex gap-2">
<Button
variant="primary"
size="small"
className="w-fit"
onClick={handleRunWithInputs}
>
Run
</Button>
<Button
variant="secondary"
size="small"
className="w-fit"
onClick={() => {
setShowInputForm(false);
setInputValues({});
Run with example values
</Button>
<Button
variant="outline"
size="small"
className="w-fit"
onClick={() => setShowInputForm((prev) => !prev)}
>
Run with my inputs
</Button>
</div>
<AnimatePresence initial={false}>
{showInputForm && buildInputSchema(output.agent.inputs) && (
<m.div
initial={{ height: 0, opacity: 0, filter: "blur(6px)" }}
animate={{ height: "auto", opacity: 1, filter: "blur(0px)" }}
exit={{ height: 0, opacity: 0, filter: "blur(6px)" }}
transition={{
height: { type: "spring", bounce: 0.15, duration: 0.5 },
opacity: { duration: 0.25 },
filter: { duration: 0.2 },
}}
className="overflow-hidden"
>
<div className="mt-4 rounded-2xl border bg-background p-3 pt-4">
<Text variant="body-medium">Enter your inputs</Text>
<FormRenderer
jsonSchema={buildInputSchema(output.agent.inputs)!}
handleChange={(v) => setInputValues(v.formData ?? {})}
uiSchema={{
"ui:submitButtonOptions": { norender: true },
}}
>
Cancel
</Button>
initialValues={inputValues}
formContext={{
showHandles: false,
size: "small",
}}
/>
<div className="-mt-8 flex gap-2">
<Button
variant="primary"
size="small"
className="w-fit"
onClick={handleRunWithInputs}
>
Run
</Button>
<Button
variant="secondary"
size="small"
className="w-fit"
onClick={() => {
setShowInputForm(false);
setInputValues({});
}}
>
Cancel
</Button>
</div>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
</m.div>
)}
</AnimatePresence>
</div>
</LazyMotion>
);
}

View File

@@ -103,7 +103,7 @@ function OutputKeySection({
</div>
<div className="mt-2">
{visibleItems.map((item, i) => (
<RenderOutputValue key={i} value={item} />
<RenderOutputValue key={`${outputKey}-${i}`} value={item} />
))}
</div>
{hasMoreItems && (

View File

@@ -209,7 +209,10 @@ export function ViewAgentOutputTool({ part }: Props) {
</div>
<div className="mt-2">
{items.slice(0, 3).map((item, i) => (
<RenderOutputValue key={i} value={item} />
<RenderOutputValue
key={`${key}-${i}`}
value={item}
/>
))}
</div>
</ContentCard>

View File

@@ -23,13 +23,23 @@ export function SidebarItemCard({
onClick,
actions,
}: Props) {
function handleKeyDown(e: React.KeyboardEvent<HTMLDivElement>) {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onClick?.();
}
}
return (
<div
role="button"
tabIndex={0}
className={cn(
"w-full cursor-pointer rounded-large border border-zinc-200 bg-white p-3 text-left ring-1 ring-transparent transition-all duration-150 hover:scale-[1.01] hover:bg-slate-50/50",
selected ? "border-slate-800 ring-slate-800" : undefined,
)}
onClick={onClick}
onKeyDown={handleKeyDown}
>
<div className="flex min-w-0 items-center justify-start gap-3">
{icon}
@@ -49,7 +59,13 @@ export function SidebarItemCard({
</Text>
</div>
{actions ? (
<div onClick={(e) => e.stopPropagation()}>{actions}</div>
<div
role="presentation"
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
>
{actions}
</div>
) : null}
</div>
</div>

View File

@@ -1,31 +1,17 @@
"use client";
import { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
import { LibraryAgentSort } from "@/app/api/__generated__/models/libraryAgentSort";
import { Text } from "@/components/atoms/Text/Text";
import { LoadingSpinner } from "@/components/atoms/LoadingSpinner/LoadingSpinner";
import { InfiniteScroll } from "@/components/contextual/InfiniteScroll/InfiniteScroll";
import { HeartIcon } from "@phosphor-icons/react";
import { useFavoriteAgents } from "../../hooks/useFavoriteAgents";
import { LibraryAgentCard } from "../LibraryAgentCard/LibraryAgentCard";
import { LibraryTabs, Tab } from "../LibraryTabs/LibraryTabs";
import { LibraryActionSubHeader } from "../LibraryActionSubHeader/LibraryActionSubHeader";
interface Props {
searchTerm: string;
tabs: Tab[];
activeTab: string;
onTabChange: (tabId: string) => void;
setLibrarySort: (value: LibraryAgentSort) => void;
}
export function FavoritesSection({
searchTerm,
tabs,
activeTab,
onTabChange,
setLibrarySort,
}: Props) {
export function FavoritesSection({ searchTerm }: Props) {
const {
allAgents: favoriteAgents,
agentLoading: isLoading,
@@ -35,33 +21,38 @@ export function FavoritesSection({
isFetchingNextPage,
} = useFavoriteAgents({ searchTerm });
return (
<>
<LibraryActionSubHeader
agentCount={agentCount}
setLibrarySort={setLibrarySort}
/>
<LibraryTabs
tabs={tabs}
activeTab={activeTab}
onTabChange={onTabChange}
/>
if (isLoading || favoriteAgents.length === 0) {
return null;
}
{isLoading ? (
<div className="flex h-[200px] items-center justify-center">
<LoadingSpinner size="large" />
return (
<div className="!mb-8">
<div className="mb-3 flex items-center gap-2 p-2">
<HeartIcon className="h-5 w-5" weight="fill" />
<div className="flex items-baseline gap-2">
<Text variant="h4">Favorites</Text>
{!isLoading && (
<Text
variant="body"
data-testid="agents-count"
className="relative bottom-px text-zinc-500"
>
{agentCount}
</Text>
)}
</div>
) : favoriteAgents.length === 0 ? (
<div className="flex h-[200px] flex-col items-center justify-center gap-2 text-zinc-500">
<HeartIcon className="h-10 w-10" />
<Text variant="body">No favorite agents yet</Text>
</div>
) : (
</div>
<div className="relative">
<InfiniteScroll
isFetchingNextPage={isFetchingNextPage}
fetchNextPage={fetchNextPage}
hasNextPage={hasNextPage}
loader={<LoadingSpinner size="medium" />}
loader={
<div className="flex h-8 w-full items-center justify-center">
<div className="h-6 w-6 animate-spin rounded-full border-b-2 border-t-2 border-neutral-800" />
</div>
}
>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{favoriteAgents.map((agent: LibraryAgent) => (
@@ -69,7 +60,9 @@ export function FavoritesSection({
))}
</div>
</InfiniteScroll>
)}
</>
</div>
{favoriteAgents.length > 0 && <div className="!mt-10 border-t" />}
</div>
);
}

View File

@@ -1,66 +0,0 @@
"use client";
import { motion, AnimatePresence } from "framer-motion";
import { HeartIcon } from "@phosphor-icons/react";
import { useEffect, useState } from "react";
interface FlyingHeartProps {
startPosition: { x: number; y: number } | null;
targetPosition: { x: number; y: number } | null;
onAnimationComplete: () => void;
}
export function FlyingHeart({
startPosition,
targetPosition,
onAnimationComplete,
}: FlyingHeartProps) {
const [isVisible, setIsVisible] = useState(false);
useEffect(() => {
if (startPosition && targetPosition) {
setIsVisible(true);
}
}, [startPosition, targetPosition]);
if (!startPosition || !targetPosition) return null;
return (
<AnimatePresence>
{isVisible && (
<motion.div
className="pointer-events-none fixed z-50"
initial={{
x: startPosition.x,
y: startPosition.y,
scale: 1,
opacity: 1,
}}
animate={{
x: targetPosition.x,
y: targetPosition.y,
scale: 0.5,
opacity: 0,
}}
exit={{ opacity: 0 }}
transition={{
type: "spring",
damping: 20,
stiffness: 200,
duration: 0.5,
}}
onAnimationComplete={() => {
setIsVisible(false);
onAnimationComplete();
}}
>
<HeartIcon
size={24}
weight="fill"
className="text-red-500 drop-shadow-md"
/>
</motion.div>
)}
</AnimatePresence>
);
}

View File

@@ -13,7 +13,7 @@ export function LibraryActionSubHeader({ agentCount, setLibrarySort }: Props) {
return (
<div className="flex items-baseline justify-between">
<div className="flex items-baseline gap-4">
<Text variant="h5">My agents</Text>
<Text variant="h4">My agents</Text>
<Text
variant="body"
data-testid="agents-count"

View File

@@ -4,7 +4,6 @@ import { Text } from "@/components/atoms/Text/Text";
import { CaretCircleRightIcon } from "@phosphor-icons/react";
import Image from "next/image";
import NextLink from "next/link";
import { motion } from "framer-motion";
import { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
import Avatar, {
@@ -15,21 +14,13 @@ import { Link } from "@/components/atoms/Link/Link";
import { AgentCardMenu } from "./components/AgentCardMenu";
import { FavoriteButton } from "./components/FavoriteButton";
import { useLibraryAgentCard } from "./useLibraryAgentCard";
import { useFavoriteAnimation } from "../../context/FavoriteAnimationContext";
interface Props {
agent: LibraryAgent;
draggable?: boolean;
}
export function LibraryAgentCard({ agent, draggable = true }: Props) {
export function LibraryAgentCard({ agent }: Props) {
const { id, name, graph_id, can_access_graph, image_url } = agent;
const { triggerFavoriteAnimation } = useFavoriteAnimation();
function handleDragStart(e: React.DragEvent<HTMLDivElement>) {
e.dataTransfer.setData("application/agent-id", id);
e.dataTransfer.effectAllowed = "move";
}
const {
isFromMarketplace,
@@ -37,119 +28,103 @@ export function LibraryAgentCard({ agent, draggable = true }: Props) {
profile,
creator_image_url,
handleToggleFavorite,
} = useLibraryAgentCard({
agent,
onFavoriteAdd: triggerFavoriteAnimation,
});
} = useLibraryAgentCard({ agent });
return (
<div
draggable={draggable}
onDragStart={handleDragStart}
className="cursor-grab active:cursor-grabbing"
data-testid="library-agent-card"
data-agent-id={id}
className="group relative inline-flex h-[10.625rem] w-full max-w-[25rem] flex-col items-start justify-start gap-2.5 rounded-medium border border-zinc-100 bg-white transition-all duration-300 hover:shadow-md"
>
<motion.div
layoutId={`agent-card-${id}`}
data-testid="library-agent-card"
data-agent-id={id}
className="group relative inline-flex h-[10.625rem] w-full max-w-[25rem] flex-col items-start justify-start gap-2.5 rounded-medium border border-zinc-100 bg-white hover:shadow-md"
transition={{
type: "spring",
damping: 25,
stiffness: 300,
}}
style={{ willChange: "transform" }}
>
<NextLink href={`/library/agents/${id}`} className="flex-shrink-0">
<div className="relative flex items-center gap-2 px-4 pt-3">
<Avatar className="h-4 w-4 rounded-full">
<AvatarImage
src={
isFromMarketplace
? creator_image_url || "/avatar-placeholder.png"
: profile?.avatar_url || "/avatar-placeholder.png"
}
alt={`${name} creator avatar`}
/>
<AvatarFallback size={48}>{name.charAt(0)}</AvatarFallback>
</Avatar>
<Text
variant="small-medium"
className="uppercase tracking-wide text-zinc-400"
>
{isFromMarketplace ? "FROM MARKETPLACE" : "Built by you"}
</Text>
</div>
</NextLink>
<FavoriteButton
isFavorite={isFavorite}
onClick={handleToggleFavorite}
className="absolute right-10 top-0"
/>
<AgentCardMenu agent={agent} />
<NextLink href={`/library/agents/${id}`} className="flex-shrink-0">
<div className="relative flex items-center gap-2 px-4 pt-3">
<Avatar className="h-4 w-4 rounded-full">
<AvatarImage
src={
isFromMarketplace
? creator_image_url || "/avatar-placeholder.png"
: profile?.avatar_url || "/avatar-placeholder.png"
}
alt={`${name} creator avatar`}
/>
<AvatarFallback size={48}>{name.charAt(0)}</AvatarFallback>
</Avatar>
<Text
variant="small-medium"
className="uppercase tracking-wide text-zinc-400"
>
{isFromMarketplace ? "FROM MARKETPLACE" : "Built by you"}
</Text>
</div>
</NextLink>
<FavoriteButton
isFavorite={isFavorite}
onClick={handleToggleFavorite}
className="absolute right-10 top-0"
/>
<AgentCardMenu agent={agent} />
<div className="flex w-full flex-1 flex-col px-4 pb-2">
<div className="flex w-full flex-1 flex-col px-4 pb-2">
<Link
href={`/library/agents/${id}`}
className="flex w-full items-start justify-between gap-2 no-underline hover:no-underline"
>
<Text
variant="h5"
data-testid="library-agent-card-name"
className="line-clamp-3 hyphens-auto break-words no-underline hover:no-underline"
>
{name}
</Text>
{!image_url ? (
<div
className={`h-[3.64rem] w-[6.70rem] flex-shrink-0 rounded-small ${
[
"bg-gradient-to-r from-green-200 to-blue-200",
"bg-gradient-to-r from-pink-200 to-purple-200",
"bg-gradient-to-r from-yellow-200 to-orange-200",
"bg-gradient-to-r from-blue-200 to-cyan-200",
"bg-gradient-to-r from-indigo-200 to-purple-200",
][parseInt(id.slice(0, 8), 16) % 5]
}`}
style={{
backgroundSize: "200% 200%",
animation: "gradient 15s ease infinite",
}}
/>
) : (
<Image
src={image_url}
alt={`${name} preview image`}
width={107}
height={58}
className="flex-shrink-0 rounded-small object-cover"
/>
)}
</Link>
<div className="mt-auto flex w-full justify-start gap-6 border-t border-zinc-100 pb-1 pt-3">
<Link
href={`/library/agents/${id}`}
className="flex w-full items-start justify-between gap-2 no-underline hover:no-underline focus:ring-0"
data-testid="library-agent-card-see-runs-link"
className="flex items-center gap-1 text-[13px]"
>
<Text
variant="h5"
data-testid="library-agent-card-name"
className="line-clamp-3 hyphens-auto break-words no-underline hover:no-underline"
>
{name}
</Text>
{!image_url ? (
<div
className={`h-[3.64rem] w-[6.70rem] flex-shrink-0 rounded-small ${
[
"bg-gradient-to-r from-green-200 to-blue-200",
"bg-gradient-to-r from-pink-200 to-purple-200",
"bg-gradient-to-r from-yellow-200 to-orange-200",
"bg-gradient-to-r from-blue-200 to-cyan-200",
"bg-gradient-to-r from-indigo-200 to-purple-200",
][parseInt(id.slice(0, 8), 16) % 5]
}`}
style={{
backgroundSize: "200% 200%",
animation: "gradient 15s ease infinite",
}}
/>
) : (
<Image
src={image_url}
alt={`${name} preview image`}
width={107}
height={58}
className="flex-shrink-0 rounded-small object-cover"
/>
)}
See runs <CaretCircleRightIcon size={20} />
</Link>
<div className="mt-auto flex w-full justify-start gap-6 border-t border-zinc-100 pb-1 pt-3">
{can_access_graph && (
<Link
href={`/library/agents/${id}`}
data-testid="library-agent-card-see-runs-link"
href={`/build?flowID=${graph_id}`}
data-testid="library-agent-card-open-in-builder-link"
className="flex items-center gap-1 text-[13px]"
isExternal
>
See runs <CaretCircleRightIcon size={20} />
Open in builder <CaretCircleRightIcon size={20} />
</Link>
{can_access_graph && (
<Link
href={`/build?flowID=${graph_id}`}
data-testid="library-agent-card-open-in-builder-link"
className="flex items-center gap-1 text-[13px]"
isExternal
>
Open in builder <CaretCircleRightIcon size={20} />
</Link>
)}
</div>
)}
</div>
</motion.div>
</div>
</div>
);
}

View File

@@ -5,10 +5,6 @@ import {
useDeleteV2DeleteLibraryAgent,
usePostV2ForkLibraryAgent,
} from "@/app/api/__generated__/endpoints/library/library";
import {
usePostV2BulkMoveAgents,
getGetV2ListLibraryFoldersQueryKey,
} from "@/app/api/__generated__/endpoints/folders/folders";
import { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
import { Button } from "@/components/atoms/Button/Button";
import { Text } from "@/components/atoms/Text/Text";
@@ -26,7 +22,6 @@ import { useQueryClient } from "@tanstack/react-query";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { MoveToFolderDialog } from "../../MoveToFolderDialog/MoveToFolderDialog";
interface AgentCardMenuProps {
agent: LibraryAgent;
@@ -37,25 +32,11 @@ export function AgentCardMenu({ agent }: AgentCardMenuProps) {
const queryClient = useQueryClient();
const router = useRouter();
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const [showMoveDialog, setShowMoveDialog] = useState(false);
const [isDeletingAgent, setIsDeletingAgent] = useState(false);
const [isDuplicatingAgent, setIsDuplicatingAgent] = useState(false);
const [isRemovingFromFolder, setIsRemovingFromFolder] = useState(false);
const { mutateAsync: deleteAgent } = useDeleteV2DeleteLibraryAgent();
const { mutateAsync: forkAgent } = usePostV2ForkLibraryAgent();
const { mutateAsync: bulkMoveAgents } = usePostV2BulkMoveAgents({
mutation: {
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: getGetV2ListLibraryAgentsQueryKey(),
});
queryClient.invalidateQueries({
queryKey: getGetV2ListLibraryFoldersQueryKey(),
});
},
},
});
async function handleDuplicateAgent() {
if (!agent.id) return;
@@ -89,37 +70,6 @@ export function AgentCardMenu({ agent }: AgentCardMenuProps) {
}
}
async function handleRemoveFromFolder() {
if (!agent.id) return;
setIsRemovingFromFolder(true);
try {
await bulkMoveAgents({
data: {
agent_ids: [agent.id],
folder_id: undefined,
},
});
toast({
title: "Removed from folder",
description: "Agent has been moved back to your library.",
});
} catch (error: unknown) {
toast({
title: "Failed to remove from folder",
description:
error instanceof Error
? error.message
: "An unexpected error occurred.",
variant: "destructive",
});
} finally {
setIsRemovingFromFolder(false);
}
}
async function handleDeleteAgent() {
if (!agent.id) return;
@@ -188,31 +138,6 @@ export function AgentCardMenu({ agent }: AgentCardMenuProps) {
Duplicate agent
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
setShowMoveDialog(true);
}}
className="flex items-center gap-2"
>
Move to folder
</DropdownMenuItem>
{agent.folder_id && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
handleRemoveFromFolder();
}}
disabled={isRemovingFromFolder}
className="flex items-center gap-2"
>
Remove from folder
</DropdownMenuItem>
</>
)}
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
@@ -258,14 +183,6 @@ export function AgentCardMenu({ agent }: AgentCardMenuProps) {
</div>
</Dialog.Content>
</Dialog>
<MoveToFolderDialog
agentId={agent.id}
agentName={agent.name}
currentFolderId={agent.folder_id}
isOpen={showMoveDialog}
setIsOpen={setShowMoveDialog}
/>
</>
);
}

View File

@@ -3,15 +3,10 @@
import { cn } from "@/lib/utils";
import { HeartIcon } from "@phosphor-icons/react";
import type { MouseEvent } from "react";
import { useRef } from "react";
import { motion, AnimatePresence } from "framer-motion";
interface FavoriteButtonProps {
isFavorite: boolean;
onClick: (
e: MouseEvent<HTMLButtonElement>,
position: { x: number; y: number },
) => void;
onClick: (e: MouseEvent<HTMLButtonElement>) => void;
className?: string;
}
@@ -20,49 +15,25 @@ export function FavoriteButton({
onClick,
className,
}: FavoriteButtonProps) {
const buttonRef = useRef<HTMLButtonElement>(null);
function handleClick(e: MouseEvent<HTMLButtonElement>) {
const rect = buttonRef.current?.getBoundingClientRect();
const position = rect
? {
x: rect.left + rect.width / 2 - 12,
y: rect.top + rect.height / 2 - 12,
}
: { x: 0, y: 0 };
onClick(e, position);
}
return (
<button
ref={buttonRef}
onClick={handleClick}
onClick={onClick}
className={cn(
"rounded-full p-2 transition-all duration-200",
"hover:scale-110 active:scale-95",
"hover:scale-110",
!isFavorite && "opacity-0 group-hover:opacity-100",
className,
)}
aria-label={isFavorite ? "Remove from favorites" : "Add to favorites"}
>
<AnimatePresence mode="wait" initial={false}>
<motion.div
key={isFavorite ? "filled" : "empty"}
initial={{ scale: 0.5, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0.5, opacity: 0 }}
transition={{ type: "spring", damping: 15, stiffness: 300 }}
>
<HeartIcon
size={20}
weight={isFavorite ? "fill" : "regular"}
className={cn(
"transition-colors duration-200",
isFavorite ? "text-red-500" : "text-gray-600 hover:text-red-500",
)}
/>
</motion.div>
</AnimatePresence>
<HeartIcon
size={20}
weight={isFavorite ? "fill" : "regular"}
className={cn(
"transition-colors duration-200",
isFavorite ? "text-red-500" : "text-gray-600 hover:text-red-500",
)}
/>
</button>
);
}

View File

@@ -14,11 +14,11 @@ import { updateFavoriteInQueries } from "./helpers";
interface Props {
agent: LibraryAgent;
onFavoriteAdd?: (position: { x: number; y: number }) => void;
}
export function useLibraryAgentCard({ agent, onFavoriteAdd }: Props) {
const { id, is_favorite, creator_image_url, marketplace_listing } = agent;
export function useLibraryAgentCard({ agent }: Props) {
const { id, name, is_favorite, creator_image_url, marketplace_listing } =
agent;
const isFromMarketplace = Boolean(marketplace_listing);
const [isFavorite, setIsFavorite] = useState(is_favorite);
@@ -49,31 +49,26 @@ export function useLibraryAgentCard({ agent, onFavoriteAdd }: Props) {
});
}
async function handleToggleFavorite(
e: React.MouseEvent,
position: { x: number; y: number },
) {
async function handleToggleFavorite(e: React.MouseEvent) {
e.preventDefault();
e.stopPropagation();
const newIsFavorite = !isFavorite;
// Optimistic update - update UI immediately
setIsFavorite(newIsFavorite);
updateQueryData(newIsFavorite);
// Trigger animation immediately for adding to favorites
if (newIsFavorite && onFavoriteAdd) {
onFavoriteAdd(position);
}
try {
await updateLibraryAgent({
libraryAgentId: id,
data: { is_favorite: newIsFavorite },
});
toast({
title: newIsFavorite ? "Added to favorites" : "Removed from favorites",
description: `${name} has been ${newIsFavorite ? "added to" : "removed from"} your favorites.`,
});
} catch {
// Revert on failure
setIsFavorite(!newIsFavorite);
updateQueryData(!newIsFavorite);

View File

@@ -1,124 +1,30 @@
"use client";
import { LibraryAgentSort } from "@/app/api/__generated__/models/libraryAgentSort";
import { LoadingSpinner } from "@/components/atoms/LoadingSpinner/LoadingSpinner";
import { InfiniteScroll } from "@/components/contextual/InfiniteScroll/InfiniteScroll";
import { LibraryActionSubHeader } from "../LibraryActionSubHeader/LibraryActionSubHeader";
import { LibraryAgentCard } from "../LibraryAgentCard/LibraryAgentCard";
import { LibraryFolder } from "../LibraryFolder/LibraryFolder";
import { LibrarySubSection } from "../LibrarySubSection/LibrarySubSection";
import { Button } from "@/components/atoms/Button/Button";
import { ArrowLeftIcon, HeartIcon } from "@phosphor-icons/react";
import { Text } from "@/components/atoms/Text/Text";
import { Tab } from "../LibraryTabs/LibraryTabs";
import {
AnimatePresence,
LayoutGroup,
motion,
useReducedMotion,
} from "framer-motion";
import { LibraryFolderEditDialog } from "../LibraryFolderEditDialog/LibraryFolderEditDialog";
import { LibraryFolderDeleteDialog } from "../LibraryFolderDeleteDialog/LibraryFolderDeleteDialog";
import { useLibraryAgentList } from "./useLibraryAgentList";
// cancels the current spring and starts a new one from current state.
const containerVariants = {
hidden: {},
show: {},
exit: {
opacity: 0,
filter: "blur(4px)",
transition: { duration: 0.12 },
},
};
// Reduced motion fallback
const reducedContainerVariants = {
hidden: {},
show: {},
exit: {
opacity: 0,
transition: { duration: 0.12 },
},
};
const itemInitial = {
opacity: 0,
filter: "blur(4px)",
};
const itemAnimate = {
opacity: 1,
filter: "blur(0px)",
};
const itemTransition = {
type: "spring" as const,
stiffness: 300,
damping: 25,
opacity: { duration: 0.2 },
filter: { duration: 0.15 },
};
const reducedItemInitial = { opacity: 0 };
const reducedItemAnimate = { opacity: 1 };
const reducedItemTransition = { duration: 0.15 };
interface Props {
searchTerm: string;
librarySort: LibraryAgentSort;
setLibrarySort: (value: LibraryAgentSort) => void;
selectedFolderId: string | null;
onFolderSelect: (folderId: string | null) => void;
tabs: Tab[];
activeTab: string;
onTabChange: (tabId: string) => void;
}
export function LibraryAgentList({
searchTerm,
librarySort,
setLibrarySort,
selectedFolderId,
onFolderSelect,
tabs,
activeTab,
onTabChange,
}: Props) {
const shouldReduceMotion = useReducedMotion();
const activeContainerVariants = shouldReduceMotion
? reducedContainerVariants
: containerVariants;
const activeInitial = shouldReduceMotion ? reducedItemInitial : itemInitial;
const activeAnimate = shouldReduceMotion ? reducedItemAnimate : itemAnimate;
const activeTransition = shouldReduceMotion
? reducedItemTransition
: itemTransition;
const {
isFavoritesTab,
agentLoading,
agentCount,
agents,
allAgents: agents,
hasNextPage,
isFetchingNextPage,
fetchNextPage,
foldersData,
currentFolder,
showFolders,
editingFolder,
setEditingFolder,
deletingFolder,
setDeletingFolder,
handleAgentDrop,
handleFolderDeleted,
} = useLibraryAgentList({
searchTerm,
librarySort,
selectedFolderId,
onFolderSelect,
activeTab,
});
} = useLibraryAgentList({ searchTerm, librarySort });
return (
<>
@@ -126,42 +32,11 @@ export function LibraryAgentList({
agentCount={agentCount}
setLibrarySort={setLibrarySort}
/>
{!selectedFolderId && (
<LibrarySubSection
tabs={tabs}
activeTab={activeTab}
onTabChange={onTabChange}
/>
)}
<div>
{selectedFolderId && (
<div className="mb-4 flex items-center gap-3">
<Button
variant="ghost"
size="small"
onClick={() => onFolderSelect(null)}
className="gap-2"
>
<ArrowLeftIcon className="h-4 w-4" />
Back to Library
</Button>
{currentFolder && (
<Text variant="h4" className="text-zinc-700">
{currentFolder.icon} {currentFolder.name}
</Text>
)}
</div>
)}
<div className="px-2">
{agentLoading ? (
<div className="flex h-[200px] items-center justify-center">
<LoadingSpinner size="large" />
</div>
) : isFavoritesTab && agents.length === 0 ? (
<div className="flex h-[200px] flex-col items-center justify-center gap-2 text-zinc-500">
<HeartIcon className="h-10 w-10" />
<Text variant="body">No favorite agents yet</Text>
</div>
) : (
<InfiniteScroll
isFetchingNextPage={isFetchingNextPage}
@@ -169,85 +44,14 @@ export function LibraryAgentList({
hasNextPage={hasNextPage}
loader={<LoadingSpinner size="medium" />}
>
<LayoutGroup>
<AnimatePresence mode="popLayout">
<motion.div
key={`${activeTab}-${selectedFolderId || "all"}`}
className="grid grid-cols-1 gap-6 sm:grid-cols-2 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"
variants={activeContainerVariants}
initial="hidden"
animate="show"
exit="exit"
>
{showFolders &&
foldersData?.folders.map((folder, i) => (
<motion.div
key={folder.id}
initial={activeInitial}
animate={activeAnimate}
transition={{
...activeTransition,
delay: i * 0.04,
}}
>
<LibraryFolder
id={folder.id}
name={folder.name}
agentCount={folder.agent_count ?? 0}
color={folder.color ?? undefined}
icon={folder.icon ?? "📁"}
onAgentDrop={handleAgentDrop}
onClick={() => onFolderSelect(folder.id)}
onEdit={() => setEditingFolder(folder)}
onDelete={() => setDeletingFolder(folder)}
/>
</motion.div>
))}
{agents.map((agent, i) => (
<motion.div
key={agent.id}
initial={activeInitial}
animate={activeAnimate}
transition={{
...activeTransition,
delay:
((showFolders
? (foldersData?.folders.length ?? 0)
: 0) +
i) *
0.04,
}}
>
<LibraryAgentCard agent={agent} />
</motion.div>
))}
</motion.div>
</AnimatePresence>
</LayoutGroup>
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{agents.map((agent) => (
<LibraryAgentCard key={agent.id} agent={agent} />
))}
</div>
</InfiniteScroll>
)}
</div>
{editingFolder && (
<LibraryFolderEditDialog
folder={editingFolder}
isOpen={!!editingFolder}
setIsOpen={(open) => {
if (!open) setEditingFolder(null);
}}
/>
)}
{deletingFolder && (
<LibraryFolderDeleteDialog
folder={deletingFolder}
isOpen={!!deletingFolder}
setIsOpen={(open) => {
if (!open) setDeletingFolder(null);
}}
onDeleted={handleFolderDeleted}
/>
)}
</>
);
}

View File

@@ -1,69 +1,36 @@
"use client";
import { useGetV2ListLibraryAgentsInfinite } from "@/app/api/__generated__/endpoints/library/library";
import { getGetV2ListLibraryAgentsQueryKey } from "@/app/api/__generated__/endpoints/library/library";
import {
useGetV2ListLibraryFolders,
usePostV2BulkMoveAgents,
getGetV2ListLibraryFoldersQueryKey,
} from "@/app/api/__generated__/endpoints/folders/folders";
import type { getV2ListLibraryFoldersResponseSuccess } from "@/app/api/__generated__/endpoints/folders/folders";
import type { LibraryFolder } from "@/app/api/__generated__/models/libraryFolder";
import { LibraryAgentSort } from "@/app/api/__generated__/models/libraryAgentSort";
import {
okData,
getPaginatedTotalCount,
getPaginationNextPageNumber,
unpaginate,
} from "@/app/api/helpers";
import { useToast } from "@/components/molecules/Toast/use-toast";
import { useFavoriteAgents } from "../../hooks/useFavoriteAgents";
import { getQueryClient } from "@/lib/react-query/queryClient";
import { useQueryClient } from "@tanstack/react-query";
import { useEffect, useRef, useState } from "react";
import { useEffect, useRef } from "react";
interface Props {
searchTerm: string;
librarySort: LibraryAgentSort;
selectedFolderId: string | null;
onFolderSelect: (folderId: string | null) => void;
activeTab: string;
}
export function useLibraryAgentList({
searchTerm,
librarySort,
selectedFolderId,
onFolderSelect,
activeTab,
}: Props) {
const isFavoritesTab = activeTab === "favorites";
const { toast } = useToast();
const stableQueryClient = getQueryClient();
const queryClient = useQueryClient();
export function useLibraryAgentList({ searchTerm, librarySort }: Props) {
const queryClient = getQueryClient();
const prevSortRef = useRef<LibraryAgentSort | null>(null);
const [editingFolder, setEditingFolder] = useState<LibraryFolder | null>(
null,
);
const [deletingFolder, setDeletingFolder] = useState<LibraryFolder | null>(
null,
);
const {
data: agentsQueryData,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
isLoading: allAgentsLoading,
isLoading: agentLoading,
} = useGetV2ListLibraryAgentsInfinite(
{
page: 1,
page_size: 20,
search_term: searchTerm || undefined,
sort_by: librarySort,
folder_id: selectedFolderId ?? undefined,
include_root_only: selectedFolderId === null ? true : undefined,
},
{
query: {
@@ -72,147 +39,28 @@ export function useLibraryAgentList({
},
);
// Reset queries when sort changes to ensure fresh data with correct sorting
useEffect(() => {
if (prevSortRef.current !== null && prevSortRef.current !== librarySort) {
stableQueryClient.resetQueries({
// Reset all library agent queries to ensure fresh fetch with new sort
queryClient.resetQueries({
queryKey: ["/api/library/agents"],
});
}
prevSortRef.current = librarySort;
}, [librarySort, stableQueryClient]);
}, [librarySort, queryClient]);
const allAgentsList = agentsQueryData
const allAgents = agentsQueryData
? unpaginate(agentsQueryData, "agents")
: [];
const allAgentsCount = getPaginatedTotalCount(agentsQueryData);
const favoriteAgentsData = useFavoriteAgents({ searchTerm });
const {
agentLoading,
agentCount,
allAgents: agents,
hasNextPage: agentsHasNextPage,
isFetchingNextPage: agentsIsFetchingNextPage,
fetchNextPage: agentsFetchNextPage,
} = isFavoritesTab
? favoriteAgentsData
: {
agentLoading: allAgentsLoading,
agentCount: allAgentsCount,
allAgents: allAgentsList,
hasNextPage: hasNextPage,
isFetchingNextPage: isFetchingNextPage,
fetchNextPage: fetchNextPage,
};
const { data: rawFoldersData } = useGetV2ListLibraryFolders(undefined, {
query: { select: okData },
});
const foldersData = searchTerm ? undefined : rawFoldersData;
const { mutate: moveAgentToFolder } = usePostV2BulkMoveAgents({
mutation: {
onMutate: async ({ data }) => {
await queryClient.cancelQueries({
queryKey: getGetV2ListLibraryFoldersQueryKey(),
});
await queryClient.cancelQueries({
queryKey: getGetV2ListLibraryAgentsQueryKey(),
});
const previousFolders =
queryClient.getQueriesData<getV2ListLibraryFoldersResponseSuccess>({
queryKey: getGetV2ListLibraryFoldersQueryKey(),
});
if (data.folder_id) {
queryClient.setQueriesData<getV2ListLibraryFoldersResponseSuccess>(
{ queryKey: getGetV2ListLibraryFoldersQueryKey() },
(old) => {
if (!old?.data?.folders) return old;
return {
...old,
data: {
...old.data,
folders: old.data.folders.map((f) =>
f.id === data.folder_id
? {
...f,
agent_count:
(f.agent_count ?? 0) + data.agent_ids.length,
}
: f,
),
},
};
},
);
}
return { previousFolders };
},
onError: (_error, _variables, context) => {
if (context?.previousFolders) {
for (const [queryKey, data] of context.previousFolders) {
queryClient.setQueryData(queryKey, data);
}
}
toast({
title: "Error",
description: "Failed to move agent. Please try again.",
variant: "destructive",
});
},
onSettled: () => {
queryClient.invalidateQueries({
queryKey: getGetV2ListLibraryFoldersQueryKey(),
});
queryClient.invalidateQueries({
queryKey: getGetV2ListLibraryAgentsQueryKey(),
});
},
},
});
function handleAgentDrop(agentId: string, folderId: string) {
moveAgentToFolder({
data: {
agent_ids: [agentId],
folder_id: folderId,
},
});
}
const currentFolder = selectedFolderId
? foldersData?.folders.find((f) => f.id === selectedFolderId)
: null;
const showFolders = !isFavoritesTab && !selectedFolderId;
function handleFolderDeleted() {
if (selectedFolderId === deletingFolder?.id) {
onFolderSelect(null);
}
}
const agentCount = getPaginatedTotalCount(agentsQueryData);
return {
isFavoritesTab,
allAgents,
agentLoading,
hasNextPage,
agentCount,
agents,
hasNextPage: agentsHasNextPage,
isFetchingNextPage: agentsIsFetchingNextPage,
fetchNextPage: agentsFetchNextPage,
foldersData,
currentFolder,
showFolders,
editingFolder,
setEditingFolder,
deletingFolder,
setDeletingFolder,
handleAgentDrop,
handleFolderDeleted,
isFetchingNextPage,
fetchNextPage,
};
}

View File

@@ -1,255 +0,0 @@
import { motion } from "framer-motion";
import { Text } from "@/components/atoms/Text/Text";
type FolderSize = "xs" | "sm" | "md" | "lg" | "xl";
export type FolderColorName = "blue" | "purple" | "emerald" | "orange" | "pink";
export type FolderColor = FolderColorName | (string & {});
const hexToColorName: Record<string, FolderColorName> = {
"#3B82F6": "blue",
"#3b82f6": "blue",
"#A855F7": "purple",
"#a855f7": "purple",
"#10B981": "emerald",
"#10b981": "emerald",
"#F97316": "orange",
"#f97316": "orange",
"#EC4899": "pink",
"#ec4899": "pink",
};
export function resolveColor(color: FolderColor | undefined): FolderColorName {
if (!color) return "blue";
if (color in hexToColorName) return hexToColorName[color];
if (color in colorMap) return color as FolderColorName;
return "blue";
}
interface Props {
className?: string;
size?: FolderSize | number;
color?: FolderColor;
icon?: string;
isOpen?: boolean;
}
const sizeMap: Record<FolderSize, number> = {
xs: 0.4,
sm: 0.75,
md: 1,
lg: 1.25,
xl: 1.5,
};
const colorMap: Record<
FolderColorName,
{
bg: string;
border: string;
borderLight: string;
fill: string;
stroke: string;
}
> = {
blue: {
bg: "bg-blue-300",
border: "border-blue-300",
borderLight: "border-blue-200",
fill: "fill-blue-300",
stroke: "stroke-blue-400",
},
purple: {
bg: "bg-purple-200",
border: "border-purple-200",
borderLight: "border-purple-200",
fill: "fill-purple-200",
stroke: "stroke-purple-400",
},
emerald: {
bg: "bg-emerald-300",
border: "border-emerald-300",
borderLight: "border-emerald-200",
fill: "fill-emerald-300",
stroke: "stroke-emerald-400",
},
orange: {
bg: "bg-orange-200",
border: "border-orange-200",
borderLight: "border-orange-200",
fill: "fill-orange-200",
stroke: "stroke-orange-400",
},
pink: {
bg: "bg-pink-300",
border: "border-pink-300",
borderLight: "border-pink-200",
fill: "fill-pink-300",
stroke: "stroke-pink-400",
},
};
export const folderCardStyles: Record<
FolderColorName,
{ bg: string; border: string; buttonBase: string; buttonHover: string }
> = {
blue: {
bg: "bg-blue-50",
border: "border-blue-200",
buttonBase: "border-zinc-600 text-black",
buttonHover: "hover:bg-blue-200",
},
purple: {
bg: "bg-purple-50",
border: "border-purple-200",
buttonBase: "border-zinc-600 text-black ",
buttonHover: "hover:bg-purple-200",
},
emerald: {
bg: "bg-emerald-50",
border: "border-emerald-200",
buttonBase: "border-zinc-600 text-black",
buttonHover: "hover:bg-emerald-200",
},
orange: {
bg: "bg-orange-50",
border: "border-orange-200",
buttonBase: "border-zinc-600 text-black",
buttonHover: "hover:bg-orange-200",
},
pink: {
bg: "bg-pink-50",
border: "border-pink-200",
buttonBase: "border-zinc-600 text-black",
buttonHover: "hover:bg-pink-200",
},
};
export function FolderIcon({
className = "",
size = "xs",
color = "blue",
icon,
isOpen = false,
}: Props) {
const scale = typeof size === "number" ? size : sizeMap[size];
const resolvedColor = resolveColor(color);
const colors = colorMap[resolvedColor];
return (
<div
className={`group relative cursor-pointer ${className}`}
style={{
width: 320 * scale,
height: 208 * scale,
}}
>
<div
className="h-52 w-80 origin-top-left"
style={{ transform: `scale(${scale})`, perspective: "500px" }}
>
<div
className={`folder-back relative mx-auto flex h-full w-[87.5%] justify-center overflow-visible rounded-3xl ${colors.bg} ${colors.border}`}
>
{[
{
initial: { rotate: -3, x: -38, y: 2 },
open: { rotate: -8, x: -70, y: -75 },
transition: {
type: "spring" as const,
bounce: 0.15,
stiffness: 160,
damping: 22,
},
className: "z-10",
},
{
initial: { rotate: 0, x: 0, y: 0 },
open: { rotate: 1, x: 2, y: -95 },
transition: {
type: "spring" as const,
duration: 0.55,
bounce: 0.12,
stiffness: 190,
damping: 24,
},
className: "z-20",
},
{
initial: { rotate: 3.5, x: 42, y: 1 },
open: { rotate: 9, x: 75, y: -80 },
transition: {
type: "spring" as const,
duration: 0.58,
bounce: 0.17,
stiffness: 170,
damping: 21,
},
className: "z-10",
},
].map((page, i) => (
<motion.div
key={i}
initial={page.initial}
animate={isOpen ? page.open : page.initial}
transition={page.transition}
className={`absolute top-2 h-fit w-32 rounded-xl shadow-lg ${page.className}`}
>
<Page color={resolvedColor} />
</motion.div>
))}
</div>
<motion.div
animate={{
rotateX: isOpen ? -15 : 0,
}}
transition={{ type: "spring", duration: 0.5, bounce: 0.25 }}
className="absolute inset-x-0 -bottom-px z-30 mx-auto flex h-44 w-[87.5%] origin-bottom items-end justify-center overflow-visible"
style={{ transformStyle: "preserve-3d" }}
>
<svg
className="h-auto w-full"
viewBox="0 0 173 109"
fill="none"
xmlns="http://www.w3.org/2000/svg"
preserveAspectRatio="none"
>
<path
className={`${colors.fill} ${colors.stroke}`}
d="M15.0423 0.500003C0.5 0.500009 0.5 14.2547 0.5 14.2547V92.5C0.5 101.337 7.66344 108.5 16.5 108.5H156.5C165.337 108.5 172.5 101.337 172.5 92.5V34.3302C172.5 25.4936 165.355 18.3302 156.519 18.3302H108.211C98.1341 18.3302 91.2921 5.57144 82.0156 1.63525C80.3338 0.921645 78.2634 0.500002 75.7187 0.500003H15.0423Z"
/>
</svg>
<div className="absolute inset-0 flex items-center justify-center text-7xl">
{icon}
</div>
</motion.div>
</div>
</div>
);
}
interface PageProps {
color: FolderColorName;
}
function Page({ color = "blue" }: PageProps) {
const colors = colorMap[color];
return (
<div
className={`h-full w-full rounded-xl border bg-white p-4 ${colors.borderLight}`}
>
<div className="flex flex-col gap-2">
<Text variant="h5" className="text-black">
agent.json
</Text>
{Array.from({ length: 8 }).map((_, i) => (
<div key={i} className="flex gap-2">
<div className="h-1.5 flex-1 rounded-full bg-neutral-100" />
<div className="h-1.5 flex-1 rounded-full bg-neutral-100" />
</div>
))}
</div>
</div>
);
}

View File

@@ -1,136 +0,0 @@
"use client";
import { Text } from "@/components/atoms/Text/Text";
import { Button } from "@/components/atoms/Button/Button";
import {
FolderIcon,
FolderColor,
folderCardStyles,
resolveColor,
} from "./FolderIcon";
import { useState } from "react";
import { PencilSimpleIcon, TrashIcon } from "@phosphor-icons/react";
interface Props {
id: string;
name: string;
agentCount: number;
color?: FolderColor;
icon: string;
onEdit?: () => void;
onDelete?: () => void;
onAgentDrop?: (agentId: string, folderId: string) => void;
onClick?: () => void;
}
export function LibraryFolder({
id,
name,
agentCount,
color,
icon,
onEdit,
onDelete,
onAgentDrop,
onClick,
}: Props) {
const [isHovered, setIsHovered] = useState(false);
const [isDragOver, setIsDragOver] = useState(false);
const resolvedColor = resolveColor(color);
const cardStyle = folderCardStyles[resolvedColor];
function handleDragOver(e: React.DragEvent<HTMLDivElement>) {
if (e.dataTransfer.types.includes("application/agent-id")) {
e.preventDefault();
e.dataTransfer.dropEffect = "move";
setIsDragOver(true);
}
}
function handleDragLeave() {
setIsDragOver(false);
}
function handleDrop(e: React.DragEvent<HTMLDivElement>) {
e.preventDefault();
setIsDragOver(false);
const agentId = e.dataTransfer.getData("application/agent-id");
if (agentId && onAgentDrop) {
onAgentDrop(agentId, id);
}
}
return (
<div
data-testid="library-folder"
data-folder-id={id}
className={`group relative inline-flex h-[10.625rem] w-full max-w-[25rem] cursor-pointer flex-col items-start justify-between gap-2.5 rounded-medium border p-4 transition-all duration-200 hover:shadow-md ${
isDragOver
? "border-blue-400 bg-blue-50 ring-2 ring-blue-200"
: `${cardStyle.border} ${cardStyle.bg}`
}`}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
onClick={onClick}
>
<div className="flex w-full items-start justify-between gap-4">
{/* Left side - Folder name and agent count */}
<div className="flex flex-1 flex-col gap-2">
<Text
variant="h5"
data-testid="library-folder-name"
className="line-clamp-2 hyphens-auto break-words"
>
{name}
</Text>
<Text
variant="small"
className="text-zinc-500"
data-testid="library-folder-agent-count"
>
{agentCount} {agentCount === 1 ? "agent" : "agents"}
</Text>
</div>
{/* Right side - Custom folder icon */}
<div className="flex-shrink-0">
<FolderIcon isOpen={isHovered} color={color} icon={icon} />
</div>
</div>
{/* Action buttons - visible on hover */}
<div
className="flex items-center justify-end gap-2"
data-testid="library-folder-actions"
>
<Button
variant="icon"
size="icon"
aria-label="Edit folder"
onClick={(e) => {
e.stopPropagation();
onEdit?.();
}}
className={`h-8 w-8 border p-2 ${cardStyle.buttonBase} ${cardStyle.buttonHover}`}
>
<PencilSimpleIcon className="h-4 w-4" />
</Button>
<Button
variant="icon"
size="icon"
aria-label="Delete folder"
onClick={(e) => {
e.stopPropagation();
onDelete?.();
}}
className={`h-8 w-8 border p-2 ${cardStyle.buttonBase} ${cardStyle.buttonHover}`}
>
<TrashIcon className="h-4 w-4" />
</Button>
</div>
</div>
);
}

View File

@@ -1,229 +0,0 @@
"use client";
import { Button } from "@/components/atoms/Button/Button";
import { Input } from "@/components/atoms/Input/Input";
import { Select } from "@/components/atoms/Select/Select";
import { Text } from "@/components/atoms/Text/Text";
import { Dialog } from "@/components/molecules/Dialog/Dialog";
import {
Form,
FormControl,
FormField,
FormItem,
FormMessage,
} from "@/components/molecules/Form/Form";
import { zodResolver } from "@hookform/resolvers/zod";
import { FolderSimpleIcon } from "@phosphor-icons/react";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { EmojiPicker } from "@ferrucc-io/emoji-picker";
import {
usePostV2CreateFolder,
getGetV2ListLibraryFoldersQueryKey,
} from "@/app/api/__generated__/endpoints/folders/folders";
import { useToast } from "@/components/molecules/Toast/use-toast";
import { useQueryClient } from "@tanstack/react-query";
const FOLDER_COLORS = [
{ value: "#3B82F6", label: "Blue" },
{ value: "#A855F7", label: "Purple" },
{ value: "#10B981", label: "Green" },
{ value: "#F97316", label: "Orange" },
{ value: "#EC4899", label: "Pink" },
];
export const libraryFolderCreationFormSchema = z.object({
folderName: z.string().min(1, "Folder name is required"),
folderColor: z.string().min(1, "Folder color is required"),
folderIcon: z.string().min(1, "Folder icon is required"),
});
export default function LibraryFolderCreationDialog() {
const [isOpen, setIsOpen] = useState(false);
const queryClient = useQueryClient();
const { toast } = useToast();
const { mutate: createFolder, isPending } = usePostV2CreateFolder({
mutation: {
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: getGetV2ListLibraryFoldersQueryKey(),
});
setIsOpen(false);
form.reset();
toast({
title: "Folder created",
description: "Your folder has been created successfully.",
});
},
onError: () => {
toast({
title: "Error",
description: "Failed to create folder. Please try again.",
variant: "destructive",
});
},
},
});
const form = useForm<z.infer<typeof libraryFolderCreationFormSchema>>({
resolver: zodResolver(libraryFolderCreationFormSchema),
defaultValues: {
folderName: "",
folderColor: "",
folderIcon: "",
},
});
function onSubmit(values: z.infer<typeof libraryFolderCreationFormSchema>) {
createFolder({
data: {
name: values.folderName.trim(),
color: values.folderColor,
icon: values.folderIcon,
},
});
}
return (
<Dialog
title="Create Folder"
styling={{ maxWidth: "30rem" }}
controlled={{
isOpen,
set: setIsOpen,
}}
onClose={() => {
setIsOpen(false);
}}
>
<Dialog.Trigger>
<Button
data-testid="upload-agent-button"
variant="secondary"
className="h-fit w-fit"
size="small"
>
<FolderSimpleIcon width={18} height={18} />
<span className="create-folder">Create folder</span>
</Button>
</Dialog.Trigger>
<Dialog.Content>
<Form
form={form}
onSubmit={(values) => onSubmit(values)}
className="flex flex-col justify-center gap-2 px-1"
>
<FormField
control={form.control}
name="folderName"
render={({ field }) => (
<FormItem>
<FormControl>
<Input
{...field}
id={field.name}
label="Folder name"
placeholder="Enter folder name"
className="!mb-0 w-full"
wrapperClassName="!mb-0"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="folderColor"
render={({ field }) => (
<FormItem>
<FormControl>
<Select
id="folderColor"
label="Folder color"
placeholder="Select a color"
value={field.value}
onValueChange={field.onChange}
options={FOLDER_COLORS.map((color) => ({
value: color.value,
label: color.label,
icon: (
<div
className="h-4 w-4 rounded-full"
style={{ backgroundColor: color.value }}
/>
),
}))}
wrapperClassName="!mb-0"
renderItem={(option) => (
<div className="flex items-center gap-2">
{option.icon}
<span>{option.label}</span>
</div>
)}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="folderIcon"
render={({ field }) => (
<FormItem>
<div className="flex flex-col gap-2">
<Text variant="large-medium" as="span" className="text-black">
Folder icon
</Text>
<div className="flex flex-col gap-3">
<div className="flex items-center gap-3">
<Text variant="small" className="text-zinc-500">
Selected:
</Text>
<div className="flex h-10 w-10 items-center justify-center rounded-lg border border-zinc-200 bg-zinc-50 text-2xl">
{form.watch("folderIcon") || (
<span className="text-sm text-zinc-400"></span>
)}
</div>
</div>
<div className="h-[295px] w-full overflow-hidden">
<EmojiPicker
onEmojiSelect={(emoji) => {
field.onChange(emoji);
}}
emojiSize={32}
className="w-full rounded-2xl px-2"
>
<EmojiPicker.Group>
<EmojiPicker.List
hideStickyHeader
containerHeight={295}
/>
</EmojiPicker.Group>
</EmojiPicker>
</div>
</div>
<FormMessage />
</div>
</FormItem>
)}
/>
<Button
type="submit"
variant="primary"
className="mt-2 min-w-[18rem]"
disabled={!form.formState.isValid || isPending}
loading={isPending}
>
Create
</Button>
</Form>
</Dialog.Content>
</Dialog>
);
}

View File

@@ -1,96 +0,0 @@
"use client";
import { Button } from "@/components/atoms/Button/Button";
import { Text } from "@/components/atoms/Text/Text";
import { Dialog } from "@/components/molecules/Dialog/Dialog";
import { useToast } from "@/components/molecules/Toast/use-toast";
import {
useDeleteV2DeleteFolder,
getGetV2ListLibraryFoldersQueryKey,
} from "@/app/api/__generated__/endpoints/folders/folders";
import { getGetV2ListLibraryAgentsQueryKey } from "@/app/api/__generated__/endpoints/library/library";
import { useQueryClient } from "@tanstack/react-query";
import type { LibraryFolder } from "@/app/api/__generated__/models/libraryFolder";
interface Props {
folder: LibraryFolder;
isOpen: boolean;
setIsOpen: (open: boolean) => void;
onDeleted?: () => void;
}
export function LibraryFolderDeleteDialog({
folder,
isOpen,
setIsOpen,
onDeleted,
}: Props) {
const queryClient = useQueryClient();
const { toast } = useToast();
const { mutate: deleteFolder, isPending } = useDeleteV2DeleteFolder({
mutation: {
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: getGetV2ListLibraryFoldersQueryKey(),
});
queryClient.invalidateQueries({
queryKey: getGetV2ListLibraryAgentsQueryKey(),
});
toast({
title: "Folder deleted",
description: `"${folder.name}" has been deleted.`,
});
setIsOpen(false);
onDeleted?.();
},
onError: () => {
toast({
title: "Error",
description: "Failed to delete folder. Please try again.",
variant: "destructive",
});
},
},
});
function handleDelete() {
deleteFolder({ folderId: folder.id });
}
return (
<Dialog
controlled={{
isOpen,
set: setIsOpen,
}}
styling={{ maxWidth: "32rem" }}
title="Delete folder"
>
<Dialog.Content>
<div>
<Text variant="large">
Are you sure you want to delete &ldquo;{folder.name}&rdquo;? Agents
inside this folder will be moved back to your library.
</Text>
<Dialog.Footer>
<Button
variant="secondary"
disabled={isPending}
onClick={() => setIsOpen(false)}
>
Cancel
</Button>
<Button
variant="destructive"
onClick={handleDelete}
loading={isPending}
>
Delete Folder
</Button>
</Dialog.Footer>
</div>
</Dialog.Content>
</Dialog>
);
}

View File

@@ -1,287 +0,0 @@
"use client";
import { Button } from "@/components/atoms/Button/Button";
import { Input } from "@/components/atoms/Input/Input";
import { Select } from "@/components/atoms/Select/Select";
import { Text } from "@/components/atoms/Text/Text";
import { Dialog } from "@/components/molecules/Dialog/Dialog";
import {
Form,
FormControl,
FormField,
FormItem,
FormMessage,
} from "@/components/molecules/Form/Form";
import { useToast } from "@/components/molecules/Toast/use-toast";
import { zodResolver } from "@hookform/resolvers/zod";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { EmojiPicker } from "@ferrucc-io/emoji-picker";
import {
usePatchV2UpdateFolder,
getGetV2ListLibraryFoldersQueryKey,
} from "@/app/api/__generated__/endpoints/folders/folders";
import { useQueryClient } from "@tanstack/react-query";
import type { LibraryFolder } from "@/app/api/__generated__/models/libraryFolder";
import type { getV2ListLibraryFoldersResponseSuccess } from "@/app/api/__generated__/endpoints/folders/folders";
import { ApiError } from "@/lib/autogpt-server-api/helpers";
const FOLDER_COLORS = [
{ value: "#3B82F6", label: "Blue" },
{ value: "#A855F7", label: "Purple" },
{ value: "#10B981", label: "Green" },
{ value: "#F97316", label: "Orange" },
{ value: "#EC4899", label: "Pink" },
];
const editFolderSchema = z.object({
folderName: z.string().min(1, "Folder name is required"),
folderColor: z.string().min(1, "Folder color is required"),
folderIcon: z.string().min(1, "Folder icon is required"),
});
interface Props {
folder: LibraryFolder;
isOpen: boolean;
setIsOpen: (open: boolean) => void;
}
export function LibraryFolderEditDialog({ folder, isOpen, setIsOpen }: Props) {
const queryClient = useQueryClient();
const { toast } = useToast();
const form = useForm<z.infer<typeof editFolderSchema>>({
resolver: zodResolver(editFolderSchema),
defaultValues: {
folderName: folder.name,
folderColor: folder.color ?? "",
folderIcon: folder.icon ?? "",
},
});
useEffect(() => {
if (isOpen) {
form.reset({
folderName: folder.name,
folderColor: folder.color ?? "",
folderIcon: folder.icon ?? "",
});
}
}, [isOpen, folder, form]);
const { mutate: updateFolder, isPending } = usePatchV2UpdateFolder({
mutation: {
onMutate: async ({ folderId, data }) => {
await queryClient.cancelQueries({
queryKey: getGetV2ListLibraryFoldersQueryKey(),
});
const previousData =
queryClient.getQueriesData<getV2ListLibraryFoldersResponseSuccess>({
queryKey: getGetV2ListLibraryFoldersQueryKey(),
});
queryClient.setQueriesData<getV2ListLibraryFoldersResponseSuccess>(
{ queryKey: getGetV2ListLibraryFoldersQueryKey() },
(old) => {
if (!old?.data?.folders) return old;
return {
...old,
data: {
...old.data,
folders: old.data.folders.map((f) =>
f.id === folderId
? {
...f,
name: data.name ?? f.name,
color: data.color ?? f.color,
icon: data.icon ?? f.icon,
}
: f,
),
},
};
},
);
return { previousData };
},
onError: (error: unknown, _variables, context) => {
if (context?.previousData) {
for (const [queryKey, data] of context.previousData) {
queryClient.setQueryData(queryKey, data);
}
}
if (error instanceof ApiError) {
const detail = (error.response as any)?.detail ?? "";
if (
typeof detail === "string" &&
detail.toLowerCase().includes("already exists")
) {
form.setError("folderName", {
message: "A folder with this name already exists",
});
return;
}
}
toast({
title: "Error",
description: "Failed to update folder. Please try again.",
variant: "destructive",
});
},
onSuccess: () => {
setIsOpen(false);
toast({
title: "Folder updated",
description: "Your folder has been updated successfully.",
});
},
onSettled: () => {
queryClient.invalidateQueries({
queryKey: getGetV2ListLibraryFoldersQueryKey(),
});
},
},
});
function onSubmit(values: z.infer<typeof editFolderSchema>) {
updateFolder({
folderId: folder.id,
data: {
name: values.folderName.trim(),
color: values.folderColor,
icon: values.folderIcon,
},
});
}
return (
<Dialog
title="Edit Folder"
styling={{ maxWidth: "30rem" }}
controlled={{
isOpen,
set: setIsOpen,
}}
>
<Dialog.Content>
<Form
form={form}
onSubmit={(values) => onSubmit(values)}
className="flex flex-col justify-center gap-2 px-1"
>
<FormField
control={form.control}
name="folderName"
render={({ field }) => (
<FormItem>
<FormControl>
<Input
{...field}
id={field.name}
label="Folder name"
placeholder="Enter folder name"
className="w-full"
wrapperClassName="!mb-0"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="folderColor"
render={({ field }) => (
<FormItem>
<FormControl>
<Select
id="folderColor"
label="Folder color"
placeholder="Select a color"
value={field.value}
onValueChange={field.onChange}
wrapperClassName="!mb-0"
options={FOLDER_COLORS.map((color) => ({
value: color.value,
label: color.label,
icon: (
<div
className="h-4 w-4 rounded-full"
style={{ backgroundColor: color.value }}
/>
),
}))}
renderItem={(option) => (
<div className="flex items-center gap-2">
{option.icon}
<span>{option.label}</span>
</div>
)}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="folderIcon"
render={({ field }) => (
<FormItem>
<div className="flex flex-col gap-2">
<Text variant="large-medium" as="span" className="text-black">
Folder icon
</Text>
<div className="flex flex-col gap-3">
<div className="flex items-center gap-3">
<Text variant="small" className="text-zinc-500">
Selected:
</Text>
<div className="flex h-10 w-10 items-center justify-center rounded-lg border border-zinc-200 bg-zinc-50 text-2xl">
{form.watch("folderIcon") || (
<span className="text-sm text-zinc-400"></span>
)}
</div>
</div>
<div className="h-[295px] w-full overflow-hidden">
<EmojiPicker
onEmojiSelect={(emoji) => {
field.onChange(emoji);
}}
emojiSize={32}
className="w-full rounded-2xl px-2"
>
<EmojiPicker.Group className="pt-2">
<EmojiPicker.List
hideStickyHeader
containerHeight={295}
/>
</EmojiPicker.Group>
</EmojiPicker>
</div>
</div>
<FormMessage />
</div>
</FormItem>
)}
/>
<Button
type="submit"
variant="primary"
className="mt-2 min-w-[18rem]"
disabled={!form.formState.isValid || isPending}
loading={isPending}
>
Save Changes
</Button>
</Form>
</Dialog.Content>
</Dialog>
);
}

View File

@@ -1,21 +0,0 @@
import LibraryFolderCreationDialog from "../LibraryFolderCreationDialog/LibraryFolderCreationDialog";
import { LibraryTabs, Tab } from "../LibraryTabs/LibraryTabs";
interface Props {
tabs: Tab[];
activeTab: string;
onTabChange: (tabId: string) => void;
}
export function LibrarySubSection({ tabs, activeTab, onTabChange }: Props) {
return (
<div className="flex items-center justify-between gap-4">
<LibraryTabs
tabs={tabs}
activeTab={activeTab}
onTabChange={onTabChange}
/>
<LibraryFolderCreationDialog />
</div>
);
}

View File

@@ -1,147 +0,0 @@
"use client";
import { useState, useEffect, useRef } from "react";
import { motion } from "framer-motion";
import { cn } from "@/lib/utils";
import { Icon } from "@phosphor-icons/react";
import { useFavoriteAnimation } from "../../context/FavoriteAnimationContext";
export interface Tab {
id: string;
title: string;
icon: Icon;
}
interface Props {
tabs: Tab[];
activeTab: string;
onTabChange: (tabId: string) => void;
layoutId?: string;
}
export function LibraryTabs({
tabs,
activeTab,
onTabChange,
layoutId = "library-tabs",
}: Props) {
const { registerFavoritesTabRef } = useFavoriteAnimation();
return (
<div className="flex items-center gap-2">
{tabs.map((tab) => (
<TabButton
key={tab.id}
tab={tab}
isActive={activeTab === tab.id}
onSelect={onTabChange}
layoutId={layoutId}
onRefReady={
tab.id === "favorites" ? registerFavoritesTabRef : undefined
}
/>
))}
</div>
);
}
interface TabButtonProps {
tab: Tab;
isActive: boolean;
onSelect: (tabId: string) => void;
layoutId: string;
onRefReady?: (element: HTMLElement | null) => void;
}
function TabButton({
tab,
isActive,
onSelect,
layoutId,
onRefReady,
}: TabButtonProps) {
const [isLoaded, setIsLoaded] = useState(false);
const buttonRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (isActive && !isLoaded) {
setIsLoaded(true);
}
}, [isActive, isLoaded]);
useEffect(() => {
if (onRefReady) {
onRefReady(buttonRef.current);
}
}, [onRefReady]);
const ButtonIcon = tab.icon;
const activeColor = "text-primary";
return (
<motion.div
ref={buttonRef}
layoutId={`${layoutId}-button-${tab.id}`}
transition={{
layout: {
type: "spring",
damping: 20,
stiffness: 230,
mass: 1.2,
ease: [0.215, 0.61, 0.355, 1],
},
}}
onClick={() => {
onSelect(tab.id);
setIsLoaded(true);
}}
className="flex h-fit w-fit"
style={{ willChange: "transform" }}
>
<motion.div
layout
transition={{
layout: {
type: "spring",
damping: 20,
stiffness: 230,
mass: 1.2,
},
}}
className={cn(
"flex h-fit cursor-pointer items-center gap-1.5 overflow-hidden border border-zinc-200 px-3 py-2 text-black transition-colors duration-75 ease-out hover:border-zinc-300 hover:bg-zinc-300",
isActive && activeColor,
isActive ? "px-4" : "px-3",
)}
style={{
borderRadius: "25px",
}}
>
<motion.div
layoutId={`${layoutId}-icon-${tab.id}`}
className="shrink-0"
>
<ButtonIcon size={18} />
</motion.div>
{isActive && (
<motion.div
className="flex items-center"
initial={isLoaded ? { opacity: 0, filter: "blur(4px)" } : false}
animate={{ opacity: 1, filter: "blur(0px)" }}
transition={{
duration: isLoaded ? 0.2 : 0,
ease: [0.86, 0, 0.07, 1],
}}
>
<motion.span
layoutId={`${layoutId}-text-${tab.id}`}
className="font-sans text-sm font-medium text-black"
>
{tab.title}
</motion.span>
</motion.div>
)}
</motion.div>
</motion.div>
);
}

View File

@@ -1,135 +0,0 @@
"use client";
import { Button } from "@/components/atoms/Button/Button";
import { Input } from "@/components/atoms/Input/Input";
import { Text } from "@/components/atoms/Text/Text";
import { Dialog } from "@/components/molecules/Dialog/Dialog";
import { useToast } from "@/components/molecules/Toast/use-toast";
import {
useGetV2ListLibraryFolders,
usePostV2BulkMoveAgents,
getGetV2ListLibraryFoldersQueryKey,
} from "@/app/api/__generated__/endpoints/folders/folders";
import { getGetV2ListLibraryAgentsQueryKey } from "@/app/api/__generated__/endpoints/library/library";
import { okData } from "@/app/api/helpers";
import { useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
interface Props {
agentId: string;
agentName: string;
currentFolderId?: string | null;
isOpen: boolean;
setIsOpen: (open: boolean) => void;
}
export function MoveToFolderDialog({
agentId,
agentName,
currentFolderId,
isOpen,
setIsOpen,
}: Props) {
const queryClient = useQueryClient();
const { toast } = useToast();
const [search, setSearch] = useState("");
const { data: foldersData } = useGetV2ListLibraryFolders(undefined, {
query: { select: okData },
});
const { mutate: moveAgent, isPending } = usePostV2BulkMoveAgents({
mutation: {
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: getGetV2ListLibraryAgentsQueryKey(),
});
queryClient.invalidateQueries({
queryKey: getGetV2ListLibraryFoldersQueryKey(),
});
setIsOpen(false);
setSearch("");
toast({
title: "Agent moved",
description: `"${agentName}" has been moved.`,
});
},
onError: () => {
toast({
title: "Error",
description: "Failed to move agent. Please try again.",
variant: "destructive",
});
},
},
});
const folders = (foldersData?.folders ?? []).filter(
(f) =>
f.id !== currentFolderId &&
f.name.toLowerCase().includes(search.toLowerCase()),
);
function handleMoveToFolder(folderId: string) {
moveAgent({
data: {
agent_ids: [agentId],
folder_id: folderId,
},
});
}
return (
<Dialog
controlled={{ isOpen, set: setIsOpen }}
styling={{ maxWidth: "28rem" }}
title="Move to folder"
onClose={() => {
setSearch("");
}}
>
<Dialog.Content>
<div className="flex flex-col gap-3">
<Input
id="search-folders"
label="Search folders"
placeholder="Search folders..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-full"
/>
<div className="max-h-[280px] overflow-y-auto">
{folders.length === 0 ? (
<div className="flex h-20 items-center justify-center">
<Text variant="small" className="text-zinc-400">
No folders found
</Text>
</div>
) : (
<div className="flex flex-col gap-1">
{folders.map((folder) => (
<Button
key={folder.id}
variant="ghost"
className="w-full justify-start gap-3 px-3 py-2.5"
disabled={isPending}
onClick={() => handleMoveToFolder(folder.id)}
>
<span className="text-lg">{folder.icon ?? "📁"}</span>
<div className="flex flex-col items-start">
<Text variant="small-medium">{folder.name}</Text>
<Text variant="small" className="text-zinc-400">
{folder.agent_count ?? 0}{" "}
{(folder.agent_count ?? 0) === 1 ? "agent" : "agents"}
</Text>
</div>
</Button>
))}
</div>
)}
</div>
</div>
</Dialog.Content>
</Dialog>
);
}

View File

@@ -1,84 +0,0 @@
"use client";
import {
createContext,
useContext,
useState,
useCallback,
useRef,
} from "react";
import { FlyingHeart } from "../components/FlyingHeart/FlyingHeart";
interface FavoriteAnimationContextType {
triggerFavoriteAnimation: (startPosition: { x: number; y: number }) => void;
registerFavoritesTabRef: (element: HTMLElement | null) => void;
}
const FavoriteAnimationContext =
createContext<FavoriteAnimationContextType | null>(null);
interface FavoriteAnimationProviderProps {
children: React.ReactNode;
onAnimationComplete?: () => void;
}
export function FavoriteAnimationProvider({
children,
onAnimationComplete,
}: FavoriteAnimationProviderProps) {
const [animationState, setAnimationState] = useState<{
startPosition: { x: number; y: number } | null;
targetPosition: { x: number; y: number } | null;
}>({
startPosition: null,
targetPosition: null,
});
const favoritesTabRef = useRef<HTMLElement | null>(null);
const registerFavoritesTabRef = useCallback((element: HTMLElement | null) => {
favoritesTabRef.current = element;
}, []);
const triggerFavoriteAnimation = useCallback(
(startPosition: { x: number; y: number }) => {
if (favoritesTabRef.current) {
const rect = favoritesTabRef.current.getBoundingClientRect();
const targetPosition = {
x: rect.left + rect.width / 2 - 12,
y: rect.top + rect.height / 2 - 12,
};
setAnimationState({ startPosition, targetPosition });
}
},
[],
);
function handleAnimationComplete() {
setAnimationState({ startPosition: null, targetPosition: null });
onAnimationComplete?.();
}
return (
<FavoriteAnimationContext.Provider
value={{ triggerFavoriteAnimation, registerFavoritesTabRef }}
>
{children}
<FlyingHeart
startPosition={animationState.startPosition}
targetPosition={animationState.targetPosition}
onAnimationComplete={handleAnimationComplete}
/>
</FavoriteAnimationContext.Provider>
);
}
export function useFavoriteAnimation() {
const context = useContext(FavoriteAnimationContext);
if (!context) {
throw new Error(
"useFavoriteAnimation must be used within FavoriteAnimationProvider",
);
}
return context;
}

View File

@@ -1,55 +1,28 @@
"use client";
import { useEffect, useState, useCallback } from "react";
import { HeartIcon, ListIcon } from "@phosphor-icons/react";
import { useEffect } from "react";
import { FavoritesSection } from "./components/FavoritesSection/FavoritesSection";
import { LibraryActionHeader } from "./components/LibraryActionHeader/LibraryActionHeader";
import { LibraryAgentList } from "./components/LibraryAgentList/LibraryAgentList";
import { Tab } from "./components/LibraryTabs/LibraryTabs";
import { useLibraryListPage } from "./components/useLibraryListPage";
import { FavoriteAnimationProvider } from "./context/FavoriteAnimationContext";
const LIBRARY_TABS: Tab[] = [
{ id: "all", title: "All", icon: ListIcon },
{ id: "favorites", title: "Favorites", icon: HeartIcon },
];
export default function LibraryPage() {
const { searchTerm, setSearchTerm, librarySort, setLibrarySort } =
useLibraryListPage();
const [selectedFolderId, setSelectedFolderId] = useState<string | null>(null);
const [activeTab, setActiveTab] = useState(LIBRARY_TABS[0].id);
useEffect(() => {
document.title = "Library AutoGPT Platform";
}, []);
function handleTabChange(tabId: string) {
setActiveTab(tabId);
setSelectedFolderId(null);
}
const handleFavoriteAnimationComplete = useCallback(() => {
setActiveTab("favorites");
setSelectedFolderId(null);
}, []);
return (
<FavoriteAnimationProvider
onAnimationComplete={handleFavoriteAnimationComplete}
>
<main className="pt-160 container min-h-screen space-y-4 pb-20 pt-16 sm:px-8 md:px-12">
<LibraryActionHeader setSearchTerm={setSearchTerm} />
<LibraryAgentList
searchTerm={searchTerm}
librarySort={librarySort}
setLibrarySort={setLibrarySort}
selectedFolderId={selectedFolderId}
onFolderSelect={setSelectedFolderId}
tabs={LIBRARY_TABS}
activeTab={activeTab}
onTabChange={handleTabChange}
/>
</main>
</FavoriteAnimationProvider>
<main className="pt-160 container min-h-screen space-y-4 pb-20 pt-16 sm:px-8 md:px-12">
<LibraryActionHeader setSearchTerm={setSearchTerm} />
<FavoritesSection searchTerm={searchTerm} />
<LibraryAgentList
searchTerm={searchTerm}
librarySort={librarySort}
setLibrarySort={setLibrarySort}
/>
</main>
);
}

View File

@@ -3658,29 +3658,6 @@
"title": "Page Size"
},
"description": "Number of agents per page (must be >= 1)"
},
{
"name": "folder_id",
"in": "query",
"required": false,
"schema": {
"anyOf": [{ "type": "string" }, { "type": "null" }],
"description": "Filter by folder ID",
"title": "Folder Id"
},
"description": "Filter by folder ID"
},
{
"name": "include_root_only",
"in": "query",
"required": false,
"schema": {
"type": "boolean",
"description": "Only return agents without a folder (root-level agents)",
"default": false,
"title": "Include Root Only"
},
"description": "Only return agents without a folder (root-level agents)"
}
],
"responses": {
@@ -4048,338 +4025,6 @@
}
}
},
"/api/library/folders": {
"get": {
"tags": ["v2", "library", "folders", "private"],
"summary": "List Library Folders",
"description": "List folders for the authenticated user.\n\nArgs:\n user_id: ID of the authenticated user.\n parent_id: Optional parent folder ID to filter by.\n include_relations: Whether to include agent and subfolder relations for counts.\n\nReturns:\n A FolderListResponse containing folders.",
"operationId": "getV2List library folders",
"security": [{ "HTTPBearerJWT": [] }],
"parameters": [
{
"name": "parent_id",
"in": "query",
"required": false,
"schema": {
"anyOf": [{ "type": "string" }, { "type": "null" }],
"description": "Filter by parent folder ID. If not provided, returns root-level folders.",
"title": "Parent Id"
},
"description": "Filter by parent folder ID. If not provided, returns root-level folders."
},
{
"name": "include_relations",
"in": "query",
"required": false,
"schema": {
"type": "boolean",
"description": "Include agent and subfolder relations (for counts)",
"default": true,
"title": "Include Relations"
},
"description": "Include agent and subfolder relations (for counts)"
}
],
"responses": {
"200": {
"description": "List of folders",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/FolderListResponse" }
}
}
},
"401": {
"$ref": "#/components/responses/HTTP401NotAuthenticatedError"
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/HTTPValidationError" }
}
}
},
"500": { "description": "Server error" }
}
},
"post": {
"tags": ["v2", "library", "folders", "private"],
"summary": "Create Folder",
"description": "Create a new folder.\n\nArgs:\n payload: The folder creation request.\n user_id: ID of the authenticated user.\n\nReturns:\n The created LibraryFolder.",
"operationId": "postV2Create folder",
"security": [{ "HTTPBearerJWT": [] }],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/FolderCreateRequest" }
}
}
},
"responses": {
"201": {
"description": "Folder created successfully",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/LibraryFolder" }
}
}
},
"400": { "description": "Validation error" },
"401": {
"$ref": "#/components/responses/HTTP401NotAuthenticatedError"
},
"404": { "description": "Parent folder not found" },
"409": { "description": "Folder name conflict" },
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/HTTPValidationError" }
}
}
},
"500": { "description": "Server error" }
}
}
},
"/api/library/folders/agents/bulk-move": {
"post": {
"tags": ["v2", "library", "folders", "private"],
"summary": "Bulk Move Agents",
"description": "Move multiple agents to a folder.\n\nArgs:\n payload: The bulk move request with agent IDs and target folder.\n user_id: ID of the authenticated user.\n\nReturns:\n The updated LibraryAgents.",
"operationId": "postV2Bulk move agents",
"requestBody": {
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/BulkMoveAgentsRequest" }
}
},
"required": true
},
"responses": {
"200": {
"description": "Agents moved successfully",
"content": {
"application/json": {
"schema": {
"items": { "$ref": "#/components/schemas/LibraryAgent" },
"type": "array",
"title": "Response Postv2Bulk Move Agents"
}
}
}
},
"401": {
"$ref": "#/components/responses/HTTP401NotAuthenticatedError"
},
"404": { "description": "Folder not found" },
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/HTTPValidationError" }
}
}
},
"500": { "description": "Server error" }
},
"security": [{ "HTTPBearerJWT": [] }]
}
},
"/api/library/folders/tree": {
"get": {
"tags": ["v2", "library", "folders", "private"],
"summary": "Get Folder Tree",
"description": "Get the full folder tree for the authenticated user.\n\nArgs:\n user_id: ID of the authenticated user.\n\nReturns:\n A FolderTreeResponse containing the nested folder structure.",
"operationId": "getV2Get folder tree",
"responses": {
"200": {
"description": "Folder tree structure",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/FolderTreeResponse" }
}
}
},
"401": {
"$ref": "#/components/responses/HTTP401NotAuthenticatedError"
},
"500": { "description": "Server error" }
},
"security": [{ "HTTPBearerJWT": [] }]
}
},
"/api/library/folders/{folder_id}": {
"delete": {
"tags": ["v2", "library", "folders", "private"],
"summary": "Delete Folder",
"description": "Soft-delete a folder and all its contents.\n\nArgs:\n folder_id: ID of the folder to delete.\n user_id: ID of the authenticated user.\n\nReturns:\n 204 No Content if successful.",
"operationId": "deleteV2Delete folder",
"security": [{ "HTTPBearerJWT": [] }],
"parameters": [
{
"name": "folder_id",
"in": "path",
"required": true,
"schema": { "type": "string", "title": "Folder Id" }
}
],
"responses": {
"204": { "description": "Folder deleted successfully" },
"401": {
"$ref": "#/components/responses/HTTP401NotAuthenticatedError"
},
"404": { "description": "Folder not found" },
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/HTTPValidationError" }
}
}
},
"500": { "description": "Server error" }
}
},
"get": {
"tags": ["v2", "library", "folders", "private"],
"summary": "Get Folder",
"description": "Get a specific folder.\n\nArgs:\n folder_id: ID of the folder to retrieve.\n user_id: ID of the authenticated user.\n\nReturns:\n The requested LibraryFolder.",
"operationId": "getV2Get folder",
"security": [{ "HTTPBearerJWT": [] }],
"parameters": [
{
"name": "folder_id",
"in": "path",
"required": true,
"schema": { "type": "string", "title": "Folder Id" }
}
],
"responses": {
"200": {
"description": "Folder details",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/LibraryFolder" }
}
}
},
"401": {
"$ref": "#/components/responses/HTTP401NotAuthenticatedError"
},
"404": { "description": "Folder not found" },
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/HTTPValidationError" }
}
}
},
"500": { "description": "Server error" }
}
},
"patch": {
"tags": ["v2", "library", "folders", "private"],
"summary": "Update Folder",
"description": "Update a folder's properties.\n\nArgs:\n folder_id: ID of the folder to update.\n payload: The folder update request.\n user_id: ID of the authenticated user.\n\nReturns:\n The updated LibraryFolder.",
"operationId": "patchV2Update folder",
"security": [{ "HTTPBearerJWT": [] }],
"parameters": [
{
"name": "folder_id",
"in": "path",
"required": true,
"schema": { "type": "string", "title": "Folder Id" }
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/FolderUpdateRequest" }
}
}
},
"responses": {
"200": {
"description": "Folder updated successfully",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/LibraryFolder" }
}
}
},
"400": { "description": "Validation error" },
"401": {
"$ref": "#/components/responses/HTTP401NotAuthenticatedError"
},
"404": { "description": "Folder not found" },
"409": { "description": "Folder name conflict" },
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/HTTPValidationError" }
}
}
},
"500": { "description": "Server error" }
}
}
},
"/api/library/folders/{folder_id}/move": {
"post": {
"tags": ["v2", "library", "folders", "private"],
"summary": "Move Folder",
"description": "Move a folder to a new parent.\n\nArgs:\n folder_id: ID of the folder to move.\n payload: The move request with target parent.\n user_id: ID of the authenticated user.\n\nReturns:\n The moved LibraryFolder.",
"operationId": "postV2Move folder",
"security": [{ "HTTPBearerJWT": [] }],
"parameters": [
{
"name": "folder_id",
"in": "path",
"required": true,
"schema": { "type": "string", "title": "Folder Id" }
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/FolderMoveRequest" }
}
}
},
"responses": {
"200": {
"description": "Folder moved successfully",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/LibraryFolder" }
}
}
},
"400": { "description": "Validation error (circular reference)" },
"401": {
"$ref": "#/components/responses/HTTP401NotAuthenticatedError"
},
"404": { "description": "Folder or target parent not found" },
"409": { "description": "Folder name conflict in target location" },
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/HTTPValidationError" }
}
}
},
"500": { "description": "Server error" }
}
}
},
"/api/library/presets": {
"get": {
"tags": ["v2", "presets"],
@@ -7930,23 +7575,6 @@
"required": ["file"],
"title": "Body_postV2Upload submission media"
},
"BulkMoveAgentsRequest": {
"properties": {
"agent_ids": {
"items": { "type": "string" },
"type": "array",
"title": "Agent Ids"
},
"folder_id": {
"anyOf": [{ "type": "string" }, { "type": "null" }],
"title": "Folder Id"
}
},
"type": "object",
"required": ["agent_ids"],
"title": "BulkMoveAgentsRequest",
"description": "Request model for moving multiple agents to a folder."
},
"CancelTaskResponse": {
"properties": {
"cancelled": { "type": "boolean", "title": "Cancelled" },
@@ -8687,96 +8315,6 @@
"title": "ExecutionStartedResponse",
"description": "Response for run/schedule actions."
},
"FolderCreateRequest": {
"properties": {
"name": {
"type": "string",
"maxLength": 100,
"minLength": 1,
"title": "Name"
},
"icon": {
"anyOf": [{ "type": "string" }, { "type": "null" }],
"title": "Icon"
},
"color": {
"anyOf": [
{ "type": "string", "pattern": "^#[0-9A-Fa-f]{6}$" },
{ "type": "null" }
],
"title": "Color",
"description": "Hex color code (#RRGGBB)"
},
"parent_id": {
"anyOf": [{ "type": "string" }, { "type": "null" }],
"title": "Parent Id"
}
},
"type": "object",
"required": ["name"],
"title": "FolderCreateRequest",
"description": "Request model for creating a folder."
},
"FolderListResponse": {
"properties": {
"folders": {
"items": { "$ref": "#/components/schemas/LibraryFolder" },
"type": "array",
"title": "Folders"
},
"pagination": { "$ref": "#/components/schemas/Pagination" }
},
"type": "object",
"required": ["folders", "pagination"],
"title": "FolderListResponse",
"description": "Response schema for a list of folders."
},
"FolderMoveRequest": {
"properties": {
"target_parent_id": {
"anyOf": [{ "type": "string" }, { "type": "null" }],
"title": "Target Parent Id"
}
},
"type": "object",
"title": "FolderMoveRequest",
"description": "Request model for moving a folder to a new parent."
},
"FolderTreeResponse": {
"properties": {
"tree": {
"items": { "$ref": "#/components/schemas/LibraryFolderTree" },
"type": "array",
"title": "Tree"
}
},
"type": "object",
"required": ["tree"],
"title": "FolderTreeResponse",
"description": "Response schema for folder tree structure."
},
"FolderUpdateRequest": {
"properties": {
"name": {
"anyOf": [
{ "type": "string", "maxLength": 100, "minLength": 1 },
{ "type": "null" }
],
"title": "Name"
},
"icon": {
"anyOf": [{ "type": "string" }, { "type": "null" }],
"title": "Icon"
},
"color": {
"anyOf": [{ "type": "string" }, { "type": "null" }],
"title": "Color"
}
},
"type": "object",
"title": "FolderUpdateRequest",
"description": "Request model for updating a folder."
},
"Graph": {
"properties": {
"id": { "type": "string", "title": "Id" },
@@ -9665,14 +9203,6 @@
"title": "Is Latest Version"
},
"is_favorite": { "type": "boolean", "title": "Is Favorite" },
"folder_id": {
"anyOf": [{ "type": "string" }, { "type": "null" }],
"title": "Folder Id"
},
"folder_name": {
"anyOf": [{ "type": "string" }, { "type": "null" }],
"title": "Folder Name"
},
"recommended_schedule_cron": {
"anyOf": [{ "type": "string" }, { "type": "null" }],
"title": "Recommended Schedule Cron"
@@ -9940,109 +9470,12 @@
{ "type": "null" }
],
"description": "User-specific settings for this library agent"
},
"folder_id": {
"anyOf": [{ "type": "string" }, { "type": "null" }],
"title": "Folder Id",
"description": "Folder ID to move agent to (None to move to root)"
}
},
"type": "object",
"title": "LibraryAgentUpdateRequest",
"description": "Schema for updating a library agent via PUT.\n\nIncludes flags for auto-updating version, marking as favorite,\narchiving, or deleting."
},
"LibraryFolder": {
"properties": {
"id": { "type": "string", "title": "Id" },
"user_id": { "type": "string", "title": "User Id" },
"name": { "type": "string", "title": "Name" },
"icon": {
"anyOf": [{ "type": "string" }, { "type": "null" }],
"title": "Icon"
},
"color": {
"anyOf": [{ "type": "string" }, { "type": "null" }],
"title": "Color"
},
"parent_id": {
"anyOf": [{ "type": "string" }, { "type": "null" }],
"title": "Parent Id"
},
"created_at": {
"type": "string",
"format": "date-time",
"title": "Created At"
},
"updated_at": {
"type": "string",
"format": "date-time",
"title": "Updated At"
},
"agent_count": {
"type": "integer",
"title": "Agent Count",
"default": 0
},
"subfolder_count": {
"type": "integer",
"title": "Subfolder Count",
"default": 0
}
},
"type": "object",
"required": ["id", "user_id", "name", "created_at", "updated_at"],
"title": "LibraryFolder",
"description": "Represents a folder for organizing library agents."
},
"LibraryFolderTree": {
"properties": {
"id": { "type": "string", "title": "Id" },
"user_id": { "type": "string", "title": "User Id" },
"name": { "type": "string", "title": "Name" },
"icon": {
"anyOf": [{ "type": "string" }, { "type": "null" }],
"title": "Icon"
},
"color": {
"anyOf": [{ "type": "string" }, { "type": "null" }],
"title": "Color"
},
"parent_id": {
"anyOf": [{ "type": "string" }, { "type": "null" }],
"title": "Parent Id"
},
"created_at": {
"type": "string",
"format": "date-time",
"title": "Created At"
},
"updated_at": {
"type": "string",
"format": "date-time",
"title": "Updated At"
},
"agent_count": {
"type": "integer",
"title": "Agent Count",
"default": 0
},
"subfolder_count": {
"type": "integer",
"title": "Subfolder Count",
"default": 0
},
"children": {
"items": { "$ref": "#/components/schemas/LibraryFolderTree" },
"type": "array",
"title": "Children",
"default": []
}
},
"type": "object",
"required": ["id", "user_id", "name", "created_at", "updated_at"],
"title": "LibraryFolderTree",
"description": "Folder with nested children for tree view."
},
"Link": {
"properties": {
"id": { "type": "string", "title": "Id" },

View File

@@ -1,15 +1,12 @@
"use client";
import { redirect } from "next/navigation";
import { LoadingSpinner } from "@/components/atoms/LoadingSpinner/LoadingSpinner";
import { useRouter } from "next/navigation";
import { useEffect } from "react";
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "AutoGPT Platform",
description: "AutoGPT Platform",
};
export default function Page() {
const router = useRouter();
useEffect(() => {
router.replace("/copilot");
}, [router]);
return <LoadingSpinner size="large" cover />;
redirect("/copilot");
}

View File

@@ -1,6 +1,7 @@
"use client";
import * as React from "react";
import Image from "next/image";
const getYouTubeVideoId = (url: string) => {
const regExp =
@@ -76,6 +77,7 @@ const VideoRenderer: React.FC<{ videoUrl: string }> = ({ videoUrl }) => {
width="100%"
height="315"
src={`https://www.youtube.com/embed/${videoId}`}
title="Embedded content"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
></iframe>
@@ -92,15 +94,15 @@ const VideoRenderer: React.FC<{ videoUrl: string }> = ({ videoUrl }) => {
const ImageRenderer: React.FC<{ imageUrl: string }> = ({ imageUrl }) => {
return (
<div className="w-full p-2">
<picture>
<img
src={imageUrl}
alt="Image"
className="h-auto max-w-full"
width="100%"
height="auto"
/>
</picture>
<Image
src={imageUrl}
alt="Image"
width={0}
height={0}
sizes="100vw"
className="h-auto w-full"
unoptimized
/>
</div>
);
};

View File

@@ -93,7 +93,7 @@ export function APIKeyCredentialsModal({
<FormDescription>
Required scope(s) for this block:{" "}
{schema.credentials_scopes?.map((s, i, a) => (
<span key={i}>
<span key={s}>
<code className="text-xs font-bold">{s}</code>
{i < a.length - 1 && ", "}
</span>

View File

@@ -1,4 +1,4 @@
import { useEffect, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { z } from "zod";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
@@ -66,16 +66,18 @@ export function HostScopedCredentialsModal({
});
const [headerPairs, setHeaderPairs] = useState<
Array<{ key: string; value: string }>
>([{ key: "", value: "" }]);
Array<{ id: string; key: string; value: string }>
>([{ id: crypto.randomUUID(), key: "", value: "" }]);
// Update form values when siblingInputs change
const prevHostRef = useRef(currentHost);
useEffect(() => {
if (currentHost === prevHostRef.current) return;
prevHostRef.current = currentHost;
if (currentHost) {
form.setValue("host", currentHost);
form.setValue("title", currentHost);
} else {
// Reset to empty when no current host
form.setValue("host", "");
form.setValue("title", "Manual Entry");
}
@@ -91,9 +93,12 @@ export function HostScopedCredentialsModal({
const { provider, providerName, createHostScopedCredentials } = credentials;
const addHeaderPair = () => {
setHeaderPairs([...headerPairs, { key: "", value: "" }]);
};
function addHeaderPair() {
setHeaderPairs((prev) => [
...prev,
{ id: crypto.randomUUID(), key: "", value: "" },
]);
}
const removeHeaderPair = (index: number) => {
if (headerPairs.length > 1) {
@@ -192,7 +197,7 @@ export function HostScopedCredentialsModal({
</FormDescription>
{headerPairs.map((pair, index) => (
<div key={index} className="flex w-full items-center gap-4">
<div key={pair.id} className="flex w-full items-center gap-4">
<Input
id={`header-${index}-key`}
label="Header Name"

View File

@@ -1,4 +1,4 @@
import { useEffect, useState } from "react";
import { useRef, useState } from "react";
import { Input } from "@/components/__legacy__/ui/input";
import { Button } from "@/components/__legacy__/ui/button";
import { useToast } from "@/components/molecules/Toast/use-toast";
@@ -7,6 +7,7 @@ import { Dialog } from "@/components/molecules/Dialog/Dialog";
import { getTimezoneDisplayName } from "@/lib/timezone-utils";
import { useUserTimezone } from "@/lib/hooks/useUserTimezone";
import { InfoIcon } from "lucide-react";
import Link from "next/link";
// Base type for cron expression only
type CronOnlyCallback = (cronExpression: string) => void;
@@ -53,15 +54,15 @@ export function CronSchedulerDialog(props: CronSchedulerDialogProps) {
const userTimezone = useUserTimezone();
const timezoneDisplay = getTimezoneDisplayName(userTimezone || "UTC");
// Reset state when dialog opens
useEffect(() => {
if (open) {
const defaultName =
props.mode === "with-name" ? props.defaultScheduleName || "" : "";
setScheduleName(defaultName);
setCronExpression(defaultCronExpression);
}
}, [open, props, defaultCronExpression]);
// Reset state when dialog opens (render-time sync instead of useEffect)
const prevOpenRef = useRef(open);
if (open && !prevOpenRef.current) {
const defaultName =
props.mode === "with-name" ? props.defaultScheduleName || "" : "";
setScheduleName(defaultName);
setCronExpression(defaultCronExpression);
}
prevOpenRef.current = open;
const handleDone = () => {
if (props.mode === "with-name" && !scheduleName.trim()) {
@@ -100,8 +101,11 @@ export function CronSchedulerDialog(props: CronSchedulerDialogProps) {
<div className="flex flex-col gap-4">
{props.mode === "with-name" && (
<div className="flex max-w-[448px] flex-col space-y-2">
<label className="text-sm font-medium">Schedule Name</label>
<label htmlFor="schedule-name" className="text-sm font-medium">
Schedule Name
</label>
<Input
id="schedule-name"
value={scheduleName}
onChange={(e) => setScheduleName(e.target.value)}
placeholder="Enter a name for this schedule"
@@ -121,9 +125,9 @@ export function CronSchedulerDialog(props: CronSchedulerDialogProps) {
<InfoIcon className="h-4 w-4 text-amber-600" />
<p className="text-sm text-amber-800">
No timezone set. Schedule will run in UTC.
<a href="/profile/settings" className="ml-1 underline">
<Link href="/profile/settings" className="ml-1 underline">
Set your timezone
</a>
</Link>
</p>
</div>
) : (

View File

@@ -452,7 +452,7 @@ export function CronScheduler({
const monthNumber = i + 1;
return (
<Button
key={i}
key={month.label}
variant={
selectedMonths.includes(monthNumber) ? "default" : "outline"
}

View File

@@ -1,6 +1,7 @@
"use client";
import React from "react";
import Image from "next/image";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import remarkMath from "remark-math";
@@ -359,20 +360,23 @@ function renderMarkdown(
</del>
),
// Image handling
img: ({ src, alt, ...props }) => {
img: ({ src, alt }) => {
// Check if it's a video URL pattern
if (src && isVideoUrl(src)) {
return renderVideoEmbed(src);
}
if (!src) return null;
return (
// eslint-disable-next-line @next/next/no-img-element
<img
<Image
src={src}
alt={alt}
className="my-4 h-auto max-w-full rounded-lg shadow-md"
loading="lazy"
{...props}
alt={alt || "Image"}
width={0}
height={0}
sizes="100vw"
className="my-4 h-auto w-full rounded-lg shadow-md"
unoptimized
/>
);
},

View File

@@ -89,7 +89,6 @@ export function ActivityDropdown({
className="!focus:border-1 w-full pr-10"
wrapperClassName="!mb-0"
autoComplete="off"
autoFocus
/>
<button
onClick={handleClearSearch}

View File

@@ -444,10 +444,7 @@ export async function navigateToAgentByName(
// Wait for the agent card to be visible before clicking
// This handles async loading of agents after page navigation
await agentCard.waitFor({ state: "visible", timeout: 15000 });
// Click the link inside the card to navigate reliably through
// the motion.div + draggable wrapper layers.
const link = agentCard.locator('a[href*="/library/agents/"]').first();
await link.click();
await agentCard.click();
}
export async function clickRunButton(page: Page): Promise<void> {
@@ -484,17 +481,19 @@ export async function clickRunButton(page: Page): Promise<void> {
// Check which button is visible and click it
if (await setupTaskButton.isVisible()) {
await setupTaskButton.click();
const startBtn = page.getByRole("button", { name: /Start Task/i }).first();
await startBtn.waitFor({ state: "visible", timeout: 15000 });
await startBtn.click();
await page
.getByRole("button", { name: /Start Task/i })
.first()
.click({ timeout: 10000 });
return;
}
if (await newTaskButton.isVisible()) {
await newTaskButton.click();
const startBtn = page.getByRole("button", { name: /Start Task/i }).first();
await startBtn.waitFor({ state: "visible", timeout: 15000 });
await startBtn.click();
await page
.getByRole("button", { name: /Start Task/i })
.first()
.click({ timeout: 10000 });
return;
}

View File

@@ -218,17 +218,6 @@ If you initially installed Docker with Hyper-V, you **dont need to reinstall*
For more details, refer to [Docker's official documentation](https://docs.docker.com/desktop/windows/wsl/).
### ⚠️ Podman Not Supported
AutoGPT requires **Docker** (Docker Desktop or Docker Engine). **Podman and podman-compose are not supported** and may cause path resolution issues, particularly on Windows.
If you see errors like:
```text
Error: the specified Containerfile or Dockerfile does not exist, ..\..\autogpt_platform\backend\Dockerfile
```
This indicates you're using Podman instead of Docker. Please install [Docker Desktop](https://docs.docker.com/desktop/) and use `docker compose` instead of `podman-compose`.
## Development