Compare commits

..

6 Commits

Author SHA1 Message Date
Ubbe
330f1e84e1 Merge branch 'dev' into fix/copilot-table-design 2026-02-12 23:39:12 +08:00
Swifty
b8b6c9de23 added feature request tooling 2026-02-12 16:38:17 +01:00
Ubbe
6b8001781f Refactor: suggestion
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-02-12 22:50:57 +08:00
Lluis Agusti
cb2f48451b fix(frontend): improve CoPilot chat table styling
Remove left/right borders and increase cell padding for tables rendered
in CoPilot chat, per design feedback from Olivia.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 22:32:57 +08:00
Abhimanyu Yadav
4f6055f494 refactor(frontend): remove default expiration date from API key credentials form (#12092)
### Changes 🏗️

Removed the default expiration date for API keys in the credentials
modal. Previously, API keys were set to expire the next day by default,
but now the expiration date field starts empty, allowing users to
explicitly choose whether they want to set an expiration date.

### 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] Open the API key credentials modal and verify the expiration date
field is empty by default
  - [x] Test creating an API key with and without an expiration date
  - [x] Verify both scenarios work correctly

<!-- greptile_comment -->

<h2>Greptile Overview</h2>

<details><summary><h3>Greptile Summary</h3></summary>

Removed the default expiration date for API key credentials in the
credentials modal. Previously, API keys were automatically set to expire
the next day at midnight. Now the expiration date field starts empty,
allowing users to explicitly choose whether to set an expiration.

- Removed `getDefaultExpirationDate()` helper function that calculated
tomorrow's date
- Changed default `expiresAt` value from calculated date to empty string
- Backend already supports optional expiration (`expires_at?: number`),
so no backend changes needed
- Form submission correctly handles empty expiration by passing
`undefined` to the API
</details>


<details><summary><h3>Confidence Score: 5/5</h3></summary>

- This PR is safe to merge with minimal risk
- The changes are straightforward and well-contained. The refactor
removes a helper function and changes a default value. The backend API
already supports optional expiration dates, and the form submission
logic correctly handles empty values by passing undefined. The change
improves UX by not forcing a default expiration date on users.
- No files require special attention
</details>


<!-- greptile_other_comments_section -->

<!-- /greptile_comment -->
2026-02-12 12:57:06 +00:00
Otto
695a185fa1 fix(frontend): remove fixed min-height from CoPilot message container (#12091)
## Summary

Removes the `min-h-screen` class from `ConversationContent` in
ChatMessagesContainer, which was causing fixed height layout issues in
the CoPilot chat interface.

## Changes

- Removed `min-h-screen` from ConversationContent className

## Linear

Fixes [SECRT-1944](https://linear.app/autogpt/issue/SECRT-1944)

<!-- greptile_comment -->

<h2>Greptile Overview</h2>

<details><summary><h3>Greptile Summary</h3></summary>

Removes the `min-h-screen` (100vh) class from `ConversationContent` that
was causing the chat message container to enforce a minimum viewport
height. The parent container already handles height constraints with
`h-full min-h-0` and flexbox layout, so the fixed minimum height was
creating layout conflicts. The component now properly grows within its
flex container using `flex-1`.
</details>


<details><summary><h3>Confidence Score: 5/5</h3></summary>

- This PR is safe to merge with minimal risk
- The change removes a single problematic CSS class that was causing
fixed height layout issues. The parent container already handles height
constraints properly with flexbox, and removing min-h-screen allows the
component to size correctly within its flex parent. This is a targeted,
low-risk bug fix with no logic changes.
- No files require special attention
</details>


<!-- greptile_other_comments_section -->

<!-- /greptile_comment -->
2026-02-12 12:46:29 +00:00
15 changed files with 1691 additions and 38 deletions

View File

@@ -12,6 +12,7 @@ from .base import BaseTool
from .create_agent import CreateAgentTool
from .customize_agent import CustomizeAgentTool
from .edit_agent import EditAgentTool
from .feature_requests import CreateFeatureRequestTool, SearchFeatureRequestsTool
from .find_agent import FindAgentTool
from .find_block import FindBlockTool
from .find_library_agent import FindLibraryAgentTool
@@ -45,6 +46,9 @@ TOOL_REGISTRY: dict[str, BaseTool] = {
"view_agent_output": AgentOutputTool(),
"search_docs": SearchDocsTool(),
"get_doc_page": GetDocPageTool(),
# Feature request tools
"search_feature_requests": SearchFeatureRequestsTool(),
"create_feature_request": CreateFeatureRequestTool(),
# Workspace tools for CoPilot file operations
"list_workspace_files": ListWorkspaceFilesTool(),
"read_workspace_file": ReadWorkspaceFileTool(),

View File

@@ -0,0 +1,369 @@
"""Feature request tools - search and create feature requests via Linear."""
import logging
from typing import Any
from pydantic import SecretStr
from backend.api.features.chat.model import ChatSession
from backend.api.features.chat.tools.base import BaseTool
from backend.api.features.chat.tools.models import (
ErrorResponse,
FeatureRequestCreatedResponse,
FeatureRequestInfo,
FeatureRequestSearchResponse,
NoResultsResponse,
ToolResponseBase,
)
from backend.blocks.linear._api import LinearClient
from backend.data.model import APIKeyCredentials
from backend.util.settings import Settings
logger = logging.getLogger(__name__)
# Target project and team IDs in our Linear workspace
FEATURE_REQUEST_PROJECT_ID = "13f066f3-f639-4a67-aaa3-31483ebdf8cd"
TEAM_ID = "557fd3d5-087e-43a9-83e3-476c8313ce49"
MAX_SEARCH_RESULTS = 10
# GraphQL queries/mutations
SEARCH_ISSUES_QUERY = """
query SearchFeatureRequests($term: String!, $filter: IssueFilter, $first: Int) {
searchIssues(term: $term, filter: $filter, first: $first) {
nodes {
id
identifier
title
description
}
}
}
"""
CUSTOMER_UPSERT_MUTATION = """
mutation CustomerUpsert($input: CustomerUpsertInput!) {
customerUpsert(input: $input) {
success
customer {
id
name
externalIds
}
}
}
"""
ISSUE_CREATE_MUTATION = """
mutation IssueCreate($input: IssueCreateInput!) {
issueCreate(input: $input) {
success
issue {
id
identifier
title
url
}
}
}
"""
CUSTOMER_NEED_CREATE_MUTATION = """
mutation CustomerNeedCreate($input: CustomerNeedCreateInput!) {
customerNeedCreate(input: $input) {
success
need {
id
body
customer {
id
name
}
issue {
id
identifier
title
url
}
}
}
}
"""
_settings: Settings | None = None
def _get_settings() -> Settings:
global _settings
if _settings is None:
_settings = Settings()
return _settings
def _get_linear_client() -> LinearClient:
"""Create a Linear client using the system API key from settings."""
api_key = _get_settings().secrets.linear_api_key
if not api_key:
raise RuntimeError("LINEAR_API_KEY secret is not configured")
credentials = APIKeyCredentials(
id="system-linear",
provider="linear",
api_key=SecretStr(api_key),
title="System Linear API Key",
)
return LinearClient(credentials=credentials)
class SearchFeatureRequestsTool(BaseTool):
"""Tool for searching existing feature requests in Linear."""
@property
def name(self) -> str:
return "search_feature_requests"
@property
def description(self) -> str:
return (
"Search existing feature requests to check if a similar request "
"already exists before creating a new one. Returns matching feature "
"requests with their ID, title, and description."
)
@property
def parameters(self) -> dict[str, Any]:
return {
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "Search term to find matching feature requests.",
},
},
"required": ["query"],
}
@property
def requires_auth(self) -> bool:
return True
async def _execute(
self,
user_id: str | None,
session: ChatSession,
**kwargs,
) -> ToolResponseBase:
query = kwargs.get("query", "").strip()
session_id = session.session_id if session else None
if not query:
return ErrorResponse(
message="Please provide a search query.",
error="Missing query parameter",
session_id=session_id,
)
client = _get_linear_client()
data = await client.query(
SEARCH_ISSUES_QUERY,
{
"term": query,
"filter": {
"project": {"id": {"eq": FEATURE_REQUEST_PROJECT_ID}},
},
"first": MAX_SEARCH_RESULTS,
},
)
nodes = data.get("searchIssues", {}).get("nodes", [])
if not nodes:
return NoResultsResponse(
message=f"No feature requests found matching '{query}'.",
suggestions=[
"Try different keywords",
"Use broader search terms",
"You can create a new feature request if none exists",
],
session_id=session_id,
)
results = [
FeatureRequestInfo(
id=node["id"],
identifier=node["identifier"],
title=node["title"],
description=node.get("description"),
)
for node in nodes
]
return FeatureRequestSearchResponse(
message=f"Found {len(results)} feature request(s) matching '{query}'.",
results=results,
count=len(results),
query=query,
session_id=session_id,
)
class CreateFeatureRequestTool(BaseTool):
"""Tool for creating feature requests (or adding needs to existing ones)."""
@property
def name(self) -> str:
return "create_feature_request"
@property
def description(self) -> str:
return (
"Create a new feature request or add a customer need to an existing one. "
"Always search first with search_feature_requests to avoid duplicates. "
"If a matching request exists, pass its ID as existing_issue_id to add "
"the user's need to it instead of creating a duplicate."
)
@property
def parameters(self) -> dict[str, Any]:
return {
"type": "object",
"properties": {
"title": {
"type": "string",
"description": "Title for the feature request.",
},
"description": {
"type": "string",
"description": "Detailed description of what the user wants and why.",
},
"existing_issue_id": {
"type": "string",
"description": (
"If adding a need to an existing feature request, "
"provide its Linear issue ID (from search results). "
"Omit to create a new feature request."
),
},
},
"required": ["title", "description"],
}
@property
def requires_auth(self) -> bool:
return True
async def _find_or_create_customer(
self, client: LinearClient, user_id: str
) -> dict:
"""Find existing customer by user_id or create a new one via upsert."""
data = await client.mutate(
CUSTOMER_UPSERT_MUTATION,
{
"input": {
"name": user_id,
"externalId": user_id,
},
},
)
result = data.get("customerUpsert", {})
if not result.get("success"):
raise RuntimeError(f"Failed to upsert customer: {data}")
return result["customer"]
async def _execute(
self,
user_id: str | None,
session: ChatSession,
**kwargs,
) -> ToolResponseBase:
title = kwargs.get("title", "").strip()
description = kwargs.get("description", "").strip()
existing_issue_id = kwargs.get("existing_issue_id")
session_id = session.session_id if session else None
if not title or not description:
return ErrorResponse(
message="Both title and description are required.",
error="Missing required parameters",
session_id=session_id,
)
if not user_id:
return ErrorResponse(
message="Authentication required to create feature requests.",
error="Missing user_id",
session_id=session_id,
)
client = _get_linear_client()
# Step 1: Find or create customer for this user
customer = await self._find_or_create_customer(client, user_id)
customer_id = customer["id"]
customer_name = customer["name"]
# Step 2: Create or reuse issue
if existing_issue_id:
# Add need to existing issue - we still need the issue details for response
is_new_issue = False
issue_id = existing_issue_id
else:
# Create new issue in the feature requests project
data = await client.mutate(
ISSUE_CREATE_MUTATION,
{
"input": {
"title": title,
"description": description,
"teamId": TEAM_ID,
"projectId": FEATURE_REQUEST_PROJECT_ID,
},
},
)
result = data.get("issueCreate", {})
if not result.get("success"):
return ErrorResponse(
message="Failed to create feature request issue.",
error=str(data),
session_id=session_id,
)
issue = result["issue"]
issue_id = issue["id"]
is_new_issue = True
# Step 3: Create customer need on the issue
data = await client.mutate(
CUSTOMER_NEED_CREATE_MUTATION,
{
"input": {
"customerId": customer_id,
"issueId": issue_id,
"body": description,
"priority": 0,
},
},
)
need_result = data.get("customerNeedCreate", {})
if not need_result.get("success"):
return ErrorResponse(
message="Failed to attach customer need to the feature request.",
error=str(data),
session_id=session_id,
)
need = need_result["need"]
issue_info = need["issue"]
return FeatureRequestCreatedResponse(
message=(
f"{'Created new feature request' if is_new_issue else 'Added your request to existing feature request'} "
f"[{issue_info['identifier']}] {issue_info['title']}."
),
issue_id=issue_info["id"],
issue_identifier=issue_info["identifier"],
issue_title=issue_info["title"],
issue_url=issue_info.get("url", ""),
is_new_issue=is_new_issue,
customer_name=customer_name,
session_id=session_id,
)

View File

@@ -40,6 +40,9 @@ class ResponseType(str, Enum):
OPERATION_IN_PROGRESS = "operation_in_progress"
# Input validation
INPUT_VALIDATION_ERROR = "input_validation_error"
# Feature request types
FEATURE_REQUEST_SEARCH = "feature_request_search"
FEATURE_REQUEST_CREATED = "feature_request_created"
# Base response model
@@ -421,3 +424,34 @@ class AsyncProcessingResponse(ToolResponseBase):
status: str = "accepted" # Must be "accepted" for detection
operation_id: str | None = None
task_id: str | None = None
# Feature request models
class FeatureRequestInfo(BaseModel):
"""Information about a feature request issue."""
id: str
identifier: str
title: str
description: str | None = None
class FeatureRequestSearchResponse(ToolResponseBase):
"""Response for search_feature_requests tool."""
type: ResponseType = ResponseType.FEATURE_REQUEST_SEARCH
results: list[FeatureRequestInfo]
count: int
query: str
class FeatureRequestCreatedResponse(ToolResponseBase):
"""Response for create_feature_request tool."""
type: ResponseType = ResponseType.FEATURE_REQUEST_CREATED
issue_id: str
issue_identifier: str
issue_title: str
issue_url: str
is_new_issue: bool # False if added to existing
customer_name: str

View File

@@ -658,6 +658,9 @@ class Secrets(UpdateTrackingModel["Secrets"], BaseSettings):
mem0_api_key: str = Field(default="", description="Mem0 API key")
elevenlabs_api_key: str = Field(default="", description="ElevenLabs API key")
linear_api_key: str = Field(
default="", description="Linear API key for system-level operations"
)
linear_client_id: str = Field(default="", description="Linear client ID")
linear_client_secret: str = Field(default="", description="Linear client secret")

View File

@@ -0,0 +1,468 @@
"""
Test script for Linear GraphQL API - Customer Requests operations.
Tests the exact GraphQL calls needed for:
1. search_feature_requests - search issues in the Customer Feature Requests project
2. add_feature_request - upsert customer + create customer need on issue
Requires LINEAR_API_KEY in backend/.env
Generate one at: https://linear.app/settings/api
"""
import json
import os
import sys
import httpx
from dotenv import load_dotenv
load_dotenv()
LINEAR_API_URL = "https://api.linear.app/graphql"
API_KEY = os.getenv("LINEAR_API_KEY")
# Target project for feature requests
FEATURE_REQUEST_PROJECT_ID = "13f066f3-f639-4a67-aaa3-31483ebdf8cd"
# Team: Internal
TEAM_ID = "557fd3d5-087e-43a9-83e3-476c8313ce49"
if not API_KEY:
print("ERROR: LINEAR_API_KEY not found in .env")
print("Generate a personal API key at: https://linear.app/settings/api")
print("Then add LINEAR_API_KEY=lin_api_... to backend/.env")
sys.exit(1)
HEADERS = {
"Authorization": API_KEY,
"Content-Type": "application/json",
}
def graphql(query: str, variables: dict | None = None) -> dict:
"""Execute a GraphQL query against Linear API."""
payload = {"query": query}
if variables:
payload["variables"] = variables
resp = httpx.post(LINEAR_API_URL, json=payload, headers=HEADERS, timeout=30)
if resp.status_code != 200:
print(f"HTTP {resp.status_code}: {resp.text[:500]}")
resp.raise_for_status()
data = resp.json()
if "errors" in data:
print(f"GraphQL Errors: {json.dumps(data['errors'], indent=2)}")
return data
# ---------------------------------------------------------------------------
# QUERIES
# ---------------------------------------------------------------------------
# Search issues within the feature requests project by title/description
SEARCH_ISSUES_IN_PROJECT = """
query SearchFeatureRequests($filter: IssueFilter!, $first: Int) {
issues(filter: $filter, first: $first) {
nodes {
id
identifier
title
description
url
state {
name
type
}
project {
id
name
}
labels {
nodes {
name
}
}
}
}
}
"""
# Get issue with its customer needs
GET_ISSUE_WITH_NEEDS = """
query GetIssueWithNeeds($id: String!) {
issue(id: $id) {
id
identifier
title
url
needs {
nodes {
id
body
priority
customer {
id
name
domains
externalIds
}
}
}
}
}
"""
# Search customers
SEARCH_CUSTOMERS = """
query SearchCustomers($filter: CustomerFilter, $first: Int) {
customers(filter: $filter, first: $first) {
nodes {
id
name
domains
externalIds
revenue
size
status {
name
}
tier {
name
}
}
}
}
"""
# ---------------------------------------------------------------------------
# MUTATIONS
# ---------------------------------------------------------------------------
CUSTOMER_UPSERT = """
mutation CustomerUpsert($input: CustomerUpsertInput!) {
customerUpsert(input: $input) {
success
customer {
id
name
domains
externalIds
}
}
}
"""
CUSTOMER_NEED_CREATE = """
mutation CustomerNeedCreate($input: CustomerNeedCreateInput!) {
customerNeedCreate(input: $input) {
success
need {
id
body
priority
customer {
id
name
}
issue {
id
identifier
title
}
}
}
}
"""
ISSUE_CREATE = """
mutation IssueCreate($input: IssueCreateInput!) {
issueCreate(input: $input) {
success
issue {
id
identifier
title
url
}
}
}
"""
# ---------------------------------------------------------------------------
# TESTS
# ---------------------------------------------------------------------------
def test_1_search_feature_requests():
"""Search for feature requests in the target project by keyword."""
print("\n" + "=" * 60)
print("TEST 1: Search feature requests in project by keyword")
print("=" * 60)
search_term = "agent"
result = graphql(
SEARCH_ISSUES_IN_PROJECT,
{
"filter": {
"project": {"id": {"eq": FEATURE_REQUEST_PROJECT_ID}},
"or": [
{"title": {"containsIgnoreCase": search_term}},
{"description": {"containsIgnoreCase": search_term}},
],
},
"first": 5,
},
)
issues = result.get("data", {}).get("issues", {}).get("nodes", [])
for issue in issues:
proj = issue.get("project") or {}
print(f"\n [{issue['identifier']}] {issue['title']}")
print(f" Project: {proj.get('name', 'N/A')}")
print(f" State: {issue['state']['name']}")
print(f" URL: {issue['url']}")
print(f"\n Found {len(issues)} issues matching '{search_term}'")
return issues
def test_2_list_all_in_project():
"""List all issues in the feature requests project."""
print("\n" + "=" * 60)
print("TEST 2: List all issues in Customer Feature Requests project")
print("=" * 60)
result = graphql(
SEARCH_ISSUES_IN_PROJECT,
{
"filter": {
"project": {"id": {"eq": FEATURE_REQUEST_PROJECT_ID}},
},
"first": 10,
},
)
issues = result.get("data", {}).get("issues", {}).get("nodes", [])
if not issues:
print(" No issues in project yet (empty project)")
for issue in issues:
print(f"\n [{issue['identifier']}] {issue['title']}")
print(f" State: {issue['state']['name']}")
print(f"\n Total: {len(issues)} issues")
return issues
def test_3_search_customers():
"""List existing customers."""
print("\n" + "=" * 60)
print("TEST 3: List customers")
print("=" * 60)
result = graphql(SEARCH_CUSTOMERS, {"first": 10})
customers = result.get("data", {}).get("customers", {}).get("nodes", [])
if not customers:
print(" No customers exist yet")
for c in customers:
status = c.get("status") or {}
tier = c.get("tier") or {}
print(f"\n [{c['id'][:8]}...] {c['name']}")
print(f" Domains: {c.get('domains', [])}")
print(f" External IDs: {c.get('externalIds', [])}")
print(
f" Status: {status.get('name', 'N/A')}, Tier: {tier.get('name', 'N/A')}"
)
print(f"\n Total: {len(customers)} customers")
return customers
def test_4_customer_upsert():
"""Upsert a test customer."""
print("\n" + "=" * 60)
print("TEST 4: Customer upsert (find-or-create)")
print("=" * 60)
result = graphql(
CUSTOMER_UPSERT,
{
"input": {
"name": "Test Customer (API Test)",
"domains": ["test-api-customer.example.com"],
"externalId": "test-customer-001",
}
},
)
upsert = result.get("data", {}).get("customerUpsert", {})
if upsert.get("success"):
customer = upsert["customer"]
print(f" Success! Customer: {customer['name']}")
print(f" ID: {customer['id']}")
print(f" Domains: {customer['domains']}")
print(f" External IDs: {customer['externalIds']}")
return customer
else:
print(f" Failed: {json.dumps(result, indent=2)}")
return None
def test_5_create_issue_and_need(customer_id: str):
"""Create a new feature request issue and attach a customer need."""
print("\n" + "=" * 60)
print("TEST 5: Create issue + customer need")
print("=" * 60)
# Step 1: Create issue in the project
result = graphql(
ISSUE_CREATE,
{
"input": {
"title": "Test Feature Request (API Test - safe to delete)",
"description": "This is a test feature request created via the GraphQL API.",
"teamId": TEAM_ID,
"projectId": FEATURE_REQUEST_PROJECT_ID,
}
},
)
data = result.get("data")
if not data:
print(f" Issue creation failed: {json.dumps(result, indent=2)}")
return None
issue_data = data.get("issueCreate", {})
if not issue_data.get("success"):
print(f" Issue creation failed: {json.dumps(result, indent=2)}")
return None
issue = issue_data["issue"]
print(f" Created issue: [{issue['identifier']}] {issue['title']}")
print(f" URL: {issue['url']}")
# Step 2: Attach customer need
result = graphql(
CUSTOMER_NEED_CREATE,
{
"input": {
"customerId": customer_id,
"issueId": issue["id"],
"body": "Our team really needs this feature for our workflow. High priority for us!",
"priority": 0,
}
},
)
need_data = result.get("data", {}).get("customerNeedCreate", {})
if need_data.get("success"):
need = need_data["need"]
print(f" Attached customer need: {need['id']}")
print(f" Customer: {need['customer']['name']}")
print(f" Body: {need['body'][:80]}")
else:
print(f" Customer need creation failed: {json.dumps(result, indent=2)}")
# Step 3: Verify by fetching the issue with needs
print("\n Verifying...")
verify = graphql(GET_ISSUE_WITH_NEEDS, {"id": issue["id"]})
issue_verify = verify.get("data", {}).get("issue", {})
needs = issue_verify.get("needs", {}).get("nodes", [])
print(f" Issue now has {len(needs)} customer need(s)")
for n in needs:
cust = n.get("customer") or {}
print(f" - {cust.get('name', 'N/A')}: {n.get('body', '')[:60]}")
return issue
def test_6_add_need_to_existing(customer_id: str, issue_id: str):
"""Add a customer need to an existing issue (the common case)."""
print("\n" + "=" * 60)
print("TEST 6: Add customer need to existing issue")
print("=" * 60)
result = graphql(
CUSTOMER_NEED_CREATE,
{
"input": {
"customerId": customer_id,
"issueId": issue_id,
"body": "We also want this! +1 from our organization.",
"priority": 0,
}
},
)
need_data = result.get("data", {}).get("customerNeedCreate", {})
if need_data.get("success"):
need = need_data["need"]
print(f" Success! Need: {need['id']}")
print(f" Customer: {need['customer']['name']}")
print(f" Issue: [{need['issue']['identifier']}] {need['issue']['title']}")
return need
else:
print(f" Failed: {json.dumps(result, indent=2)}")
return None
def main():
print("Linear GraphQL API - Customer Requests Test Suite")
print("=" * 60)
print(f"API URL: {LINEAR_API_URL}")
print(f"API Key: {API_KEY[:10]}...")
print(f"Project: Customer Feature Requests ({FEATURE_REQUEST_PROJECT_ID[:8]}...)")
# --- Read-only tests ---
test_1_search_feature_requests()
test_2_list_all_in_project()
test_3_search_customers()
# --- Write tests ---
print("\n" + "=" * 60)
answer = (
input("Run WRITE tests? (creates test customer + issue + need) [y/N]: ")
.strip()
.lower()
)
if answer != "y":
print("Skipped write tests.")
print("\nDone!")
return
customer = test_4_customer_upsert()
if not customer:
print("Customer upsert failed, stopping.")
return
issue = test_5_create_issue_and_need(customer["id"])
if not issue:
print("Issue creation failed, stopping.")
return
# Test adding a second need to the same issue (simulates another customer requesting same feature)
# First upsert a second customer
result = graphql(
CUSTOMER_UPSERT,
{
"input": {
"name": "Second Test Customer",
"domains": ["second-test.example.com"],
"externalId": "test-customer-002",
}
},
)
customer2 = result.get("data", {}).get("customerUpsert", {}).get("customer")
if customer2:
test_6_add_need_to_existing(customer2["id"], issue["id"])
print("\n" + "=" * 60)
print("All tests complete!")
print(
"Check the project: https://linear.app/autogpt/project/customer-feature-requests-710dcbf8bf4e/issues"
)
if __name__ == "__main__":
main()

View File

@@ -22,11 +22,6 @@ Sentry.init({
enabled: shouldEnable,
// Suppress cross-origin stylesheet errors from Sentry Replay (rrweb)
// serializing DOM snapshots with cross-origin stylesheets
// (e.g., from browser extensions or CDN-loaded CSS)
ignoreErrors: [/Not allowed to access cross-origin stylesheet/],
// Add optional integrations for additional features
integrations: [
Sentry.captureConsoleIntegration(),

View File

@@ -15,6 +15,10 @@ import { ToolUIPart, UIDataTypes, UIMessage, UITools } from "ai";
import { useEffect, useRef, useState } from "react";
import { CreateAgentTool } from "../../tools/CreateAgent/CreateAgent";
import { EditAgentTool } from "../../tools/EditAgent/EditAgent";
import {
CreateFeatureRequestTool,
SearchFeatureRequestsTool,
} from "../../tools/FeatureRequests/FeatureRequests";
import { FindAgentsTool } from "../../tools/FindAgents/FindAgents";
import { FindBlocksTool } from "../../tools/FindBlocks/FindBlocks";
import { RunAgentTool } from "../../tools/RunAgent/RunAgent";
@@ -159,7 +163,7 @@ export const ChatMessagesContainer = ({
return (
<Conversation className="min-h-0 flex-1">
<ConversationContent className="flex min-h-screen flex-1 flex-col gap-6 px-3 py-6">
<ConversationContent className="flex flex-1 flex-col gap-6 px-3 py-6">
{isLoading && messages.length === 0 && (
<div className="flex min-h-full flex-1 items-center justify-center">
<LoadingSpinner className="text-neutral-600" />
@@ -254,6 +258,20 @@ export const ChatMessagesContainer = ({
part={part as ToolUIPart}
/>
);
case "tool-search_feature_requests":
return (
<SearchFeatureRequestsTool
key={`${message.id}-${i}`}
part={part as ToolUIPart}
/>
);
case "tool-create_feature_request":
return (
<CreateFeatureRequestTool
key={`${message.id}-${i}`}
part={part as ToolUIPart}
/>
);
default:
return null;
}

View File

@@ -14,6 +14,10 @@ import { Text } from "@/components/atoms/Text/Text";
import { CopilotChatActionsProvider } from "../components/CopilotChatActionsProvider/CopilotChatActionsProvider";
import { CreateAgentTool } from "../tools/CreateAgent/CreateAgent";
import { EditAgentTool } from "../tools/EditAgent/EditAgent";
import {
CreateFeatureRequestTool,
SearchFeatureRequestsTool,
} from "../tools/FeatureRequests/FeatureRequests";
import { FindAgentsTool } from "../tools/FindAgents/FindAgents";
import { FindBlocksTool } from "../tools/FindBlocks/FindBlocks";
import { RunAgentTool } from "../tools/RunAgent/RunAgent";
@@ -45,6 +49,8 @@ const SECTIONS = [
"Tool: Create Agent",
"Tool: Edit Agent",
"Tool: View Agent Output",
"Tool: Search Feature Requests",
"Tool: Create Feature Request",
"Full Conversation Example",
] as const;
@@ -1421,6 +1427,235 @@ export default function StyleguidePage() {
</SubSection>
</Section>
{/* ============================================================= */}
{/* SEARCH FEATURE REQUESTS */}
{/* ============================================================= */}
<Section title="Tool: Search Feature Requests">
<SubSection label="Input streaming">
<SearchFeatureRequestsTool
part={{
type: "tool-search_feature_requests",
toolCallId: uid(),
state: "input-streaming",
input: { query: "dark mode" },
}}
/>
</SubSection>
<SubSection label="Input available">
<SearchFeatureRequestsTool
part={{
type: "tool-search_feature_requests",
toolCallId: uid(),
state: "input-available",
input: { query: "dark mode" },
}}
/>
</SubSection>
<SubSection label="Output available (with results)">
<SearchFeatureRequestsTool
part={{
type: "tool-search_feature_requests",
toolCallId: uid(),
state: "output-available",
input: { query: "dark mode" },
output: {
type: "feature_request_search",
message:
'Found 2 feature request(s) matching "dark mode".',
query: "dark mode",
count: 2,
results: [
{
id: "fr-001",
identifier: "INT-42",
title: "Add dark mode to the platform",
description:
"Users have requested a dark mode option for the builder and copilot interfaces to reduce eye strain during long sessions.",
},
{
id: "fr-002",
identifier: "INT-87",
title: "Dark theme for agent output viewer",
description:
"Specifically requesting dark theme support for the agent output/execution viewer panel.",
},
],
},
}}
/>
</SubSection>
<SubSection label="Output available (no results)">
<SearchFeatureRequestsTool
part={{
type: "tool-search_feature_requests",
toolCallId: uid(),
state: "output-available",
input: { query: "teleportation" },
output: {
type: "no_results",
message:
"No feature requests found matching 'teleportation'.",
suggestions: [
"Try different keywords",
"Use broader search terms",
"You can create a new feature request if none exists",
],
},
}}
/>
</SubSection>
<SubSection label="Output available (error)">
<SearchFeatureRequestsTool
part={{
type: "tool-search_feature_requests",
toolCallId: uid(),
state: "output-available",
input: { query: "dark mode" },
output: {
type: "error",
message: "Failed to search feature requests.",
error: "LINEAR_API_KEY environment variable is not set",
},
}}
/>
</SubSection>
<SubSection label="Output error">
<SearchFeatureRequestsTool
part={{
type: "tool-search_feature_requests",
toolCallId: uid(),
state: "output-error",
input: { query: "dark mode" },
}}
/>
</SubSection>
</Section>
{/* ============================================================= */}
{/* CREATE FEATURE REQUEST */}
{/* ============================================================= */}
<Section title="Tool: Create Feature Request">
<SubSection label="Input streaming">
<CreateFeatureRequestTool
part={{
type: "tool-create_feature_request",
toolCallId: uid(),
state: "input-streaming",
input: {
title: "Add dark mode",
description: "I would love dark mode for the platform.",
},
}}
/>
</SubSection>
<SubSection label="Input available">
<CreateFeatureRequestTool
part={{
type: "tool-create_feature_request",
toolCallId: uid(),
state: "input-available",
input: {
title: "Add dark mode",
description: "I would love dark mode for the platform.",
},
}}
/>
</SubSection>
<SubSection label="Output available (new issue created)">
<CreateFeatureRequestTool
part={{
type: "tool-create_feature_request",
toolCallId: uid(),
state: "output-available",
input: {
title: "Add dark mode",
description: "I would love dark mode for the platform.",
},
output: {
type: "feature_request_created",
message:
"Created new feature request [INT-105] Add dark mode.",
issue_id: "issue-new-123",
issue_identifier: "INT-105",
issue_title: "Add dark mode",
issue_url:
"https://linear.app/autogpt/issue/INT-105/add-dark-mode",
is_new_issue: true,
customer_name: "user-abc-123",
},
}}
/>
</SubSection>
<SubSection label="Output available (added to existing issue)">
<CreateFeatureRequestTool
part={{
type: "tool-create_feature_request",
toolCallId: uid(),
state: "output-available",
input: {
title: "Dark mode support",
description:
"Please add dark mode, it would help with long sessions.",
existing_issue_id: "fr-001",
},
output: {
type: "feature_request_created",
message:
"Added your request to existing feature request [INT-42] Add dark mode to the platform.",
issue_id: "fr-001",
issue_identifier: "INT-42",
issue_title: "Add dark mode to the platform",
issue_url:
"https://linear.app/autogpt/issue/INT-42/add-dark-mode-to-the-platform",
is_new_issue: false,
customer_name: "user-xyz-789",
},
}}
/>
</SubSection>
<SubSection label="Output available (error)">
<CreateFeatureRequestTool
part={{
type: "tool-create_feature_request",
toolCallId: uid(),
state: "output-available",
input: {
title: "Add dark mode",
description: "I would love dark mode.",
},
output: {
type: "error",
message:
"Failed to attach customer need to the feature request.",
error: "Linear API request failed (500): Internal error",
},
}}
/>
</SubSection>
<SubSection label="Output error">
<CreateFeatureRequestTool
part={{
type: "tool-create_feature_request",
toolCallId: uid(),
state: "output-error",
input: { title: "Add dark mode" },
}}
/>
</SubSection>
</Section>
{/* ============================================================= */}
{/* FULL CONVERSATION EXAMPLE */}
{/* ============================================================= */}

View File

@@ -0,0 +1,240 @@
"use client";
import type { ToolUIPart } from "ai";
import { useMemo } from "react";
import { MorphingTextAnimation } from "../../components/MorphingTextAnimation/MorphingTextAnimation";
import {
ContentBadge,
ContentCard,
ContentCardDescription,
ContentCardHeader,
ContentCardTitle,
ContentGrid,
ContentLink,
ContentMessage,
ContentSuggestionsList,
} from "../../components/ToolAccordion/AccordionContent";
import { ToolAccordion } from "../../components/ToolAccordion/ToolAccordion";
import {
AccordionIcon,
getAccordionTitle,
getAnimationText,
getFeatureRequestOutput,
isCreatedOutput,
isErrorOutput,
isNoResultsOutput,
isSearchResultsOutput,
ToolIcon,
type FeatureRequestToolType,
} from "./helpers";
export interface FeatureRequestToolPart {
type: FeatureRequestToolType;
toolCallId: string;
state: ToolUIPart["state"];
input?: unknown;
output?: unknown;
}
interface Props {
part: FeatureRequestToolPart;
}
function truncate(text: string, maxChars: number): string {
const trimmed = text.trim();
if (trimmed.length <= maxChars) return trimmed;
return `${trimmed.slice(0, maxChars).trimEnd()}`;
}
export function SearchFeatureRequestsTool({ part }: Props) {
const output = getFeatureRequestOutput(part);
const text = getAnimationText(part);
const isStreaming =
part.state === "input-streaming" || part.state === "input-available";
const isError =
part.state === "output-error" || (!!output && isErrorOutput(output));
const normalized = useMemo(() => {
if (!output) return null;
return { title: getAccordionTitle(part.type, output) };
}, [output, part.type]);
const isOutputAvailable = part.state === "output-available" && !!output;
const searchOutput =
isOutputAvailable && output && isSearchResultsOutput(output)
? output
: null;
const noResultsOutput =
isOutputAvailable && output && isNoResultsOutput(output) ? output : null;
const errorOutput =
isOutputAvailable && output && isErrorOutput(output) ? output : null;
const hasExpandableContent =
isOutputAvailable &&
((!!searchOutput && searchOutput.count > 0) ||
!!noResultsOutput ||
!!errorOutput);
const accordionDescription =
hasExpandableContent && searchOutput
? `Found ${searchOutput.count} result${searchOutput.count === 1 ? "" : "s"} for "${searchOutput.query}"`
: hasExpandableContent && (noResultsOutput || errorOutput)
? ((noResultsOutput ?? errorOutput)?.message ?? null)
: null;
return (
<div className="py-2">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<ToolIcon
toolType={part.type}
isStreaming={isStreaming}
isError={isError}
/>
<MorphingTextAnimation
text={text}
className={isError ? "text-red-500" : undefined}
/>
</div>
{hasExpandableContent && normalized && (
<ToolAccordion
icon={<AccordionIcon toolType={part.type} />}
title={normalized.title}
description={accordionDescription}
>
{searchOutput && (
<ContentGrid>
{searchOutput.results.map((r) => (
<ContentCard key={r.id}>
<ContentCardHeader>
<ContentCardTitle>
{r.identifier} {r.title}
</ContentCardTitle>
</ContentCardHeader>
{r.description && (
<ContentCardDescription>
{truncate(r.description, 200)}
</ContentCardDescription>
)}
</ContentCard>
))}
</ContentGrid>
)}
{noResultsOutput && (
<div>
<ContentMessage>{noResultsOutput.message}</ContentMessage>
{noResultsOutput.suggestions &&
noResultsOutput.suggestions.length > 0 && (
<ContentSuggestionsList items={noResultsOutput.suggestions} />
)}
</div>
)}
{errorOutput && (
<div>
<ContentMessage>{errorOutput.message}</ContentMessage>
{errorOutput.error && (
<ContentCardDescription>
{errorOutput.error}
</ContentCardDescription>
)}
</div>
)}
</ToolAccordion>
)}
</div>
);
}
export function CreateFeatureRequestTool({ part }: Props) {
const output = getFeatureRequestOutput(part);
const text = getAnimationText(part);
const isStreaming =
part.state === "input-streaming" || part.state === "input-available";
const isError =
part.state === "output-error" || (!!output && isErrorOutput(output));
const normalized = useMemo(() => {
if (!output) return null;
return { title: getAccordionTitle(part.type, output) };
}, [output, part.type]);
const isOutputAvailable = part.state === "output-available" && !!output;
const createdOutput =
isOutputAvailable && output && isCreatedOutput(output) ? output : null;
const errorOutput =
isOutputAvailable && output && isErrorOutput(output) ? output : null;
const hasExpandableContent =
isOutputAvailable && (!!createdOutput || !!errorOutput);
const accordionDescription =
hasExpandableContent && createdOutput
? `${createdOutput.issue_identifier}${createdOutput.issue_title}`
: hasExpandableContent && errorOutput
? errorOutput.message
: null;
return (
<div className="py-2">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<ToolIcon
toolType={part.type}
isStreaming={isStreaming}
isError={isError}
/>
<MorphingTextAnimation
text={text}
className={isError ? "text-red-500" : undefined}
/>
</div>
{hasExpandableContent && normalized && (
<ToolAccordion
icon={<AccordionIcon toolType={part.type} />}
title={normalized.title}
description={accordionDescription}
>
{createdOutput && (
<ContentCard>
<ContentCardHeader
action={
createdOutput.issue_url ? (
<ContentLink href={createdOutput.issue_url}>
View
</ContentLink>
) : undefined
}
>
<ContentCardTitle>
{createdOutput.issue_identifier} {createdOutput.issue_title}
</ContentCardTitle>
</ContentCardHeader>
<div className="mt-2 flex items-center gap-2">
<ContentBadge>
{createdOutput.is_new_issue ? "New" : "Existing"}
</ContentBadge>
</div>
<ContentMessage>{createdOutput.message}</ContentMessage>
</ContentCard>
)}
{errorOutput && (
<div>
<ContentMessage>{errorOutput.message}</ContentMessage>
{errorOutput.error && (
<ContentCardDescription>
{errorOutput.error}
</ContentCardDescription>
)}
</div>
)}
</ToolAccordion>
)}
</div>
);
}

View File

@@ -0,0 +1,271 @@
import {
CheckCircleIcon,
LightbulbIcon,
MagnifyingGlassIcon,
PlusCircleIcon,
} from "@phosphor-icons/react";
import type { ToolUIPart } from "ai";
/* ------------------------------------------------------------------ */
/* Types (local until API client is regenerated) */
/* ------------------------------------------------------------------ */
interface FeatureRequestInfo {
id: string;
identifier: string;
title: string;
description?: string | null;
}
export interface FeatureRequestSearchResponse {
type: "feature_request_search";
message: string;
results: FeatureRequestInfo[];
count: number;
query: string;
}
export interface FeatureRequestCreatedResponse {
type: "feature_request_created";
message: string;
issue_id: string;
issue_identifier: string;
issue_title: string;
issue_url: string;
is_new_issue: boolean;
customer_name: string;
}
interface NoResultsResponse {
type: "no_results";
message: string;
suggestions?: string[];
}
interface ErrorResponse {
type: "error";
message: string;
error?: string;
}
export type FeatureRequestOutput =
| FeatureRequestSearchResponse
| FeatureRequestCreatedResponse
| NoResultsResponse
| ErrorResponse;
export type FeatureRequestToolType =
| "tool-search_feature_requests"
| "tool-create_feature_request"
| string;
/* ------------------------------------------------------------------ */
/* Output parsing */
/* ------------------------------------------------------------------ */
function parseOutput(output: unknown): FeatureRequestOutput | null {
if (!output) return null;
if (typeof output === "string") {
const trimmed = output.trim();
if (!trimmed) return null;
try {
return parseOutput(JSON.parse(trimmed) as unknown);
} catch {
return null;
}
}
if (typeof output === "object") {
const type = (output as { type?: unknown }).type;
if (
type === "feature_request_search" ||
type === "feature_request_created" ||
type === "no_results" ||
type === "error"
) {
return output as FeatureRequestOutput;
}
// Fallback structural checks
if ("results" in output && "query" in output)
return output as FeatureRequestSearchResponse;
if ("issue_identifier" in output)
return output as FeatureRequestCreatedResponse;
if ("suggestions" in output && !("error" in output))
return output as NoResultsResponse;
if ("error" in output || "details" in output)
return output as ErrorResponse;
}
return null;
}
export function getFeatureRequestOutput(
part: unknown,
): FeatureRequestOutput | null {
if (!part || typeof part !== "object") return null;
return parseOutput((part as { output?: unknown }).output);
}
/* ------------------------------------------------------------------ */
/* Type guards */
/* ------------------------------------------------------------------ */
export function isSearchResultsOutput(
output: FeatureRequestOutput,
): output is FeatureRequestSearchResponse {
return (
output.type === "feature_request_search" ||
("results" in output && "query" in output)
);
}
export function isCreatedOutput(
output: FeatureRequestOutput,
): output is FeatureRequestCreatedResponse {
return (
output.type === "feature_request_created" || "issue_identifier" in output
);
}
export function isNoResultsOutput(
output: FeatureRequestOutput,
): output is NoResultsResponse {
return (
output.type === "no_results" ||
("suggestions" in output && !("error" in output))
);
}
export function isErrorOutput(
output: FeatureRequestOutput,
): output is ErrorResponse {
return output.type === "error" || "error" in output;
}
/* ------------------------------------------------------------------ */
/* Accordion metadata */
/* ------------------------------------------------------------------ */
export function getAccordionTitle(
toolType: FeatureRequestToolType,
output: FeatureRequestOutput,
): string {
if (toolType === "tool-search_feature_requests") {
if (isSearchResultsOutput(output)) return "Feature requests";
if (isNoResultsOutput(output)) return "No feature requests found";
return "Feature request search error";
}
if (isCreatedOutput(output)) {
return output.is_new_issue
? "Feature request created"
: "Added to feature request";
}
if (isErrorOutput(output)) return "Feature request error";
return "Feature request";
}
/* ------------------------------------------------------------------ */
/* Animation text */
/* ------------------------------------------------------------------ */
interface AnimationPart {
type: FeatureRequestToolType;
state: ToolUIPart["state"];
input?: unknown;
output?: unknown;
}
export function getAnimationText(part: AnimationPart): string {
if (part.type === "tool-search_feature_requests") {
const query = (part.input as { query?: string } | undefined)?.query?.trim();
const queryText = query ? ` for "${query}"` : "";
switch (part.state) {
case "input-streaming":
case "input-available":
return `Searching feature requests${queryText}`;
case "output-available": {
const output = parseOutput(part.output);
if (!output) return `Searching feature requests${queryText}`;
if (isSearchResultsOutput(output)) {
return `Found ${output.count} feature request${output.count === 1 ? "" : "s"}${queryText}`;
}
if (isNoResultsOutput(output))
return `No feature requests found${queryText}`;
return `Error searching feature requests${queryText}`;
}
case "output-error":
return `Error searching feature requests${queryText}`;
default:
return "Searching feature requests";
}
}
// create_feature_request
const title = (part.input as { title?: string } | undefined)?.title?.trim();
const titleText = title ? ` "${title}"` : "";
switch (part.state) {
case "input-streaming":
case "input-available":
return `Creating feature request${titleText}`;
case "output-available": {
const output = parseOutput(part.output);
if (!output) return `Creating feature request${titleText}`;
if (isCreatedOutput(output)) {
return output.is_new_issue
? `Created ${output.issue_identifier}`
: `Added to ${output.issue_identifier}`;
}
if (isErrorOutput(output)) return "Error creating feature request";
return `Created feature request${titleText}`;
}
case "output-error":
return "Error creating feature request";
default:
return "Creating feature request";
}
}
/* ------------------------------------------------------------------ */
/* Icons */
/* ------------------------------------------------------------------ */
export function ToolIcon({
toolType,
isStreaming,
isError,
}: {
toolType: FeatureRequestToolType;
isStreaming?: boolean;
isError?: boolean;
}) {
const IconComponent =
toolType === "tool-create_feature_request"
? PlusCircleIcon
: MagnifyingGlassIcon;
return (
<IconComponent
size={14}
weight="regular"
className={
isError
? "text-red-500"
: isStreaming
? "text-neutral-500"
: "text-neutral-400"
}
/>
);
}
export function AccordionIcon({
toolType,
}: {
toolType: FeatureRequestToolType;
}) {
const IconComponent =
toolType === "tool-create_feature_request"
? CheckCircleIcon
: LightbulbIcon;
return <IconComponent size={32} weight="light" />;
}

View File

@@ -10495,7 +10495,9 @@
"operation_started",
"operation_pending",
"operation_in_progress",
"input_validation_error"
"input_validation_error",
"feature_request_search",
"feature_request_created"
],
"title": "ResponseType",
"description": "Types of tool responses."

View File

@@ -180,3 +180,14 @@ body[data-google-picker-open="true"] [data-dialog-content] {
z-index: 1 !important;
pointer-events: none !important;
}
/* CoPilot chat table styling — remove left/right borders, increase padding */
[data-streamdown="table-wrapper"] table {
border-left: none;
border-right: none;
}
[data-streamdown="table-wrapper"] th,
[data-streamdown="table-wrapper"] td {
padding: 0.875rem 1rem; /* py-3.5 px-4 */
}

View File

@@ -30,6 +30,7 @@ export function APIKeyCredentialsModal({
const {
form,
isLoading,
isSubmitting,
supportsApiKey,
providerName,
schemaDescription,
@@ -138,7 +139,12 @@ export function APIKeyCredentialsModal({
/>
)}
/>
<Button type="submit" className="min-w-68">
<Button
type="submit"
className="min-w-68"
loading={isSubmitting}
disabled={isSubmitting}
>
Add API Key
</Button>
</form>

View File

@@ -4,6 +4,7 @@ import {
CredentialsMetaInput,
} from "@/lib/autogpt-server-api/types";
import { zodResolver } from "@hookform/resolvers/zod";
import { useState } from "react";
import { useForm, type UseFormReturn } from "react-hook-form";
import { z } from "zod";
@@ -26,6 +27,7 @@ export function useAPIKeyCredentialsModal({
}: Args): {
form: UseFormReturn<APIKeyFormValues>;
isLoading: boolean;
isSubmitting: boolean;
supportsApiKey: boolean;
provider?: string;
providerName?: string;
@@ -33,6 +35,7 @@ export function useAPIKeyCredentialsModal({
onSubmit: (values: APIKeyFormValues) => Promise<void>;
} {
const credentials = useCredentials(schema, siblingInputs);
const [isSubmitting, setIsSubmitting] = useState(false);
const formSchema = z.object({
apiKey: z.string().min(1, "API Key is required"),
@@ -40,48 +43,42 @@ export function useAPIKeyCredentialsModal({
expiresAt: z.string().optional(),
});
function getDefaultExpirationDate(): string {
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
tomorrow.setHours(0, 0, 0, 0);
const year = tomorrow.getFullYear();
const month = String(tomorrow.getMonth() + 1).padStart(2, "0");
const day = String(tomorrow.getDate()).padStart(2, "0");
const hours = String(tomorrow.getHours()).padStart(2, "0");
const minutes = String(tomorrow.getMinutes()).padStart(2, "0");
return `${year}-${month}-${day}T${hours}:${minutes}`;
}
const form = useForm<APIKeyFormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
apiKey: "",
title: "",
expiresAt: getDefaultExpirationDate(),
expiresAt: "",
},
});
async function onSubmit(values: APIKeyFormValues) {
if (!credentials || credentials.isLoading) return;
const expiresAt = values.expiresAt
? new Date(values.expiresAt).getTime() / 1000
: undefined;
const newCredentials = await credentials.createAPIKeyCredentials({
api_key: values.apiKey,
title: values.title,
expires_at: expiresAt,
});
onCredentialsCreate({
provider: credentials.provider,
id: newCredentials.id,
type: "api_key",
title: newCredentials.title,
});
setIsSubmitting(true);
try {
const expiresAt = values.expiresAt
? new Date(values.expiresAt).getTime() / 1000
: undefined;
const newCredentials = await credentials.createAPIKeyCredentials({
api_key: values.apiKey,
title: values.title,
expires_at: expiresAt,
});
onCredentialsCreate({
provider: credentials.provider,
id: newCredentials.id,
type: "api_key",
title: newCredentials.title,
});
} finally {
setIsSubmitting(false);
}
}
return {
form,
isLoading: !credentials || credentials.isLoading,
isSubmitting,
supportsApiKey: !!credentials?.supportsApiKey,
provider: credentials?.provider,
providerName:

View File

@@ -226,7 +226,7 @@ function renderMarkdown(
table: ({ children, ...props }) => (
<div className="my-4 overflow-x-auto">
<table
className="min-w-full divide-y divide-gray-200 rounded-lg border border-gray-200 dark:divide-gray-700 dark:border-gray-700"
className="min-w-full divide-y divide-gray-200 border-y border-gray-200 dark:divide-gray-700 dark:border-gray-700"
{...props}
>
{children}
@@ -235,7 +235,7 @@ function renderMarkdown(
),
th: ({ children, ...props }) => (
<th
className="bg-gray-50 px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-700 dark:bg-gray-800 dark:text-gray-300"
className="bg-gray-50 px-4 py-3.5 text-left text-xs font-semibold uppercase tracking-wider text-gray-700 dark:bg-gray-800 dark:text-gray-300"
{...props}
>
{children}
@@ -243,7 +243,7 @@ function renderMarkdown(
),
td: ({ children, ...props }) => (
<td
className="border-t border-gray-200 px-4 py-3 text-sm text-gray-600 dark:border-gray-700 dark:text-gray-400"
className="border-t border-gray-200 px-4 py-3.5 text-sm text-gray-600 dark:border-gray-700 dark:text-gray-400"
{...props}
>
{children}