Compare commits

..

6 Commits

Author SHA1 Message Date
Zamil Majdy
39e4be16d5 Merge branch 'master' into feat/claude-sdk-block 2026-01-06 06:24:23 +07:00
Ubbe
4a7bc006a8 hotfix(frontend): chat should be disabled by default (#11639)
### Changes 🏗️

Chat should be disabled by default; otherwise, it flashes, and if Launch
Darkly fails to fail, it is dangerous.

### Checklist 📋

#### For code changes:
- [x] I have clearly listed my changes in the PR description
- [x] I have made a test plan
- [x] I have tested my changes according to the test plan:
  - [x] Run locally with Launch Darkly disabled and test the above
2025-12-18 19:04:13 +01:00
Zamil Majdy
60b3465724 feat(blocks): add SmartAgentBlock using Claude Agent SDK
Implements a new AI agent block that bridges Claude Agent SDK with AutoGPT's tool ecosystem:

- Uses Claude Agent SDK for native agent capabilities
- Discovers and executes connected AutoGPT tool nodes
- Supports iterative agent mode with conversation management
- Proper credential validation for Anthropic API keys
- Configurable models (Claude 4.x and 3.x series)
- Zero-warning block registration and validation

Block features:
- Agent mode execution with tool discovery
- Integration with AutoGPT's execution manager
- Robust async error handling
- Support for max iterations and working directory configuration

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-16 13:50:58 +01:00
Ubbe
e8d37ab116 feat(frontend): add nice scrollable tabs on Selected Run view (#11596)
## Changes 🏗️


https://github.com/user-attachments/assets/7e49ed5b-c818-4aa3-b5d6-4fa86fada7ee

When the content of Summary + Outputs + Inputs is long enough, it will
show in this new `<ScrollableTabs />` component, which auto-scrolls the
content as you click on a tab.

## Checklist 📋

### For code changes

- [x] I have clearly listed my changes in the PR description
- [x] I have made a test plan
- [x] I have tested my changes according to the test plan:
  - [x] Run the app locally
  - [x] Check the new page with scrollable tabs
2025-12-15 16:57:36 +07:00
Ubbe
7f7ef6a271 feat(frontend): imporve agent inputs read-only (#11621)
## Changes 🏗️

The main goal of this PR is to improve how we display inputs used for a
given task.

Agent inputs can be of many types (text, long text, date, select, file,
etc.). Until now, we have tried to display them as text, which has not
always worked. Given we already have `<RunAgentInputs />`, which uses
form elements to display the inputs ( _prefilled with data_ ), most of
the time it will look better and less buggy than text.

### Before

<img width="800" height="614" alt="Screenshot 2025-12-14 at 17 45 44"
src="https://github.com/user-attachments/assets/3d851adf-9638-46c1-adfa-b5e68dc78bb0"
/>

### After

<img width="800" height="708" alt="Screenshot 2025-12-14 at 17 45 21"
src="https://github.com/user-attachments/assets/367f32b4-2c30-4368-8d63-4cad06e32437"
/>

### Other improvements

- 🗑️  Removed `<EditInputsModal />`
- it is not used given the API does not support editing inputs for a
schedule yt
- Made `<InformationTooltip />` icon size customisable    

### Checklist 📋

#### For code changes:
- [x] I have clearly listed my changes in the PR description
- [x] I have made a test plan
- [x] I have tested my changes according to the test plan:
  - [x] Run the app locally
- [x] Check the new view tasks use the form elements instead of text to
display inputs
2025-12-15 00:11:27 +07:00
Ubbe
aefac541d9 fix(frontend): force light mode for now (#11619)
## Changes 🏗️

We have the setup for light/dark mode support ( Tailwind + `next-themes`
), but not the capacity yet from contributions to make the app dark-mode
ready. First, we need to make it look good in light mode 😆

This disables `dark:` mode classes on the code, to prevent the app
looking oopsie when the user is seeing it with a browser with dark mode
preference:

### Before these changes

<img width="800" height="739" alt="Screenshot 2025-12-14 at 17 09 25"
src="https://github.com/user-attachments/assets/76333e03-930a-40b6-b91e-47ee01bf2c00"
/>

### After

<img width="800" height="722" alt="Screenshot 2025-12-14 at 16 55 46"
src="https://github.com/user-attachments/assets/34d85359-c68f-474c-8c66-2bebf28f923e"
/>

## Checklist 📋

### For code changes

- [x] I have clearly listed my changes in the PR description
- [x] I have made a test plan
- [x] I have tested my changes according to the test plan:
  - [x] Run the app on a browser with dark mode preference
  - [x] It still looks in light mode without broken styles
2025-12-15 00:10:36 +07:00
29 changed files with 9767 additions and 523 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,858 @@
import logging
import os
import re
from enum import Enum
from typing import TYPE_CHECKING, Any, Literal, NamedTuple
from claude_agent_sdk import query
from claude_agent_sdk.types import (
AssistantMessage,
ClaudeAgentOptions,
TextBlock,
ToolUseBlock,
)
from pydantic import BaseModel, SecretStr
# Avoid circular imports by importing only essential types
# ExecutionParams, ToolInfo, and other classes will be imported dynamically when needed
from backend.data.block import (
Block,
BlockCategory,
BlockOutput,
BlockSchemaInput,
BlockSchemaOutput,
BlockType,
)
from backend.data.dynamic_fields import is_tool_pin
from backend.data.execution import ExecutionContext
from backend.data.model import (
APIKeyCredentials,
CredentialsField,
CredentialsMetaInput,
SchemaField,
)
from backend.integrations.providers import ProviderName
from backend.util import json
from backend.util.clients import get_database_manager_async_client
if TYPE_CHECKING:
from backend.data.graph import Link, Node
from backend.executor.manager import ExecutionProcessor
logger = logging.getLogger(__name__)
class ToolInfo(BaseModel):
"""Processed tool call information."""
tool_call: Any # The original tool call object from LLM response
tool_name: str # The function name
tool_def: dict[str, Any] # The tool definition from tool_functions
input_data: dict[str, Any] # Processed input data ready for tool execution
field_mapping: dict[str, str] # Field name mapping for the tool
class ExecutionParams(BaseModel):
"""Tool execution parameters."""
user_id: str
graph_id: str
node_id: str
graph_version: int
graph_exec_id: str
node_exec_id: str
execution_context: "ExecutionContext"
def _create_tool_response(call_id: str, content: str) -> dict[str, Any]:
"""Create a tool response in the correct format."""
# Anthropics format: tool IDs typically start with "toolu_"
if call_id.startswith("toolu_"):
return {
"role": "user",
"type": "message",
"content": [
{"tool_use_id": call_id, "type": "tool_result", "content": content}
],
}
# OpenAI format: tool IDs typically start with "call_".
# Or default fallback (if the tool_id doesn't match any known prefix)
return {"role": "tool", "tool_call_id": call_id, "content": content}
class ModelMetadata(NamedTuple):
provider: str
context_window: int
max_output_tokens: int | None
class AgentModel(str, Enum):
"""Available models for the Smart Agent."""
# Claude 4.x models (latest)
CLAUDE_4_1_OPUS = "claude-opus-4-1-20250805"
CLAUDE_4_OPUS = "claude-opus-4-20250514"
CLAUDE_4_SONNET = "claude-sonnet-4-20250514"
CLAUDE_4_5_OPUS = "claude-opus-4-5-20251101"
CLAUDE_4_5_SONNET = "claude-sonnet-4-5-20250929"
CLAUDE_4_5_HAIKU = "claude-haiku-4-5-20251001"
# Claude 3.x models (stable)
CLAUDE_3_7_SONNET = "claude-3-7-sonnet-20250219"
CLAUDE_3_HAIKU = "claude-3-haiku-20240307"
@property
def metadata(self) -> ModelMetadata:
return AGENT_MODEL_METADATA[self]
@property
def provider(self) -> str:
return self.metadata.provider
# Agent model metadata mapping
AGENT_MODEL_METADATA = {
# Claude 4.x models
AgentModel.CLAUDE_4_1_OPUS: ModelMetadata(ProviderName.ANTHROPIC, 500000, 4096),
AgentModel.CLAUDE_4_OPUS: ModelMetadata(ProviderName.ANTHROPIC, 500000, 4096),
AgentModel.CLAUDE_4_SONNET: ModelMetadata(ProviderName.ANTHROPIC, 500000, 4096),
AgentModel.CLAUDE_4_5_OPUS: ModelMetadata(ProviderName.ANTHROPIC, 500000, 8192),
AgentModel.CLAUDE_4_5_SONNET: ModelMetadata(ProviderName.ANTHROPIC, 500000, 8192),
AgentModel.CLAUDE_4_5_HAIKU: ModelMetadata(ProviderName.ANTHROPIC, 200000, 4096),
# Claude 3.x models
AgentModel.CLAUDE_3_7_SONNET: ModelMetadata(ProviderName.ANTHROPIC, 200000, 4096),
AgentModel.CLAUDE_3_HAIKU: ModelMetadata(ProviderName.ANTHROPIC, 200000, 4096),
}
# Anthropic-only credentials for Claude models
ClaudeCredentials = CredentialsMetaInput[
Literal[ProviderName.ANTHROPIC], Literal["api_key"]
]
def ClaudeCredentialsField() -> ClaudeCredentials:
return CredentialsField(
description="Anthropic API key for Claude Agent SDK access.",
discriminator="model",
discriminator_mapping={
model.value: model.metadata.provider for model in AgentModel
},
)
# Test credentials for Claude models
TEST_CLAUDE_CREDENTIALS = APIKeyCredentials(
id="test-claude-creds",
provider=ProviderName.ANTHROPIC,
api_key=SecretStr("mock-anthropic-api-key"),
title="Mock Anthropic API key",
expires_at=None,
)
TEST_CLAUDE_CREDENTIALS_INPUT = {
"provider": TEST_CLAUDE_CREDENTIALS.provider,
"id": TEST_CLAUDE_CREDENTIALS.id,
"type": TEST_CLAUDE_CREDENTIALS.type,
"title": TEST_CLAUDE_CREDENTIALS.title,
}
class SmartAgentBlock(Block):
"""
A smart agent block that uses Claude Agent SDK for native agent capabilities
while executing AutoGPT tool nodes.
This block combines Claude's native agent functionality with AutoGPT's tool ecosystem:
- Uses Claude Agent SDK for core agent intelligence
- Discovers connected AutoGPT tool nodes like SmartDecisionMaker
- When Claude calls tools, executes the actual AutoGPT tool nodes
- Provides Claude with the tool execution results
"""
class Input(BlockSchemaInput):
task: str = SchemaField(
description="The task for the agent to complete. Be specific about your requirements.",
placeholder="Analyze the data file and create a summary report with key insights...",
)
model: AgentModel = SchemaField(
title="Model",
default=AgentModel.CLAUDE_4_5_SONNET,
description="The model to use for the agent.",
advanced=False,
)
credentials: ClaudeCredentials = ClaudeCredentialsField()
max_iterations: int = SchemaField(
default=15,
description="Maximum number of agent iterations. Use -1 for unlimited (use carefully!).",
advanced=False,
)
system_prompt: str = SchemaField(
title="System Prompt",
default="You are a helpful AI assistant with access to tools. Think step by step about which tools to use to complete the task efficiently. When you have completed the objective, provide a clear summary of the results.",
description="System prompt to guide the agent's behavior.",
advanced=True,
)
working_directory: str = SchemaField(
default="/tmp/smart_agent",
description="Working directory for the agent.",
advanced=True,
)
class Output(BlockSchemaOutput):
result: str = SchemaField(
description="The final result or answer from the agent."
)
iterations_used: int = SchemaField(
description="Number of iterations used to complete the task."
)
tools_used: list[str] = SchemaField(
description="List of AutoGPT tools used during execution.",
default_factory=list,
)
success: bool = SchemaField(
description="Whether the task was completed successfully."
)
error: str = SchemaField(
default="", description="Error message if the task failed."
)
# Tool output pins for connecting to other blocks (like SmartDecisionMakerBlock)
tools: Any = SchemaField(
description="Tool calls output for connecting to other AutoGPT blocks."
)
conversations: list[Any] = SchemaField(
description="Conversation history with Claude Agent SDK.",
default_factory=list,
)
def __init__(self):
super().__init__(
id="c1a2u3d4-e5a6-g7e8-n9t0-b1l2o3c4k5d6",
description=(
"An AI agent powered by Claude Agent SDK that executes connected AutoGPT tool nodes. "
"Combines Claude's native agent capabilities with AutoGPT's tool ecosystem."
),
categories={BlockCategory.AI},
block_type=BlockType.AI,
input_schema=SmartAgentBlock.Input,
output_schema=SmartAgentBlock.Output,
test_input={
"task": "What tools are available?",
"credentials": TEST_CLAUDE_CREDENTIALS_INPUT,
"model": AgentModel.CLAUDE_4_5_SONNET,
},
test_output=[],
test_credentials=TEST_CLAUDE_CREDENTIALS,
)
@staticmethod
def cleanup(s: str):
"""Clean up block names for use as tool function names."""
return re.sub(r"[^a-zA-Z0-9_-]", "_", s).lower()
async def _create_tool_node_signatures(
self,
node_id: str,
) -> list[dict[str, Any]]:
"""
Creates function signatures for connected tools.
Args:
node_id: The node_id for which to create function signatures.
Returns:
List of function signatures for tools
"""
from backend.blocks.agent import AgentExecutorBlock
db_client = get_database_manager_async_client()
tools = [
(link, node)
for link, node in await db_client.get_connected_output_nodes(node_id)
if is_tool_pin(link.source_name) and link.source_id == node_id
]
if not tools:
raise ValueError("There is no next node to execute.")
return_tool_functions: list[dict[str, Any]] = []
grouped_tool_links: dict[str, tuple["Node", list["Link"]]] = {}
for link, node in tools:
if link.sink_id not in grouped_tool_links:
grouped_tool_links[link.sink_id] = (node, [link])
else:
grouped_tool_links[link.sink_id][1].append(link)
for sink_node, links in grouped_tool_links.values():
if not sink_node:
raise ValueError(f"Sink node not found: {links[0].sink_id}")
if sink_node.block_id == AgentExecutorBlock().id:
# Dynamic import to avoid circular dependency
from backend.blocks.smart_decision_maker import SmartDecisionMakerBlock
tool_func = (
await SmartDecisionMakerBlock._create_agent_function_signature(
sink_node, links
)
)
return_tool_functions.append(tool_func)
else:
# Dynamic import to avoid circular dependency
from backend.blocks.smart_decision_maker import SmartDecisionMakerBlock
tool_func = (
await SmartDecisionMakerBlock._create_block_function_signature(
sink_node, links
)
)
return_tool_functions.append(tool_func)
return return_tool_functions
async def _execute_single_tool_with_manager(
self,
tool_info: ToolInfo,
execution_params: ExecutionParams,
execution_processor: "ExecutionProcessor",
) -> dict:
"""Execute a single tool using the execution manager for proper integration."""
# Lazy imports to avoid circular dependencies
from concurrent.futures import Future
from backend.data.execution import NodeExecutionEntry
tool_call = tool_info.tool_call
tool_def = tool_info.tool_def
raw_input_data = tool_info.input_data
# Get sink node and field mapping
sink_node_id = tool_def["function"]["_sink_node_id"]
# Use proper database operations for tool execution
db_client = get_database_manager_async_client()
# Get target node
target_node = await db_client.get_node(sink_node_id)
if not target_node:
raise ValueError(f"Target node {sink_node_id} not found")
# Create proper node execution using upsert_execution_input
node_exec_result = None
final_input_data = None
# Add all inputs to the execution
if not raw_input_data:
raise ValueError(f"Tool call has no input data: {tool_call}")
for input_name, input_value in raw_input_data.items():
node_exec_result, final_input_data = await db_client.upsert_execution_input(
node_id=sink_node_id,
graph_exec_id=execution_params.graph_exec_id,
input_name=input_name,
input_data=input_value,
)
assert node_exec_result is not None, "node_exec_result should not be None"
# Create NodeExecutionEntry for execution manager
node_exec_entry = NodeExecutionEntry(
user_id=execution_params.user_id,
graph_exec_id=execution_params.graph_exec_id,
graph_id=execution_params.graph_id,
graph_version=execution_params.graph_version,
node_exec_id=node_exec_result.node_exec_id,
node_id=sink_node_id,
block_id=target_node.block_id,
inputs=final_input_data or {},
execution_context=execution_params.execution_context,
)
# Use the execution manager to execute the tool node
try:
# Get NodeExecutionProgress from the execution manager's running nodes
node_exec_progress = execution_processor.running_node_execution[
sink_node_id
]
# Use the execution manager's own graph stats
graph_stats_pair = (
execution_processor.execution_stats,
execution_processor.execution_stats_lock,
)
# Create a completed future for the task tracking system
node_exec_future = Future()
node_exec_progress.add_task(
node_exec_id=node_exec_result.node_exec_id,
task=node_exec_future,
)
# Execute the node directly since we're in the SmartAgent context
node_exec_future.set_result(
await execution_processor.on_node_execution(
node_exec=node_exec_entry,
node_exec_progress=node_exec_progress,
nodes_input_masks=None,
graph_stats_pair=graph_stats_pair,
)
)
# Get outputs from database after execution completes using database manager client
node_outputs = await db_client.get_execution_outputs_by_node_exec_id(
node_exec_result.node_exec_id
)
# Create tool response
tool_response_content = (
json.dumps(node_outputs)
if node_outputs
else "Tool executed successfully"
)
return _create_tool_response(tool_call.id, tool_response_content)
except Exception as e:
logger.error(f"Tool execution with manager failed: {e}")
# Return error response
return _create_tool_response(
tool_call.id, f"Tool execution failed: {str(e)}"
)
def _setup_environment(
self, credentials: APIKeyCredentials, working_dir: str
) -> dict[str, str]:
"""Setup environment for Claude Agent SDK."""
os.makedirs(working_dir, exist_ok=True)
env = os.environ.copy()
env["ANTHROPIC_API_KEY"] = credentials.api_key.get_secret_value()
return env
def _build_tool_descriptions_for_claude(
self, tool_functions: list[dict[str, Any]]
) -> str:
"""Build description of available AutoGPT tools for Claude."""
if not tool_functions:
return "No tools are currently connected to this agent."
tool_descriptions = ["Available AutoGPT tools:"]
for tool_def in tool_functions:
func_def = tool_def.get("function", {})
name = func_def.get("name", "unknown")
description = func_def.get("description", "No description")
tool_descriptions.append(f"- {name}: {description}")
tool_descriptions.append(
"\nWhen you need to use a tool, call it with function calling syntax."
)
return "\n".join(tool_descriptions)
def _extract_tool_calls_from_claude_message(
self, message: AssistantMessage
) -> list[dict[str, Any]]:
"""Extract tool calls from Claude Agent SDK message."""
tool_calls = []
for content_block in message.content:
if isinstance(content_block, ToolUseBlock):
tool_calls.append(
{
"id": content_block.id,
"function": {
"name": content_block.name,
"arguments": content_block.input,
},
}
)
return tool_calls
def _extract_text_content_from_claude_message(
self, message: AssistantMessage
) -> str:
"""Extract text content from Claude Agent SDK message."""
text_parts = []
for content_block in message.content:
if isinstance(content_block, TextBlock):
text_parts.append(content_block.text)
return "".join(text_parts)
def _format_conversation_for_claude(self, conversation: list[dict]) -> str:
"""Format conversation history for Claude Agent SDK."""
formatted = []
for msg in conversation:
role = msg.get("role", "user")
content = msg.get("content", "")
if role == "tool":
# Format tool response
tool_id = msg.get("tool_call_id", "unknown")
formatted.append(f"Tool result ({tool_id}): {content}")
else:
# Simple format for user/assistant messages
formatted.append(f"{role.title()}: {content}")
return "\n\n".join(formatted)
def _normalize_tool_args(self, tool_args: Any) -> dict:
"""Normalize tool arguments to dict format."""
if isinstance(tool_args, str):
return json.loads(tool_args)
elif isinstance(tool_args, dict):
return tool_args
else:
return dict(tool_args) if tool_args else {}
def _create_tool_info_from_claude_call(
self, tool_call: dict[str, Any], tool_functions: list[dict[str, Any]]
) -> ToolInfo:
"""Convert Claude tool call to AutoGPT ToolInfo format."""
tool_name = tool_call["function"]["name"]
tool_args = self._normalize_tool_args(tool_call["function"]["arguments"])
tool_id = tool_call["id"]
# Find the AutoGPT tool definition
tool_def = next(
(
tf
for tf in tool_functions
if tf.get("function", {}).get("name") == tool_name
),
None,
)
if not tool_def:
raise ValueError(f"AutoGPT tool '{tool_name}' not found")
# Create mock tool call object for AutoGPT compatibility
class MockToolCall:
def __init__(self, tool_id: str, name: str, args: dict):
self.id = tool_id
self.function = type(
"Function", (), {"name": name, "arguments": json.dumps(args)}
)()
# Build input data from arguments
field_mapping = tool_def["function"].get("_field_mapping", {})
expected_args = tool_def["function"]["parameters"].get("properties", {})
input_data = {
field_mapping.get(clean_arg_name, clean_arg_name): tool_args.get(
clean_arg_name
)
for clean_arg_name in expected_args
}
return ToolInfo(
tool_call=MockToolCall(tool_id, tool_name, tool_args),
tool_name=tool_name,
tool_def=tool_def,
input_data=input_data,
field_mapping=field_mapping,
)
async def _attempt_claude_call_with_validation(
self,
prompt: str,
options: ClaudeAgentOptions,
) -> AssistantMessage:
"""Claude SDK call - let generator cleanup happen naturally to avoid cancel scope issues."""
try:
# Simple approach: don't try to manually manage the generator lifecycle
async for message in query(prompt=prompt, options=options):
if isinstance(message, AssistantMessage):
return message
raise ValueError("No AssistantMessage received from Claude SDK")
except Exception as e:
logger.error(f"Claude SDK call failed: {e}")
raise
# Note: No finally block - let the generator be cleaned up naturally by garbage collection
async def _execute_tools_agent_mode(
self,
input_data: Input,
credentials,
tool_functions: list[dict[str, Any]],
graph_exec_id: str,
node_id: str,
node_exec_id: str,
user_id: str,
graph_id: str,
graph_version: int,
execution_context: ExecutionContext,
execution_processor: "ExecutionProcessor",
):
"""Execute tools in agent mode with a loop until finished, following SmartDecisionMakerBlock pattern."""
max_iterations = input_data.max_iterations
iteration = 0
# Execution parameters for tool execution
execution_params = ExecutionParams(
user_id=user_id,
graph_id=graph_id,
node_id=node_id,
graph_version=graph_version,
graph_exec_id=graph_exec_id,
node_exec_id=node_exec_id,
execution_context=execution_context,
)
# Build enhanced task prompt with tool descriptions
tool_descriptions = self._build_tool_descriptions_for_claude(tool_functions)
enhanced_task = f"""{input_data.task}
{tool_descriptions}
Complete the task step by step using the available tools as needed."""
# Start conversation with enhanced task
current_conversation = [{"role": "user", "content": enhanced_task}]
while max_iterations < 0 or iteration < max_iterations:
iteration += 1
logger.debug(f"Claude agent mode iteration {iteration}")
# Prepare conversation for this iteration
iteration_conversation = list(current_conversation)
# On the last iteration, add encouragement to finish
if max_iterations > 0 and iteration == max_iterations:
last_iteration_message = {
"role": "system",
"content": f"This is your last iteration ({iteration}/{max_iterations}). "
"Try to complete the task with the information you have. "
"Prefer finishing with a clear response rather than making additional tool calls.",
}
iteration_conversation.append(last_iteration_message)
# Format conversation for Claude SDK
conversation_text = self._format_conversation_for_claude(
iteration_conversation
)
# Setup Claude options for this iteration
claude_options = ClaudeAgentOptions(
system_prompt=input_data.system_prompt,
model=input_data.model.value,
max_turns=1, # Single turn per iteration
cwd=input_data.working_directory,
env=self._setup_environment(credentials, input_data.working_directory),
permission_mode="bypassPermissions",
)
# Get Claude response
logger.debug(f"Claude agent iteration {iteration}: Making Claude SDK call")
try:
claude_response = await self._attempt_claude_call_with_validation(
conversation_text, claude_options
)
logger.debug(f"Claude agent iteration {iteration}: Received response")
except Exception as e:
logger.error(
f"Claude agent iteration {iteration}: Call failed with {type(e).__name__}: {str(e)}"
)
yield (
"error",
f"Claude call failed in agent mode iteration {iteration}: {str(e)}",
)
return
# Process tool calls
tool_calls = self._extract_tool_calls_from_claude_message(claude_response)
text_content = self._extract_text_content_from_claude_message(
claude_response
)
# Add Claude's response to conversation
assistant_message = {
"role": "assistant",
"content": text_content,
"tool_calls": tool_calls if tool_calls else [],
}
current_conversation.append(assistant_message)
# If no tool calls, we're done
if not tool_calls:
yield "finished", text_content
yield "conversations", current_conversation
return
# Execute tools and collect responses
tool_outputs = []
for tool_call in tool_calls:
# Convert tool call to ToolInfo format for AutoGPT execution
tool_info = self._create_tool_info_from_claude_call(
tool_call, tool_functions
)
try:
# Execute via AutoGPT's execution manager
tool_response = await self._execute_single_tool_with_manager(
tool_info, execution_params, execution_processor
)
tool_outputs.append(tool_response)
except Exception as e:
logger.error(f"Tool execution failed: {e}")
error_response = _create_tool_response(
tool_call["id"], f"Error: {str(e)}"
)
tool_outputs.append(error_response)
# Add tool results to conversation
current_conversation.extend(tool_outputs)
# Yield intermediate conversation state
yield "conversations", current_conversation
# If we reach max iterations, yield the current state
if max_iterations < 0:
yield "finished", f"Agent mode completed after {iteration} iterations"
else:
yield (
"finished",
f"Agent mode completed after {max_iterations} iterations (limit reached)",
)
yield "conversations", current_conversation
async def _execute_single_call_mode(
self,
input_data: Input, # Used for configuration and consistency with agent mode
tool_functions: list[dict[str, Any]],
enhanced_task: str,
claude_options: ClaudeAgentOptions | None,
):
"""Execute single call mode and yield tool outputs for external execution."""
# Create Claude options for single call if not provided
if claude_options is None:
claude_options = ClaudeAgentOptions(
system_prompt=input_data.system_prompt,
model=input_data.model.value,
max_turns=1, # Single call mode
cwd=input_data.working_directory,
permission_mode="bypassPermissions",
)
else:
# Override max_turns to 1 for single call
claude_options.max_turns = 1
try:
claude_response = await self._attempt_claude_call_with_validation(
enhanced_task, claude_options
)
except Exception as e:
yield "error", f"Claude SDK error: {str(e)}"
yield "success", False
return
if claude_response:
text_content = self._extract_text_content_from_claude_message(
claude_response
)
tool_calls = self._extract_tool_calls_from_claude_message(claude_response)
if not tool_calls:
# No tool calls - just return the result
yield "result", text_content
yield "success", True
yield "tools", [] # No tools used
return
# Process and yield tool calls for external execution
for tool_call in tool_calls:
tool_name = tool_call["function"]["name"]
tool_args = self._normalize_tool_args(
tool_call["function"]["arguments"]
)
# Find the tool definition (fallback to first if only one available)
tool_def = next(
(
tool
for tool in tool_functions
if tool["function"]["name"] == tool_name
),
tool_functions[0] if len(tool_functions) == 1 else None,
)
if not tool_def:
continue
# Get field mapping and sink node ID
field_mapping = tool_def["function"].get("_field_mapping", {})
sink_node_id = tool_def["function"]["_sink_node_id"]
expected_args = tool_def["function"]["parameters"].get(
"properties", tool_args.keys()
)
# Yield tool outputs like SmartDecisionMakerBlock
for clean_arg_name in expected_args:
original_field_name = field_mapping.get(
clean_arg_name, clean_arg_name
)
arg_value = tool_args.get(clean_arg_name)
# Create the same emit key format as SmartDecisionMakerBlock
sanitized_arg_name = self.cleanup(original_field_name)
emit_key = f"tools_^_{sink_node_id}_~_{sanitized_arg_name}"
logger.debug(f"Yielding tool output: {emit_key}")
yield emit_key, arg_value
# Yield conversation and tool results
yield (
"conversations",
[
{
"role": "assistant",
"content": text_content,
"tool_calls": tool_calls,
}
],
)
yield "tools", tool_calls
yield "success", True
return
# If no messages received
yield "error", "No response from Claude Agent SDK"
yield "success", False
async def run(
self,
input_data: Input,
*,
credentials: APIKeyCredentials,
graph_id: str,
node_id: str,
graph_exec_id: str,
node_exec_id: str,
user_id: str,
graph_version: int,
execution_context: ExecutionContext,
execution_processor: "ExecutionProcessor",
**kwargs, # Additional execution context parameters
) -> BlockOutput:
_ = kwargs # Suppress unused parameter warning
# Validate credentials
if credentials.provider != ProviderName.ANTHROPIC:
error_msg = f"SmartAgentBlock requires Anthropic/Claude credentials, but received {credentials.provider} credentials. Please configure Anthropic API key credentials."
logger.error(error_msg)
yield "error", error_msg
yield "success", False
return
# Discover connected AutoGPT tool nodes
try:
tool_functions = await self._create_tool_node_signatures(node_id)
except ValueError as e:
if "no next node" in str(e).lower():
# Agent can work without tools - just provide Claude with reasoning capability
tool_functions = []
logger.info("No tools connected - running as pure Claude Agent")
else:
raise
yield "tool_functions", json.dumps(tool_functions)
# Always run Claude Agent SDK in agent mode (iterative execution)
async for result in self._execute_tools_agent_mode(
input_data=input_data,
credentials=credentials,
tool_functions=tool_functions,
graph_exec_id=graph_exec_id,
node_id=node_id,
node_exec_id=node_exec_id,
user_id=user_id,
graph_id=graph_id,
graph_version=graph_version,
execution_context=execution_context,
execution_processor=execution_processor,
):
yield result

View File

@@ -1,8 +1,9 @@
import logging import logging
import queue
from collections import defaultdict from collections import defaultdict
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from enum import Enum from enum import Enum
from multiprocessing import Manager
from queue import Empty
from typing import ( from typing import (
TYPE_CHECKING, TYPE_CHECKING,
Annotated, Annotated,
@@ -1163,16 +1164,12 @@ class NodeExecutionEntry(BaseModel):
class ExecutionQueue(Generic[T]): class ExecutionQueue(Generic[T]):
""" """
Thread-safe queue for managing node execution within a single graph execution. Queue for managing the execution of agents.
This will be shared between different processes
Note: Uses queue.Queue (not multiprocessing.Queue) since all access is from
threads within the same process. If migrating back to ProcessPoolExecutor,
replace with multiprocessing.Manager().Queue() for cross-process safety.
""" """
def __init__(self): def __init__(self):
# Thread-safe queue (not multiprocessing) — see class docstring self.queue = Manager().Queue()
self.queue: queue.Queue[T] = queue.Queue()
def add(self, execution: T) -> T: def add(self, execution: T) -> T:
self.queue.put(execution) self.queue.put(execution)
@@ -1187,7 +1184,7 @@ class ExecutionQueue(Generic[T]):
def get_or_none(self) -> T | None: def get_or_none(self) -> T | None:
try: try:
return self.queue.get_nowait() return self.queue.get_nowait()
except queue.Empty: except Empty:
return None return None

View File

@@ -1,60 +0,0 @@
"""Tests for ExecutionQueue thread-safety."""
import queue
import threading
import pytest
from backend.data.execution import ExecutionQueue
def test_execution_queue_uses_stdlib_queue():
"""Verify ExecutionQueue uses queue.Queue (not multiprocessing)."""
q = ExecutionQueue()
assert isinstance(q.queue, queue.Queue)
def test_basic_operations():
"""Test add, get, empty, and get_or_none."""
q = ExecutionQueue()
assert q.empty() is True
assert q.get_or_none() is None
result = q.add("item1")
assert result == "item1"
assert q.empty() is False
item = q.get()
assert item == "item1"
assert q.empty() is True
def test_thread_safety():
"""Test concurrent access from multiple threads."""
q = ExecutionQueue()
results = []
num_items = 100
def producer():
for i in range(num_items):
q.add(f"item_{i}")
def consumer():
count = 0
while count < num_items:
item = q.get_or_none()
if item is not None:
results.append(item)
count += 1
producer_thread = threading.Thread(target=producer)
consumer_thread = threading.Thread(target=consumer)
producer_thread.start()
consumer_thread.start()
producer_thread.join(timeout=5)
consumer_thread.join(timeout=5)
assert len(results) == num_items

View File

@@ -825,6 +825,29 @@ files = [
{file = "charset_normalizer-3.4.2.tar.gz", hash = "sha256:5baececa9ecba31eff645232d59845c07aa030f0c81ee70184a90d35099a0e63"}, {file = "charset_normalizer-3.4.2.tar.gz", hash = "sha256:5baececa9ecba31eff645232d59845c07aa030f0c81ee70184a90d35099a0e63"},
] ]
[[package]]
name = "claude-agent-sdk"
version = "0.1.17"
description = "Python SDK for Claude Code"
optional = false
python-versions = ">=3.10"
groups = ["main"]
files = [
{file = "claude_agent_sdk-0.1.17-py3-none-macosx_11_0_arm64.whl", hash = "sha256:46fd904ce90a183414f979d359519cff347f52c1906d50f1e247551c786a3613"},
{file = "claude_agent_sdk-0.1.17-py3-none-manylinux_2_17_aarch64.whl", hash = "sha256:08c41656da3ec7930cb966b35df23da6f68fa0fc0a32c61e73d6450c40c0e17e"},
{file = "claude_agent_sdk-0.1.17-py3-none-manylinux_2_17_x86_64.whl", hash = "sha256:be5fe696e50829348b031106dc926bb459c94804a13584a52a483a35ebd1c388"},
{file = "claude_agent_sdk-0.1.17-py3-none-win_amd64.whl", hash = "sha256:f2f4e05785a42525cf8026f0bba69bce359b83a57856f0b7349cd311a0d644be"},
{file = "claude_agent_sdk-0.1.17.tar.gz", hash = "sha256:85d0818b7259b65ea0ee3103ae736f2b359243de81540ab1eee771d8cae0d8ac"},
]
[package.dependencies]
anyio = ">=4.0.0"
mcp = ">=0.1.0"
typing-extensions = {version = ">=4.0.0", markers = "python_version < \"3.11\""}
[package.extras]
dev = ["anyio[trio] (>=4.0.0)", "mypy (>=1.0.0)", "pytest (>=7.0.0)", "pytest-asyncio (>=0.20.0)", "pytest-cov (>=4.0.0)", "ruff (>=0.1.0)"]
[[package]] [[package]]
name = "cleo" name = "cleo"
version = "2.1.0" version = "2.1.0"
@@ -2304,6 +2327,18 @@ http2 = ["h2 (>=3,<5)"]
socks = ["socksio (==1.*)"] socks = ["socksio (==1.*)"]
zstd = ["zstandard (>=0.18.0)"] zstd = ["zstandard (>=0.18.0)"]
[[package]]
name = "httpx-sse"
version = "0.4.3"
description = "Consume Server-Sent Event (SSE) messages with HTTPX."
optional = false
python-versions = ">=3.9"
groups = ["main"]
files = [
{file = "httpx_sse-0.4.3-py3-none-any.whl", hash = "sha256:0ac1c9fe3c0afad2e0ebb25a934a59f4c7823b60792691f779fad2c5568830fc"},
{file = "httpx_sse-0.4.3.tar.gz", hash = "sha256:9b1ed0127459a66014aec3c56bebd93da3c1bc8bb6618c8082039a44889a755d"},
]
[[package]] [[package]]
name = "huggingface-hub" name = "huggingface-hub"
version = "0.34.4" version = "0.34.4"
@@ -2941,6 +2976,39 @@ files = [
{file = "mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325"}, {file = "mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325"},
] ]
[[package]]
name = "mcp"
version = "1.24.0"
description = "Model Context Protocol SDK"
optional = false
python-versions = ">=3.10"
groups = ["main"]
files = [
{file = "mcp-1.24.0-py3-none-any.whl", hash = "sha256:db130e103cc50ddc3dffc928382f33ba3eaef0b711f7a87c05e7ded65b1ca062"},
{file = "mcp-1.24.0.tar.gz", hash = "sha256:aeaad134664ce56f2721d1abf300666a1e8348563f4d3baff361c3b652448efc"},
]
[package.dependencies]
anyio = ">=4.5"
httpx = ">=0.27.1"
httpx-sse = ">=0.4"
jsonschema = ">=4.20.0"
pydantic = ">=2.11.0,<3.0.0"
pydantic-settings = ">=2.5.2"
pyjwt = {version = ">=2.10.1", extras = ["crypto"]}
python-multipart = ">=0.0.9"
pywin32 = {version = ">=310", markers = "sys_platform == \"win32\""}
sse-starlette = ">=1.6.1"
starlette = ">=0.27"
typing-extensions = ">=4.9.0"
typing-inspection = ">=0.4.1"
uvicorn = {version = ">=0.31.1", markers = "sys_platform != \"emscripten\""}
[package.extras]
cli = ["python-dotenv (>=1.0.0)", "typer (>=0.16.0)"]
rich = ["rich (>=13.9.4)"]
ws = ["websockets (>=15.0.1)"]
[[package]] [[package]]
name = "mdurl" name = "mdurl"
version = "0.1.2" version = "0.1.2"
@@ -4481,7 +4549,6 @@ files = [
{file = "psycopg2_binary-2.9.10-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:bb89f0a835bcfc1d42ccd5f41f04870c1b936d8507c6df12b7737febc40f0909"}, {file = "psycopg2_binary-2.9.10-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:bb89f0a835bcfc1d42ccd5f41f04870c1b936d8507c6df12b7737febc40f0909"},
{file = "psycopg2_binary-2.9.10-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f0c2d907a1e102526dd2986df638343388b94c33860ff3bbe1384130828714b1"}, {file = "psycopg2_binary-2.9.10-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f0c2d907a1e102526dd2986df638343388b94c33860ff3bbe1384130828714b1"},
{file = "psycopg2_binary-2.9.10-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f8157bed2f51db683f31306aa497311b560f2265998122abe1dce6428bd86567"}, {file = "psycopg2_binary-2.9.10-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f8157bed2f51db683f31306aa497311b560f2265998122abe1dce6428bd86567"},
{file = "psycopg2_binary-2.9.10-cp313-cp313-win_amd64.whl", hash = "sha256:27422aa5f11fbcd9b18da48373eb67081243662f9b46e6fd07c3eb46e4535142"},
{file = "psycopg2_binary-2.9.10-cp38-cp38-macosx_12_0_x86_64.whl", hash = "sha256:eb09aa7f9cecb45027683bb55aebaaf45a0df8bf6de68801a6afdc7947bb09d4"}, {file = "psycopg2_binary-2.9.10-cp38-cp38-macosx_12_0_x86_64.whl", hash = "sha256:eb09aa7f9cecb45027683bb55aebaaf45a0df8bf6de68801a6afdc7947bb09d4"},
{file = "psycopg2_binary-2.9.10-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b73d6d7f0ccdad7bc43e6d34273f70d587ef62f824d7261c4ae9b8b1b6af90e8"}, {file = "psycopg2_binary-2.9.10-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b73d6d7f0ccdad7bc43e6d34273f70d587ef62f824d7261c4ae9b8b1b6af90e8"},
{file = "psycopg2_binary-2.9.10-cp38-cp38-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ce5ab4bf46a211a8e924d307c1b1fcda82368586a19d0a24f8ae166f5c784864"}, {file = "psycopg2_binary-2.9.10-cp38-cp38-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ce5ab4bf46a211a8e924d307c1b1fcda82368586a19d0a24f8ae166f5c784864"},
@@ -5086,7 +5153,7 @@ description = "Python for Window Extensions"
optional = false optional = false
python-versions = "*" python-versions = "*"
groups = ["main"] groups = ["main"]
markers = "platform_system == \"Windows\"" markers = "platform_system == \"Windows\" or sys_platform == \"win32\""
files = [ files = [
{file = "pywin32-311-cp310-cp310-win32.whl", hash = "sha256:d03ff496d2a0cd4a5893504789d4a15399133fe82517455e78bad62efbb7f0a3"}, {file = "pywin32-311-cp310-cp310-win32.whl", hash = "sha256:d03ff496d2a0cd4a5893504789d4a15399133fe82517455e78bad62efbb7f0a3"},
{file = "pywin32-311-cp310-cp310-win_amd64.whl", hash = "sha256:797c2772017851984b97180b0bebe4b620bb86328e8a884bb626156295a63b3b"}, {file = "pywin32-311-cp310-cp310-win_amd64.whl", hash = "sha256:797c2772017851984b97180b0bebe4b620bb86328e8a884bb626156295a63b3b"},
@@ -6053,6 +6120,27 @@ postgresql-psycopgbinary = ["psycopg[binary] (>=3.0.7)"]
pymysql = ["pymysql"] pymysql = ["pymysql"]
sqlcipher = ["sqlcipher3_binary"] sqlcipher = ["sqlcipher3_binary"]
[[package]]
name = "sse-starlette"
version = "3.0.3"
description = "SSE plugin for Starlette"
optional = false
python-versions = ">=3.9"
groups = ["main"]
files = [
{file = "sse_starlette-3.0.3-py3-none-any.whl", hash = "sha256:af5bf5a6f3933df1d9c7f8539633dc8444ca6a97ab2e2a7cd3b6e431ac03a431"},
{file = "sse_starlette-3.0.3.tar.gz", hash = "sha256:88cfb08747e16200ea990c8ca876b03910a23b547ab3bd764c0d8eb81019b971"},
]
[package.dependencies]
anyio = ">=4.7.0"
[package.extras]
daphne = ["daphne (>=4.2.0)"]
examples = ["aiosqlite (>=0.21.0)", "fastapi (>=0.115.12)", "sqlalchemy[asyncio] (>=2.0.41)", "starlette (>=0.49.1)", "uvicorn (>=0.34.0)"]
granian = ["granian (>=2.3.1)"]
uvicorn = ["uvicorn (>=0.34.0)"]
[[package]] [[package]]
name = "stagehand" name = "stagehand"
version = "0.5.1" version = "0.5.1"
@@ -7279,4 +7367,4 @@ cffi = ["cffi (>=1.11)"]
[metadata] [metadata]
lock-version = "2.1" lock-version = "2.1"
python-versions = ">=3.10,<3.14" python-versions = ">=3.10,<3.14"
content-hash = "13b191b2a1989d3321ff713c66ff6f5f4f3b82d15df4d407e0e5dbf87d7522c4" content-hash = "4594d56a0d808095e20861774ddfe8f87bc461f4f88ca7b748ff066225fa2684"

View File

@@ -82,6 +82,7 @@ firecrawl-py = "^4.3.6"
exa-py = "^1.14.20" exa-py = "^1.14.20"
croniter = "^6.0.0" croniter = "^6.0.0"
stagehand = "^0.5.1" stagehand = "^0.5.1"
claude-agent-sdk = "^0.1.17"
[tool.poetry.group.dev.dependencies] [tool.poetry.group.dev.dependencies]
aiohappyeyeballs = "^2.6.1" aiohappyeyeballs = "^2.6.1"

View File

@@ -1,16 +1,11 @@
"use client"; "use client";
import type { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent"; import type { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
import type { import { Text } from "@/components/atoms/Text/Text";
BlockIOSubSchema, import type { CredentialsMetaInput } from "@/lib/autogpt-server-api/types";
CredentialsMetaInput,
} from "@/lib/autogpt-server-api/types";
import { CredentialsInput } from "../CredentialsInputs/CredentialsInputs"; import { CredentialsInput } from "../CredentialsInputs/CredentialsInputs";
import { import { RunAgentInputs } from "../RunAgentInputs/RunAgentInputs";
getAgentCredentialsFields, import { getAgentCredentialsFields, getAgentInputFields } from "./helpers";
getAgentInputFields,
renderValue,
} from "./helpers";
type Props = { type Props = {
agent: LibraryAgent; agent: LibraryAgent;
@@ -28,19 +23,23 @@ export function AgentInputsReadOnly({
getAgentCredentialsFields(agent), getAgentCredentialsFields(agent),
); );
// Take actual input entries as leading; augment with schema from input fields.
// TODO: ensure consistent ordering.
const inputEntries = const inputEntries =
inputs && inputs &&
Object.entries(inputs).map<[string, [BlockIOSubSchema | undefined, any]]>( Object.entries(inputs).map(([key, value]) => ({
([k, v]) => [k, [inputFields[k], v]], key,
); schema: inputFields[key],
value,
}));
const hasInputs = inputEntries && inputEntries.length > 0; const hasInputs = inputEntries && inputEntries.length > 0;
const hasCredentials = credentialInputs && credentialFieldEntries.length > 0; const hasCredentials = credentialInputs && credentialFieldEntries.length > 0;
if (!hasInputs && !hasCredentials) { if (!hasInputs && !hasCredentials) {
return <div className="text-neutral-600">No input for this run.</div>; return (
<Text variant="body" className="text-zinc-700">
No input for this run.
</Text>
);
} }
return ( return (
@@ -48,16 +47,20 @@ export function AgentInputsReadOnly({
{/* Regular inputs */} {/* Regular inputs */}
{hasInputs && ( {hasInputs && (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
{inputEntries.map(([key, [schema, value]]) => ( {inputEntries.map(({ key, schema, value }) => {
<div key={key} className="flex flex-col gap-1.5"> if (!schema) return null;
<label className="text-sm font-medium">
{schema?.title || key} return (
</label> <RunAgentInputs
<p className="whitespace-pre-wrap break-words text-sm text-neutral-700"> key={key}
{renderValue(value)} schema={schema}
</p> value={value}
</div> placeholder={schema.description}
))} onChange={() => {}}
readOnly={true}
/>
);
})}
</div> </div>
)} )}

View File

@@ -9,6 +9,7 @@ import { Button } from "@/components/atoms/Button/Button";
import { FileInput } from "@/components/atoms/FileInput/FileInput"; import { FileInput } from "@/components/atoms/FileInput/FileInput";
import { Switch } from "@/components/atoms/Switch/Switch"; import { Switch } from "@/components/atoms/Switch/Switch";
import { GoogleDrivePickerInput } from "@/components/contextual/GoogleDrivePicker/GoogleDrivePickerInput"; import { GoogleDrivePickerInput } from "@/components/contextual/GoogleDrivePicker/GoogleDrivePickerInput";
import { InformationTooltip } from "@/components/molecules/InformationTooltip/InformationTooltip";
import { TimePicker } from "@/components/molecules/TimePicker/TimePicker"; import { TimePicker } from "@/components/molecules/TimePicker/TimePicker";
import { import {
BlockIOObjectSubSchema, BlockIOObjectSubSchema,
@@ -32,6 +33,7 @@ interface Props {
value?: any; value?: any;
placeholder?: string; placeholder?: string;
onChange: (value: any) => void; onChange: (value: any) => void;
readOnly?: boolean;
} }
/** /**
@@ -44,6 +46,7 @@ export function RunAgentInputs({
value, value,
placeholder, placeholder,
onChange, onChange,
readOnly = false,
...props ...props
}: Props & React.HTMLAttributes<HTMLElement>) { }: Props & React.HTMLAttributes<HTMLElement>) {
const { handleUploadFile, uploadProgress } = useRunAgentInputs(); const { handleUploadFile, uploadProgress } = useRunAgentInputs();
@@ -62,7 +65,6 @@ export function RunAgentInputs({
id={`${baseId}-number`} id={`${baseId}-number`}
label={schema.title ?? placeholder ?? "Number"} label={schema.title ?? placeholder ?? "Number"}
hideLabel hideLabel
size="small"
type="number" type="number"
value={value ?? ""} value={value ?? ""}
placeholder={placeholder || "Enter number"} placeholder={placeholder || "Enter number"}
@@ -80,7 +82,6 @@ export function RunAgentInputs({
id={`${baseId}-textarea`} id={`${baseId}-textarea`}
label={schema.title ?? placeholder ?? "Text"} label={schema.title ?? placeholder ?? "Text"}
hideLabel hideLabel
size="small"
type="textarea" type="textarea"
rows={3} rows={3}
value={value ?? ""} value={value ?? ""}
@@ -130,7 +131,6 @@ export function RunAgentInputs({
id={`${baseId}-date`} id={`${baseId}-date`}
label={schema.title ?? placeholder ?? "Date"} label={schema.title ?? placeholder ?? "Date"}
hideLabel hideLabel
size="small"
type="date" type="date"
value={value ? format(value as Date, "yyyy-MM-dd") : ""} value={value ? format(value as Date, "yyyy-MM-dd") : ""}
onChange={(e) => { onChange={(e) => {
@@ -159,7 +159,6 @@ export function RunAgentInputs({
id={`${baseId}-datetime`} id={`${baseId}-datetime`}
label={schema.title ?? placeholder ?? "Date time"} label={schema.title ?? placeholder ?? "Date time"}
hideLabel hideLabel
size="small"
type="datetime-local" type="datetime-local"
value={value ?? ""} value={value ?? ""}
onChange={(e) => onChange((e.target as HTMLInputElement).value)} onChange={(e) => onChange((e.target as HTMLInputElement).value)}
@@ -194,7 +193,6 @@ export function RunAgentInputs({
label={schema.title ?? placeholder ?? "Select"} label={schema.title ?? placeholder ?? "Select"}
hideLabel hideLabel
value={value ?? ""} value={value ?? ""}
size="small"
onValueChange={(val: string) => onChange(val)} onValueChange={(val: string) => onChange(val)}
placeholder={placeholder || "Select an option"} placeholder={placeholder || "Select an option"}
options={schema.enum options={schema.enum
@@ -217,7 +215,6 @@ export function RunAgentInputs({
items={allKeys.map((key) => ({ items={allKeys.map((key) => ({
value: key, value: key,
label: _schema.properties[key]?.title ?? key, label: _schema.properties[key]?.title ?? key,
size: "small",
}))} }))}
selectedValues={selectedValues} selectedValues={selectedValues}
onChange={(values: string[]) => onChange={(values: string[]) =>
@@ -336,7 +333,6 @@ export function RunAgentInputs({
id={`${baseId}-text`} id={`${baseId}-text`}
label={schema.title ?? placeholder ?? "Text"} label={schema.title ?? placeholder ?? "Text"}
hideLabel hideLabel
size="small"
type="text" type="text"
value={value ?? ""} value={value ?? ""}
onChange={(e) => onChange((e.target as HTMLInputElement).value)} onChange={(e) => onChange((e.target as HTMLInputElement).value)}
@@ -347,6 +343,17 @@ export function RunAgentInputs({
} }
return ( return (
<div className="no-drag relative flex w-full">{innerInputElement}</div> <div className="flex w-full flex-col gap-0 space-y-2">
<label className="large-medium flex items-center gap-1 font-medium">
{schema.title || placeholder}
<InformationTooltip description={schema.description} />
</label>
<div
className="no-drag relative flex w-full"
style={readOnly ? { pointerEvents: "none", opacity: 0.7 } : undefined}
>
{innerInputElement}
</div>
</div>
); );
} }

View File

@@ -73,22 +73,15 @@ export function ModalRunSection() {
title="Task Inputs" title="Task Inputs"
subtitle="Enter the information you want to provide to the agent for this task" subtitle="Enter the information you want to provide to the agent for this task"
> >
{/* Regular inputs */}
{inputFields.map(([key, inputSubSchema]) => ( {inputFields.map(([key, inputSubSchema]) => (
<div key={key} className="flex w-full flex-col gap-0 space-y-2"> <RunAgentInputs
<label className="flex items-center gap-1 text-sm font-medium"> key={key}
{inputSubSchema.title || key} schema={inputSubSchema}
<InformationTooltip description={inputSubSchema.description} /> value={inputValues[key] ?? inputSubSchema.default}
</label> placeholder={inputSubSchema.description}
onChange={(value) => setInputValue(key, value)}
<RunAgentInputs data-testid={`agent-input-${key}`}
schema={inputSubSchema} />
value={inputValues[key] ?? inputSubSchema.default}
placeholder={inputSubSchema.description}
onChange={(value) => setInputValue(key, value)}
data-testid={`agent-input-${key}`}
/>
</div>
))} ))}
</ModalSection> </ModalSection>
) : null} ) : null}

View File

@@ -4,20 +4,19 @@ import { AgentExecutionStatus } from "@/app/api/__generated__/models/agentExecut
import type { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent"; import type { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
import { LoadingSpinner } from "@/components/atoms/LoadingSpinner/LoadingSpinner"; import { LoadingSpinner } from "@/components/atoms/LoadingSpinner/LoadingSpinner";
import { Text } from "@/components/atoms/Text/Text"; import { Text } from "@/components/atoms/Text/Text";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/atoms/Tooltip/BaseTooltip";
import { ErrorCard } from "@/components/molecules/ErrorCard/ErrorCard"; import { ErrorCard } from "@/components/molecules/ErrorCard/ErrorCard";
import { InformationTooltip } from "@/components/molecules/InformationTooltip/InformationTooltip";
import {
ScrollableTabs,
ScrollableTabsContent,
ScrollableTabsList,
ScrollableTabsTrigger,
} from "@/components/molecules/ScrollableTabs/ScrollableTabs";
import { PendingReviewsList } from "@/components/organisms/PendingReviewsList/PendingReviewsList"; import { PendingReviewsList } from "@/components/organisms/PendingReviewsList/PendingReviewsList";
import { usePendingReviewsForExecution } from "@/hooks/usePendingReviews"; import { usePendingReviewsForExecution } from "@/hooks/usePendingReviews";
import { isLargeScreen, useBreakpoint } from "@/lib/hooks/useBreakpoint"; import { isLargeScreen, useBreakpoint } from "@/lib/hooks/useBreakpoint";
import { InfoIcon } from "@phosphor-icons/react";
import { useEffect } from "react"; import { useEffect } from "react";
import { AgentInputsReadOnly } from "../../modals/AgentInputsReadOnly/AgentInputsReadOnly"; import { AgentInputsReadOnly } from "../../modals/AgentInputsReadOnly/AgentInputsReadOnly";
import { AnchorLinksWrap } from "../AnchorLinksWrap";
import { LoadingSelectedContent } from "../LoadingSelectedContent"; import { LoadingSelectedContent } from "../LoadingSelectedContent";
import { RunDetailCard } from "../RunDetailCard/RunDetailCard"; import { RunDetailCard } from "../RunDetailCard/RunDetailCard";
import { RunDetailHeader } from "../RunDetailHeader/RunDetailHeader"; import { RunDetailHeader } from "../RunDetailHeader/RunDetailHeader";
@@ -28,9 +27,6 @@ import { SelectedRunActions } from "./components/SelectedRunActions/SelectedRunA
import { WebhookTriggerSection } from "./components/WebhookTriggerSection"; import { WebhookTriggerSection } from "./components/WebhookTriggerSection";
import { useSelectedRunView } from "./useSelectedRunView"; import { useSelectedRunView } from "./useSelectedRunView";
const anchorStyles =
"border-b-2 border-transparent pb-1 text-sm font-medium text-slate-600 transition-colors hover:text-slate-900 hover:border-slate-900";
interface Props { interface Props {
agent: LibraryAgent; agent: LibraryAgent;
runId: string; runId: string;
@@ -65,13 +61,6 @@ export function SelectedRunView({
const withSummary = run?.stats?.activity_status; const withSummary = run?.stats?.activity_status;
const withReviews = run?.status === AgentExecutionStatus.REVIEW; const withReviews = run?.status === AgentExecutionStatus.REVIEW;
function scrollToSection(id: string) {
const element = document.getElementById(id);
if (element) {
element.scrollIntoView({ behavior: "smooth", block: "start" });
}
}
if (responseError || httpError) { if (responseError || httpError) {
return ( return (
<ErrorCard <ErrorCard
@@ -112,118 +101,116 @@ export function SelectedRunView({
/> />
)} )}
{/* Navigation Links */} <ScrollableTabs
<AnchorLinksWrap> defaultValue="output"
{withSummary && ( className="-mt-2 flex flex-col"
<button >
onClick={() => scrollToSection("summary")} <ScrollableTabsList className="px-4">
className={anchorStyles} {withSummary && (
> <ScrollableTabsTrigger value="summary">
Summary Summary
</button> </ScrollableTabsTrigger>
)}
<button
onClick={() => scrollToSection("output")}
className={anchorStyles}
>
Output
</button>
<button
onClick={() => scrollToSection("input")}
className={anchorStyles}
>
Your input
</button>
{withReviews && (
<button
onClick={() => scrollToSection("reviews")}
className={anchorStyles}
>
Reviews ({pendingReviews.length})
</button>
)}
</AnchorLinksWrap>
{/* Summary Section */}
{withSummary && (
<div id="summary" className="scroll-mt-4">
<RunDetailCard
title={
<div className="flex items-center gap-2">
<Text variant="lead-semibold">Summary</Text>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<InfoIcon
size={16}
className="cursor-help text-neutral-500 hover:text-neutral-700"
/>
</TooltipTrigger>
<TooltipContent>
<p className="max-w-xs">
This AI-generated summary describes how the agent
handled your task. It&apos;s an experimental
feature and may occasionally be inaccurate.
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
}
>
<RunSummary run={run} />
</RunDetailCard>
</div>
)}
{/* Output Section */}
<div id="output" className="scroll-mt-4">
<RunDetailCard title="Output">
{isLoading ? (
<div className="text-neutral-500">
<LoadingSpinner />
</div>
) : run && "outputs" in run ? (
<RunOutputs outputs={run.outputs as any} />
) : (
<Text variant="body" className="text-neutral-600">
No output from this run.
</Text>
)} )}
</RunDetailCard> <ScrollableTabsTrigger value="output">
</div> Output
</ScrollableTabsTrigger>
{/* Input Section */} <ScrollableTabsTrigger value="input">
<div id="input" className="scroll-mt-4"> Your input
<RunDetailCard title="Your input"> </ScrollableTabsTrigger>
<AgentInputsReadOnly {withReviews && (
agent={agent} <ScrollableTabsTrigger value="reviews">
inputs={run?.inputs} Reviews ({pendingReviews.length})
credentialInputs={run?.credential_inputs} </ScrollableTabsTrigger>
/> )}
</RunDetailCard> </ScrollableTabsList>
</div> <div className="my-6 flex flex-col gap-6">
{/* Summary Section */}
{/* Reviews Section */} {withSummary && (
{withReviews && ( <ScrollableTabsContent value="summary">
<div id="reviews" className="scroll-mt-4"> <div className="scroll-mt-4">
<RunDetailCard> <RunDetailCard
{reviewsLoading ? ( title={
<div className="text-neutral-500">Loading reviews</div> <div className="flex items-center gap-1">
) : pendingReviews.length > 0 ? ( <Text variant="lead-semibold">Summary</Text>
<PendingReviewsList <InformationTooltip
reviews={pendingReviews} iconSize={20}
onReviewComplete={refetchReviews} description="This AI-generated summary describes how the agent handled your task. It's an experimental feature and may occasionally be inaccurate."
emptyMessage="No pending reviews for this execution" />
/> </div>
) : ( }
<div className="text-neutral-600"> >
No pending reviews for this execution <RunSummary run={run} />
</RunDetailCard>
</div> </div>
)} </ScrollableTabsContent>
</RunDetailCard> )}
{/* Output Section */}
<ScrollableTabsContent value="output">
<div className="scroll-mt-4">
<RunDetailCard title="Output">
{isLoading ? (
<div className="text-neutral-500">
<LoadingSpinner />
</div>
) : run && "outputs" in run ? (
<RunOutputs outputs={run.outputs as any} />
) : (
<Text variant="body" className="text-neutral-600">
No output from this run.
</Text>
)}
</RunDetailCard>
</div>
</ScrollableTabsContent>
{/* Input Section */}
<ScrollableTabsContent value="input">
<div id="input" className="scroll-mt-4">
<RunDetailCard
title={
<div className="flex items-center gap-1">
<Text variant="lead-semibold">Your input</Text>
<InformationTooltip
iconSize={20}
description="This is the input that was provided to the agent for running this task."
/>
</div>
}
>
<AgentInputsReadOnly
agent={agent}
inputs={run?.inputs}
credentialInputs={run?.credential_inputs}
/>
</RunDetailCard>
</div>
</ScrollableTabsContent>
{/* Reviews Section */}
{withReviews && (
<ScrollableTabsContent value="reviews">
<div className="scroll-mt-4">
<RunDetailCard>
{reviewsLoading ? (
<LoadingSpinner size="small" />
) : pendingReviews.length > 0 ? (
<PendingReviewsList
reviews={pendingReviews}
onReviewComplete={refetchReviews}
emptyMessage="No pending reviews for this execution"
/>
) : (
<Text variant="body" className="text-zinc-700">
No pending reviews for this execution
</Text>
)}
</RunDetailCard>
</div>
</ScrollableTabsContent>
)}
</div> </div>
)} </ScrollableTabs>
</div> </div>
</SelectedViewLayout> </SelectedViewLayout>
</div> </div>

View File

@@ -9,7 +9,6 @@ import { humanizeCronExpression } from "@/lib/cron-expression-utils";
import { isLargeScreen, useBreakpoint } from "@/lib/hooks/useBreakpoint"; import { isLargeScreen, useBreakpoint } from "@/lib/hooks/useBreakpoint";
import { formatInTimezone, getTimezoneDisplayName } from "@/lib/timezone-utils"; import { formatInTimezone, getTimezoneDisplayName } from "@/lib/timezone-utils";
import { AgentInputsReadOnly } from "../../modals/AgentInputsReadOnly/AgentInputsReadOnly"; import { AgentInputsReadOnly } from "../../modals/AgentInputsReadOnly/AgentInputsReadOnly";
import { AnchorLinksWrap } from "../AnchorLinksWrap";
import { LoadingSelectedContent } from "../LoadingSelectedContent"; import { LoadingSelectedContent } from "../LoadingSelectedContent";
import { RunDetailCard } from "../RunDetailCard/RunDetailCard"; import { RunDetailCard } from "../RunDetailCard/RunDetailCard";
import { RunDetailHeader } from "../RunDetailHeader/RunDetailHeader"; import { RunDetailHeader } from "../RunDetailHeader/RunDetailHeader";
@@ -17,9 +16,6 @@ import { SelectedViewLayout } from "../SelectedViewLayout";
import { SelectedScheduleActions } from "./components/SelectedScheduleActions"; import { SelectedScheduleActions } from "./components/SelectedScheduleActions";
import { useSelectedScheduleView } from "./useSelectedScheduleView"; import { useSelectedScheduleView } from "./useSelectedScheduleView";
const anchorStyles =
"border-b-2 border-transparent pb-1 text-sm font-medium text-slate-600 transition-colors hover:text-slate-900 hover:border-slate-900";
interface Props { interface Props {
agent: LibraryAgent; agent: LibraryAgent;
scheduleId: string; scheduleId: string;
@@ -45,13 +41,6 @@ export function SelectedScheduleView({
const breakpoint = useBreakpoint(); const breakpoint = useBreakpoint();
const isLgScreenUp = isLargeScreen(breakpoint); const isLgScreenUp = isLargeScreen(breakpoint);
function scrollToSection(id: string) {
const element = document.getElementById(id);
if (element) {
element.scrollIntoView({ behavior: "smooth", block: "start" });
}
}
if (error) { if (error) {
return ( return (
<ErrorCard <ErrorCard
@@ -108,22 +97,6 @@ export function SelectedScheduleView({
) : null} ) : null}
</div> </div>
{/* Navigation Links */}
<AnchorLinksWrap>
<button
onClick={() => scrollToSection("schedule")}
className={anchorStyles}
>
Schedule
</button>
<button
onClick={() => scrollToSection("input")}
className={anchorStyles}
>
Your input
</button>
</AnchorLinksWrap>
{/* Schedule Section */} {/* Schedule Section */}
<div id="schedule" className="scroll-mt-4"> <div id="schedule" className="scroll-mt-4">
<RunDetailCard title="Schedule"> <RunDetailCard title="Schedule">

View File

@@ -1,84 +0,0 @@
"use client";
import type { GraphExecutionJobInfo } from "@/app/api/__generated__/models/graphExecutionJobInfo";
import type { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
import { Button } from "@/components/atoms/Button/Button";
import { Text } from "@/components/atoms/Text/Text";
import { Dialog } from "@/components/molecules/Dialog/Dialog";
import { PencilSimpleIcon } from "@phosphor-icons/react";
import { RunAgentInputs } from "../../../../modals/RunAgentInputs/RunAgentInputs";
import { useEditInputsModal } from "./useEditInputsModal";
type Props = {
agent: LibraryAgent;
schedule: GraphExecutionJobInfo;
};
export function EditInputsModal({ agent, schedule }: Props) {
const {
isOpen,
setIsOpen,
inputFields,
values,
setValues,
handleSave,
isSaving,
} = useEditInputsModal(agent, schedule);
return (
<Dialog
controlled={{ isOpen, set: setIsOpen }}
styling={{ maxWidth: "32rem" }}
>
<Dialog.Trigger>
<Button
variant="ghost"
size="small"
className="absolute -right-2 -top-2"
>
<PencilSimpleIcon className="size-4" /> Edit inputs
</Button>
</Dialog.Trigger>
<Dialog.Content>
<div className="flex flex-col gap-4">
<Text variant="h3">Edit inputs</Text>
<div className="flex flex-col gap-4">
{Object.entries(inputFields).map(([key, fieldSchema]) => (
<div key={key} className="flex flex-col gap-1.5">
<label className="text-sm font-medium">
{fieldSchema?.title || key}
</label>
<RunAgentInputs
schema={fieldSchema as any}
value={values[key]}
onChange={(v) => setValues((prev) => ({ ...prev, [key]: v }))}
/>
</div>
))}
</div>
</div>
<Dialog.Footer>
<div className="flex w-full justify-end gap-2">
<Button
variant="secondary"
size="small"
onClick={() => setIsOpen(false)}
className="min-w-32"
>
Cancel
</Button>
<Button
variant="primary"
size="small"
onClick={handleSave}
loading={isSaving}
className="min-w-32"
>
{isSaving ? "Saving…" : "Save"}
</Button>
</div>
</Dialog.Footer>
</Dialog.Content>
</Dialog>
);
}

View File

@@ -1,78 +0,0 @@
"use client";
import { useMemo, useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { getGetV1ListExecutionSchedulesForAGraphQueryKey } from "@/app/api/__generated__/endpoints/schedules/schedules";
import type { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
import type { GraphExecutionJobInfo } from "@/app/api/__generated__/models/graphExecutionJobInfo";
import { useToast } from "@/components/molecules/Toast/use-toast";
function getAgentInputFields(agent: LibraryAgent): Record<string, any> {
const schema = agent.input_schema as unknown as {
properties?: Record<string, any>;
} | null;
if (!schema || !schema.properties) return {};
const properties = schema.properties as Record<string, any>;
const visibleEntries = Object.entries(properties).filter(
([, sub]) => !sub?.hidden,
);
return Object.fromEntries(visibleEntries);
}
export function useEditInputsModal(
agent: LibraryAgent,
schedule: GraphExecutionJobInfo,
) {
const queryClient = useQueryClient();
const { toast } = useToast();
const [isOpen, setIsOpen] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const inputFields = useMemo(() => getAgentInputFields(agent), [agent]);
const [values, setValues] = useState<Record<string, any>>({
...(schedule.input_data as Record<string, any>),
});
async function handleSave() {
setIsSaving(true);
try {
const res = await fetch(`/api/schedules/${schedule.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ inputs: values }),
});
if (!res.ok) {
let message = "Failed to update schedule inputs";
const data = await res.json();
message = data?.message || data?.detail || message;
throw new Error(message);
}
await queryClient.invalidateQueries({
queryKey: getGetV1ListExecutionSchedulesForAGraphQueryKey(
schedule.graph_id,
),
});
toast({
title: "Schedule inputs updated",
});
setIsOpen(false);
} catch (error: any) {
toast({
title: "Failed to update schedule inputs",
description: error?.message || "An unexpected error occurred.",
variant: "destructive",
});
}
setIsSaving(false);
}
return {
isOpen,
setIsOpen,
inputFields,
values,
setValues,
handleSave,
isSaving,
} as const;
}

View File

@@ -25,9 +25,10 @@ export function SelectedScheduleActions({ agent, scheduleId }: Props) {
<Button <Button
variant="icon" variant="icon"
size="icon" size="icon"
aria-label="Open in builder"
as="NextLink" as="NextLink"
href={openInBuilderHref} href={openInBuilderHref}
target="_blank"
aria-label="View scheduled task details"
> >
<EyeIcon weight="bold" size={18} className="text-zinc-700" /> <EyeIcon weight="bold" size={18} className="text-zinc-700" />
</Button> </Button>

View File

@@ -4,7 +4,6 @@ import type { GraphExecutionMeta } from "@/app/api/__generated__/models/graphExe
import type { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent"; import type { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
import { Input } from "@/components/atoms/Input/Input"; import { Input } from "@/components/atoms/Input/Input";
import { ErrorCard } from "@/components/molecules/ErrorCard/ErrorCard"; import { ErrorCard } from "@/components/molecules/ErrorCard/ErrorCard";
import { InformationTooltip } from "@/components/molecules/InformationTooltip/InformationTooltip";
import { import {
getAgentCredentialsFields, getAgentCredentialsFields,
getAgentInputFields, getAgentInputFields,
@@ -138,25 +137,13 @@ export function SelectedTemplateView({
<RunDetailCard title="Your Input"> <RunDetailCard title="Your Input">
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
{inputFields.map(([key, inputSubSchema]) => ( {inputFields.map(([key, inputSubSchema]) => (
<div <RunAgentInputs
key={key} key={key}
className="flex w-full flex-col gap-0 space-y-2" schema={inputSubSchema}
> value={inputs[key] ?? inputSubSchema.default}
<label className="flex items-center gap-1 text-sm font-medium"> placeholder={inputSubSchema.description}
{inputSubSchema.title || key} onChange={(value) => setInputValue(key, value)}
{inputSubSchema.description && ( />
<InformationTooltip
description={inputSubSchema.description}
/>
)}
</label>
<RunAgentInputs
schema={inputSubSchema}
value={inputs[key] ?? inputSubSchema.default}
placeholder={inputSubSchema.description}
onChange={(value) => setInputValue(key, value)}
/>
</div>
))} ))}
</div> </div>
</RunDetailCard> </RunDetailCard>

View File

@@ -3,7 +3,6 @@
import type { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent"; import type { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
import { Input } from "@/components/atoms/Input/Input"; import { Input } from "@/components/atoms/Input/Input";
import { ErrorCard } from "@/components/molecules/ErrorCard/ErrorCard"; import { ErrorCard } from "@/components/molecules/ErrorCard/ErrorCard";
import { InformationTooltip } from "@/components/molecules/InformationTooltip/InformationTooltip";
import { import {
getAgentCredentialsFields, getAgentCredentialsFields,
getAgentInputFields, getAgentInputFields,
@@ -131,25 +130,13 @@ export function SelectedTriggerView({
<RunDetailCard title="Your Input"> <RunDetailCard title="Your Input">
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
{inputFields.map(([key, inputSubSchema]) => ( {inputFields.map(([key, inputSubSchema]) => (
<div <RunAgentInputs
key={key} key={key}
className="flex w-full flex-col gap-0 space-y-2" schema={inputSubSchema}
> value={inputs[key] ?? inputSubSchema.default}
<label className="flex items-center gap-1 text-sm font-medium"> placeholder={inputSubSchema.description}
{inputSubSchema.title || key} onChange={(value) => setInputValue(key, value)}
{inputSubSchema.description && ( />
<InformationTooltip
description={inputSubSchema.description}
/>
)}
</label>
<RunAgentInputs
schema={inputSubSchema}
value={inputs[key] ?? inputSubSchema.default}
placeholder={inputSubSchema.description}
onChange={(value) => setInputValue(key, value)}
/>
</div>
))} ))}
</div> </div>
</RunDetailCard> </RunDetailCard>

View File

@@ -680,28 +680,20 @@ export function AgentRunDraftView({
{/* Regular inputs */} {/* Regular inputs */}
{Object.entries(agentInputFields).map(([key, inputSubSchema]) => ( {Object.entries(agentInputFields).map(([key, inputSubSchema]) => (
<div key={key} className="flex flex-col space-y-2"> <RunAgentInputs
<label className="flex items-center gap-1 text-sm font-medium"> key={key}
{inputSubSchema.title || key} schema={inputSubSchema}
<InformationTooltip value={inputValues[key] ?? inputSubSchema.default}
description={inputSubSchema.description} placeholder={inputSubSchema.description}
/> onChange={(value) => {
</label> setInputValues((obj) => ({
...obj,
<RunAgentInputs [key]: value,
schema={inputSubSchema} }));
value={inputValues[key] ?? inputSubSchema.default} setChangedPresetAttributes((prev) => prev.add("inputs"));
placeholder={inputSubSchema.description} }}
onChange={(value) => { data-testid={`agent-input-${key}`}
setInputValues((obj) => ({ />
...obj,
[key]: value,
}));
setChangedPresetAttributes((prev) => prev.add("inputs"));
}}
data-testid={`agent-input-${key}`}
/>
</div>
))} ))}
</CardContent> </CardContent>
</Card> </Card>

View File

@@ -1,36 +1,33 @@
"use client"; "use client";
import { LaunchDarklyProvider } from "@/services/feature-flags/feature-flag-provider"; import { TooltipProvider } from "@/components/atoms/Tooltip/BaseTooltip";
import OnboardingProvider from "@/providers/onboarding/onboarding-provider"; import { SentryUserTracker } from "@/components/monitor/SentryUserTracker";
import { BackendAPIProvider } from "@/lib/autogpt-server-api/context"; import { BackendAPIProvider } from "@/lib/autogpt-server-api/context";
import { getQueryClient } from "@/lib/react-query/queryClient"; import { getQueryClient } from "@/lib/react-query/queryClient";
import { QueryClientProvider } from "@tanstack/react-query";
import {
ThemeProvider as NextThemesProvider,
ThemeProviderProps,
} from "next-themes";
import { NuqsAdapter } from "nuqs/adapters/next/app";
import { TooltipProvider } from "@/components/atoms/Tooltip/BaseTooltip";
import CredentialsProvider from "@/providers/agent-credentials/credentials-provider"; import CredentialsProvider from "@/providers/agent-credentials/credentials-provider";
import { SentryUserTracker } from "@/components/monitor/SentryUserTracker"; import OnboardingProvider from "@/providers/onboarding/onboarding-provider";
import { LaunchDarklyProvider } from "@/services/feature-flags/feature-flag-provider";
import { QueryClientProvider } from "@tanstack/react-query";
import { ThemeProvider, ThemeProviderProps } from "next-themes";
import { NuqsAdapter } from "nuqs/adapters/next/app";
export function Providers({ children, ...props }: ThemeProviderProps) { export function Providers({ children, ...props }: ThemeProviderProps) {
const queryClient = getQueryClient(); const queryClient = getQueryClient();
return ( return (
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<NuqsAdapter> <NuqsAdapter>
<NextThemesProvider {...props}> <BackendAPIProvider>
<BackendAPIProvider> <SentryUserTracker />
<SentryUserTracker /> <CredentialsProvider>
<CredentialsProvider> <LaunchDarklyProvider>
<LaunchDarklyProvider> <OnboardingProvider>
<OnboardingProvider> <ThemeProvider forcedTheme="light" {...props}>
<TooltipProvider>{children}</TooltipProvider> <TooltipProvider>{children}</TooltipProvider>
</OnboardingProvider> </ThemeProvider>
</LaunchDarklyProvider> </OnboardingProvider>
</CredentialsProvider> </LaunchDarklyProvider>
</BackendAPIProvider> </CredentialsProvider>
</NextThemesProvider> </BackendAPIProvider>
</NuqsAdapter> </NuqsAdapter>
</QueryClientProvider> </QueryClientProvider>
); );

View File

@@ -9,16 +9,20 @@ import ReactMarkdown from "react-markdown";
type Props = { type Props = {
description?: string; description?: string;
iconSize?: number;
}; };
export function InformationTooltip({ description }: Props) { export function InformationTooltip({ description, iconSize = 24 }: Props) {
if (!description) return null; if (!description) return null;
return ( return (
<TooltipProvider delayDuration={400}> <TooltipProvider delayDuration={400}>
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Info className="rounded-full p-1 hover:bg-slate-50" size={24} /> <Info
className="rounded-full p-1 hover:bg-slate-50"
size={iconSize}
/>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent> <TooltipContent>
<ReactMarkdown <ReactMarkdown

View File

@@ -0,0 +1,437 @@
import type { Meta, StoryObj } from "@storybook/nextjs";
import {
ScrollableTabs,
ScrollableTabsContent,
ScrollableTabsList,
ScrollableTabsTrigger,
} from "./ScrollableTabs";
const meta = {
title: "Molecules/ScrollableTabs",
component: ScrollableTabs,
parameters: {
layout: "fullscreen",
},
tags: ["autodocs"],
argTypes: {},
} satisfies Meta<typeof ScrollableTabs>;
export default meta;
type Story = StoryObj<typeof meta>;
function ScrollableTabsDemo() {
return (
<div className="flex flex-col gap-8 p-8">
<h2 className="text-2xl font-bold">ScrollableTabs Examples</h2>
<div className="space-y-6">
<div>
<h3 className="mb-4 text-lg font-semibold">
Short Content (Tabs Hidden)
</h3>
<div className="h-[300px] overflow-y-auto border border-zinc-200">
<ScrollableTabs defaultValue="tab1" className="h-full">
<ScrollableTabsList>
<ScrollableTabsTrigger value="tab1">
Account
</ScrollableTabsTrigger>
<ScrollableTabsTrigger value="tab2">
Password
</ScrollableTabsTrigger>
<ScrollableTabsTrigger value="tab3">
Settings
</ScrollableTabsTrigger>
</ScrollableTabsList>
<ScrollableTabsContent value="tab1">
<div className="p-4 text-sm">
Make changes to your account here. Click save when you&apos;re
done.
</div>
</ScrollableTabsContent>
<ScrollableTabsContent value="tab2">
<div className="p-4 text-sm">
Change your password here. After saving, you&apos;ll be logged
out.
</div>
</ScrollableTabsContent>
<ScrollableTabsContent value="tab3">
<div className="p-4 text-sm">
Update your preferences and settings here.
</div>
</ScrollableTabsContent>
</ScrollableTabs>
</div>
</div>
<div>
<h3 className="mb-4 text-lg font-semibold">
Long Content (Tabs Visible)
</h3>
<div className="h-[400px] overflow-y-auto border border-zinc-200">
<ScrollableTabs defaultValue="tab1" className="h-full">
<ScrollableTabsList>
<ScrollableTabsTrigger value="tab1">
Account
</ScrollableTabsTrigger>
<ScrollableTabsTrigger value="tab2">
Password
</ScrollableTabsTrigger>
<ScrollableTabsTrigger value="tab3">
Settings
</ScrollableTabsTrigger>
</ScrollableTabsList>
<ScrollableTabsContent value="tab1">
<div className="p-8 text-sm">
<h4 className="mb-4 text-lg font-semibold">
Account Settings
</h4>
<p className="mb-4">
Make changes to your account here. Click save when
you&apos;re done.
</p>
<p className="mb-4">
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed
do eiusmod tempor incididunt ut labore et dolore magna
aliqua. Ut enim ad minim veniam, quis nostrud exercitation
ullamco laboris.
</p>
<p className="mb-4">
Duis aute irure dolor in reprehenderit in voluptate velit
esse cillum dolore eu fugiat nulla pariatur. Excepteur sint
occaecat cupidatat non proident.
</p>
<p>
Sed ut perspiciatis unde omnis iste natus error sit
voluptatem accusantium doloremque laudantium, totam rem
aperiam.
</p>
</div>
</ScrollableTabsContent>
<ScrollableTabsContent value="tab2">
<div className="p-8 text-sm">
<h4 className="mb-4 text-lg font-semibold">
Password Settings
</h4>
<p className="mb-4">
Change your password here. After saving, you&apos;ll be
logged out.
</p>
<p className="mb-4">
At vero eos et accusamus et iusto odio dignissimos ducimus
qui blanditiis praesentium voluptatum deleniti atque
corrupti quos dolores et quas molestias excepturi sint
occaecati cupiditate.
</p>
<p className="mb-4">
Et harum quidem rerum facilis est et expedita distinctio.
Nam libero tempore, cum soluta nobis est eligendi optio
cumque nihil impedit quo minus.
</p>
<p>
Temporibus autem quibusdam et aut officiis debitis aut rerum
necessitatibus saepe eveniet ut et voluptates repudiandae
sint.
</p>
</div>
</ScrollableTabsContent>
<ScrollableTabsContent value="tab3">
<div className="p-8 text-sm">
<h4 className="mb-4 text-lg font-semibold">
General Settings
</h4>
<p className="mb-4">
Update your preferences and settings here.
</p>
<p className="mb-4">
Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut
odit aut fugit, sed quia consequuntur magni dolores eos qui
ratione voluptatem sequi nesciunt.
</p>
<p className="mb-4">
Neque porro quisquam est, qui dolorem ipsum quia dolor sit
amet, consectetur, adipisci velit, sed quia non numquam eius
modi tempora incidunt ut labore et dolore magnam aliquam
quaerat voluptatem.
</p>
<p>
Ut enim ad minima veniam, quis nostrum exercitationem ullam
corporis suscipit laboriosam, nisi ut aliquid ex ea commodi
consequatur.
</p>
</div>
</ScrollableTabsContent>
</ScrollableTabs>
</div>
</div>
<div>
<h3 className="mb-4 text-lg font-semibold">Many Tabs</h3>
<div className="h-[500px] overflow-y-auto border border-zinc-200">
<ScrollableTabs defaultValue="overview" className="h-full">
<ScrollableTabsList>
<ScrollableTabsTrigger value="overview">
Overview
</ScrollableTabsTrigger>
<ScrollableTabsTrigger value="analytics">
Analytics
</ScrollableTabsTrigger>
<ScrollableTabsTrigger value="reports">
Reports
</ScrollableTabsTrigger>
<ScrollableTabsTrigger value="notifications">
Notifications
</ScrollableTabsTrigger>
<ScrollableTabsTrigger value="integrations">
Integrations
</ScrollableTabsTrigger>
<ScrollableTabsTrigger value="billing">
Billing
</ScrollableTabsTrigger>
</ScrollableTabsList>
<ScrollableTabsContent value="overview">
<div className="p-8 text-sm">
<h4 className="mb-4 text-lg font-semibold">
Dashboard Overview
</h4>
<p className="mb-4">
Dashboard overview with key metrics and recent activity.
</p>
<p className="mb-4">
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed
do eiusmod tempor incididunt ut labore et dolore magna
aliqua.
</p>
<p>
Ut enim ad minim veniam, quis nostrud exercitation ullamco
laboris nisi ut aliquip ex ea commodo consequat.
</p>
</div>
</ScrollableTabsContent>
<ScrollableTabsContent value="analytics">
<div className="p-8 text-sm">
<h4 className="mb-4 text-lg font-semibold">Analytics</h4>
<p className="mb-4">
Detailed analytics and performance metrics.
</p>
<p className="mb-4">
Duis aute irure dolor in reprehenderit in voluptate velit
esse cillum dolore eu fugiat nulla pariatur.
</p>
<p>
Excepteur sint occaecat cupidatat non proident, sunt in
culpa qui officia deserunt mollit anim id est laborum.
</p>
</div>
</ScrollableTabsContent>
<ScrollableTabsContent value="reports">
<div className="p-8 text-sm">
<h4 className="mb-4 text-lg font-semibold">Reports</h4>
<p className="mb-4">
Generate and view reports for your account.
</p>
<p className="mb-4">
Sed ut perspiciatis unde omnis iste natus error sit
voluptatem accusantium doloremque laudantium.
</p>
<p>
Totam rem aperiam, eaque ipsa quae ab illo inventore
veritatis et quasi architecto beatae vitae dicta sunt
explicabo.
</p>
</div>
</ScrollableTabsContent>
<ScrollableTabsContent value="notifications">
<div className="p-8 text-sm">
<h4 className="mb-4 text-lg font-semibold">Notifications</h4>
<p className="mb-4">Manage your notification preferences.</p>
<p className="mb-4">
Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut
odit aut fugit.
</p>
<p>
Sed quia consequuntur magni dolores eos qui ratione
voluptatem sequi nesciunt.
</p>
</div>
</ScrollableTabsContent>
<ScrollableTabsContent value="integrations">
<div className="p-8 text-sm">
<h4 className="mb-4 text-lg font-semibold">Integrations</h4>
<p className="mb-4">
Connect and manage third-party integrations.
</p>
<p className="mb-4">
Neque porro quisquam est, qui dolorem ipsum quia dolor sit
amet.
</p>
<p>
Consectetur, adipisci velit, sed quia non numquam eius modi
tempora incidunt.
</p>
</div>
</ScrollableTabsContent>
<ScrollableTabsContent value="billing">
<div className="p-8 text-sm">
<h4 className="mb-4 text-lg font-semibold">Billing</h4>
<p className="mb-4">
View and manage your billing information.
</p>
<p className="mb-4">
Ut enim ad minima veniam, quis nostrum exercitationem ullam
corporis suscipit laboriosam.
</p>
<p>
Nisi ut aliquid ex ea commodi consequatur? Quis autem vel
eum iure reprehenderit qui in ea voluptate velit esse.
</p>
</div>
</ScrollableTabsContent>
</ScrollableTabs>
</div>
</div>
</div>
</div>
);
}
export const Default = {
render: () => <ScrollableTabsDemo />,
} satisfies Story;
export const ShortContent = {
render: () => (
<div className="p-8">
<div className="h-[200px] overflow-y-auto border border-zinc-200">
<ScrollableTabs defaultValue="account" className="h-full">
<ScrollableTabsList>
<ScrollableTabsTrigger value="account">
Account
</ScrollableTabsTrigger>
<ScrollableTabsTrigger value="password">
Password
</ScrollableTabsTrigger>
</ScrollableTabsList>
<ScrollableTabsContent value="account">
<div className="p-4 text-sm">
Make changes to your account here. Click save when you&apos;re
done.
</div>
</ScrollableTabsContent>
<ScrollableTabsContent value="password">
<div className="p-4 text-sm">
Change your password here. After saving, you&apos;ll be logged
out.
</div>
</ScrollableTabsContent>
</ScrollableTabs>
</div>
</div>
),
} satisfies Story;
export const LongContent = {
render: () => (
<div className="p-8">
<div className="h-[600px] overflow-y-auto border border-zinc-200">
<ScrollableTabs defaultValue="tab1" className="h-full">
<ScrollableTabsList>
<ScrollableTabsTrigger value="tab1">Account</ScrollableTabsTrigger>
<ScrollableTabsTrigger value="tab2">Password</ScrollableTabsTrigger>
<ScrollableTabsTrigger value="tab3">Settings</ScrollableTabsTrigger>
</ScrollableTabsList>
<ScrollableTabsContent value="tab1">
<div className="p-8 text-sm">
<h4 className="mb-4 text-lg font-semibold">Account Settings</h4>
<p className="mb-4">
Make changes to your account here. Click save when you&apos;re
done.
</p>
<p className="mb-4">
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do
eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut
enim ad minim veniam, quis nostrud exercitation ullamco laboris
nisi ut aliquip ex ea commodo consequat.
</p>
<p className="mb-4">
Duis aute irure dolor in reprehenderit in voluptate velit esse
cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat
cupidatat non proident, sunt in culpa qui officia deserunt
mollit anim id est laborum.
</p>
<p className="mb-4">
Sed ut perspiciatis unde omnis iste natus error sit voluptatem
accusantium doloremque laudantium, totam rem aperiam, eaque ipsa
quae ab illo inventore veritatis et quasi architecto beatae
vitae dicta sunt explicabo.
</p>
<p>
Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit
aut fugit, sed quia consequuntur magni dolores eos qui ratione
voluptatem sequi nesciunt.
</p>
</div>
</ScrollableTabsContent>
<ScrollableTabsContent value="tab2">
<div className="p-8 text-sm">
<h4 className="mb-4 text-lg font-semibold">Password Settings</h4>
<p className="mb-4">
Change your password here. After saving, you&apos;ll be logged
out.
</p>
<p className="mb-4">
At vero eos et accusamus et iusto odio dignissimos ducimus qui
blanditiis praesentium voluptatum deleniti atque corrupti quos
dolores et quas molestias excepturi sint occaecati cupiditate
non provident.
</p>
<p className="mb-4">
Similique sunt in culpa qui officia deserunt mollitia animi, id
est laborum et dolorum fuga. Et harum quidem rerum facilis est
et expedita distinctio.
</p>
<p className="mb-4">
Nam libero tempore, cum soluta nobis est eligendi optio cumque
nihil impedit quo minus id quod maxime placeat facere possimus,
omnis voluptas assumenda est, omnis dolor repellendus.
</p>
<p>
Temporibus autem quibusdam et aut officiis debitis aut rerum
necessitatibus saepe eveniet ut et voluptates repudiandae sint
et molestiae non recusandae.
</p>
</div>
</ScrollableTabsContent>
<ScrollableTabsContent value="tab3">
<div className="p-8 text-sm">
<h4 className="mb-4 text-lg font-semibold">General Settings</h4>
<p className="mb-4">Update your preferences and settings here.</p>
<p className="mb-4">
Neque porro quisquam est, qui dolorem ipsum quia dolor sit amet,
consectetur, adipisci velit, sed quia non numquam eius modi
tempora incidunt ut labore et dolore magnam aliquam quaerat
voluptatem.
</p>
<p className="mb-4">
Ut enim ad minima veniam, quis nostrum exercitationem ullam
corporis suscipit laboriosam, nisi ut aliquid ex ea commodi
consequatur? Quis autem vel eum iure reprehenderit qui in ea
voluptate velit esse quam nihil molestiae consequatur.
</p>
<p className="mb-4">
Vel illum qui dolorem eum fugiat quo voluptas nulla pariatur? At
vero eos et accusamus et iusto odio dignissimos ducimus qui
blanditiis praesentium voluptatum deleniti atque corrupti quos
dolores.
</p>
<p>
Et quas molestias excepturi sint occaecati cupiditate non
provident, similique sunt in culpa qui officia deserunt mollitia
animi, id est laborum et dolorum fuga.
</p>
</div>
</ScrollableTabsContent>
</ScrollableTabs>
</div>
</div>
),
} satisfies Story;

View File

@@ -0,0 +1,59 @@
"use client";
import { cn } from "@/lib/utils";
import { Children } from "react";
import { ScrollableTabsContent } from "./components/ScrollableTabsContent";
import { ScrollableTabsList } from "./components/ScrollableTabsList";
import { ScrollableTabsTrigger } from "./components/ScrollableTabsTrigger";
import { ScrollableTabsContext } from "./context";
import { findContentElements, findListElement } from "./helpers";
import { useScrollableTabsInternal } from "./useScrollableTabs";
interface Props {
children?: React.ReactNode;
className?: string;
defaultValue?: string;
}
export function ScrollableTabs({ children, className, defaultValue }: Props) {
const {
activeValue,
setActiveValue,
registerContent,
scrollToSection,
scrollContainer,
contentContainerRef,
} = useScrollableTabsInternal({ defaultValue });
const childrenArray = Children.toArray(children);
const listElement = findListElement(childrenArray);
const contentElements = findContentElements(childrenArray);
return (
<ScrollableTabsContext.Provider
value={{
activeValue,
setActiveValue,
registerContent,
scrollToSection,
scrollContainer,
}}
>
<div className={cn("relative flex flex-col", className)}>
{listElement}
<div
ref={(node) => {
if (contentContainerRef) {
contentContainerRef.current = node;
}
}}
className="max-h-[64rem] overflow-y-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-zinc-300 dark:scrollbar-thumb-zinc-700"
>
<div className="min-h-full pb-[200px]">{contentElements}</div>
</div>
</div>
</ScrollableTabsContext.Provider>
);
}
export { ScrollableTabsContent, ScrollableTabsList, ScrollableTabsTrigger };

View File

@@ -0,0 +1,48 @@
"use client";
import { cn } from "@/lib/utils";
import * as React from "react";
import { useScrollableTabs } from "../context";
interface Props extends React.HTMLAttributes<HTMLDivElement> {
value: string;
}
export const ScrollableTabsContent = React.forwardRef<HTMLDivElement, Props>(
function ScrollableTabsContent(
{ className, value, children, ...props },
ref,
) {
const { registerContent } = useScrollableTabs();
const contentRef = React.useRef<HTMLDivElement>(null);
React.useEffect(() => {
if (contentRef.current) {
registerContent(value, contentRef.current);
}
return () => {
registerContent(value, null);
};
}, [value, registerContent]);
return (
<div
ref={(node) => {
if (typeof ref === "function") ref(node);
else if (ref) ref.current = node;
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
contentRef.current = node;
}}
data-scrollable-tab-content
data-value={value}
className={cn("focus-visible:outline-none", className)}
{...props}
>
{children}
</div>
);
},
);
ScrollableTabsContent.displayName = "ScrollableTabsContent";

View File

@@ -0,0 +1,52 @@
"use client";
import { cn } from "@/lib/utils";
import * as React from "react";
import { useScrollableTabs } from "../context";
export const ScrollableTabsList = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(function ScrollableTabsList({ className, children, ...props }, ref) {
const { activeValue } = useScrollableTabs();
const [activeTabElement, setActiveTabElement] =
React.useState<HTMLElement | null>(null);
React.useEffect(() => {
const activeButton = Array.from(
document.querySelectorAll<HTMLElement>(
'[data-scrollable-tab-trigger][data-value="' + activeValue + '"]',
),
)[0];
if (activeButton) {
setActiveTabElement(activeButton);
}
}, [activeValue]);
return (
<div className="relative" ref={ref}>
<div
className={cn(
"inline-flex w-full items-center justify-start border-b border-zinc-100",
className,
)}
{...props}
>
{children}
</div>
{activeTabElement && (
<div
className="transition-left transition-right absolute bottom-0 h-0.5 bg-purple-600 duration-200 ease-in-out"
style={{
left: activeTabElement.offsetLeft,
width: activeTabElement.offsetWidth,
willChange: "left, width",
}}
/>
)}
</div>
);
});
ScrollableTabsList.displayName = "ScrollableTabsList";

View File

@@ -0,0 +1,53 @@
"use client";
import { cn } from "@/lib/utils";
import * as React from "react";
import { useScrollableTabs } from "../context";
interface Props extends React.ButtonHTMLAttributes<HTMLButtonElement> {
value: string;
}
export const ScrollableTabsTrigger = React.forwardRef<HTMLButtonElement, Props>(
function ScrollableTabsTrigger(
{ className, value, children, ...props },
ref,
) {
const { activeValue, scrollToSection } = useScrollableTabs();
const elementRef = React.useRef<HTMLButtonElement>(null);
const isActive = activeValue === value;
function handleClick(e: React.MouseEvent<HTMLButtonElement>) {
e.preventDefault();
e.stopPropagation();
scrollToSection(value);
props.onClick?.(e);
}
return (
<button
type="button"
ref={(node) => {
if (typeof ref === "function") ref(node);
else if (ref) ref.current = node;
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
elementRef.current = node;
}}
data-scrollable-tab-trigger
data-value={value}
onClick={handleClick}
className={cn(
"relative inline-flex items-center justify-center whitespace-nowrap px-3 py-3 font-sans text-[0.875rem] font-medium leading-[1.5rem] text-zinc-700 transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-neutral-400 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
isActive && "text-purple-600",
className,
)}
{...props}
>
{children}
</button>
);
},
);
ScrollableTabsTrigger.displayName = "ScrollableTabsTrigger";

View File

@@ -0,0 +1,22 @@
import * as React from "react";
import { createContext, useContext } from "react";
interface ScrollableTabsContextValue {
activeValue: string | null;
setActiveValue: React.Dispatch<React.SetStateAction<string | null>>;
registerContent: (value: string, element: HTMLElement | null) => void;
scrollToSection: (value: string) => void;
scrollContainer: HTMLElement | null;
}
export const ScrollableTabsContext = createContext<
ScrollableTabsContextValue | undefined
>(undefined);
export function useScrollableTabs() {
const context = useContext(ScrollableTabsContext);
if (!context) {
throw new Error("useScrollableTabs must be used within a ScrollableTabs");
}
return context;
}

View File

@@ -0,0 +1,48 @@
import * as React from "react";
const HEADER_OFFSET = 100;
export function calculateScrollPosition(
elementRect: DOMRect,
containerRect: DOMRect,
currentScrollTop: number,
): number {
const elementTopRelativeToContainer =
elementRect.top - containerRect.top + currentScrollTop - HEADER_OFFSET;
return Math.max(0, elementTopRelativeToContainer);
}
function hasDisplayName(
type: unknown,
displayName: string,
): type is { displayName: string } {
return (
typeof type === "object" &&
type !== null &&
"displayName" in type &&
(type as { displayName: unknown }).displayName === displayName
);
}
export function findListElement(
children: React.ReactNode[],
): React.ReactElement | undefined {
return children.find(
(child) =>
React.isValidElement(child) &&
hasDisplayName(child.type, "ScrollableTabsList"),
) as React.ReactElement | undefined;
}
export function findContentElements(
children: React.ReactNode[],
): React.ReactNode[] {
return children.filter(
(child) =>
!(
React.isValidElement(child) &&
hasDisplayName(child.type, "ScrollableTabsList")
),
);
}

View File

@@ -0,0 +1,60 @@
import { useCallback, useRef, useState } from "react";
import { calculateScrollPosition } from "./helpers";
interface Args {
defaultValue?: string;
}
export function useScrollableTabsInternal({ defaultValue }: Args) {
const [activeValue, setActiveValue] = useState<string | null>(
defaultValue || null,
);
const contentRefs = useRef<Map<string, HTMLElement>>(new Map());
const contentContainerRef = useRef<HTMLDivElement | null>(null);
function registerContent(value: string, element: HTMLElement | null) {
if (element) {
contentRefs.current.set(value, element);
} else {
contentRefs.current.delete(value);
}
}
function scrollToSection(value: string) {
const element = contentRefs.current.get(value);
const scrollContainer = contentContainerRef.current;
if (!element || !scrollContainer) return;
setActiveValue(value);
const containerRect = scrollContainer.getBoundingClientRect();
const elementRect = element.getBoundingClientRect();
const currentScrollTop = scrollContainer.scrollTop;
const scrollTop = calculateScrollPosition(
elementRect,
containerRect,
currentScrollTop,
);
const maxScrollTop =
scrollContainer.scrollHeight - scrollContainer.clientHeight;
const clampedScrollTop = Math.min(Math.max(0, scrollTop), maxScrollTop);
scrollContainer.scrollTo({
top: clampedScrollTop,
behavior: "smooth",
});
}
const memoizedRegisterContent = useCallback(registerContent, []);
const memoizedScrollToSection = useCallback(scrollToSection, []);
return {
activeValue,
setActiveValue,
registerContent: memoizedRegisterContent,
scrollToSection: memoizedScrollToSection,
scrollContainer: contentContainerRef.current,
contentContainerRef,
};
}

View File

@@ -48,7 +48,7 @@ const mockFlags = {
[Flag.AGENT_FAVORITING]: false, [Flag.AGENT_FAVORITING]: false,
[Flag.MARKETPLACE_SEARCH_TERMS]: DEFAULT_SEARCH_TERMS, [Flag.MARKETPLACE_SEARCH_TERMS]: DEFAULT_SEARCH_TERMS,
[Flag.ENABLE_PLATFORM_PAYMENT]: false, [Flag.ENABLE_PLATFORM_PAYMENT]: false,
[Flag.CHAT]: true, [Flag.CHAT]: false,
}; };
export function useGetFlag<T extends Flag>(flag: T): FlagValues[T] | null { export function useGetFlag<T extends Flag>(flag: T): FlagValues[T] | null {

View File

@@ -1,10 +1,10 @@
import scrollbar from "tailwind-scrollbar";
import type { Config } from "tailwindcss"; import type { Config } from "tailwindcss";
import tailwindcssAnimate from "tailwindcss-animate"; import tailwindcssAnimate from "tailwindcss-animate";
import scrollbar from "tailwind-scrollbar";
import { colors } from "./src/components/styles/colors"; import { colors } from "./src/components/styles/colors";
const config = { const config = {
darkMode: ["class"], darkMode: ["class", ".dark-mode"], // ignore dark: prefix classes for now until we fully support dark mode
content: ["./src/**/*.{ts,tsx}"], content: ["./src/**/*.{ts,tsx}"],
prefix: "", prefix: "",
theme: { theme: {