chore: ran linting

This commit is contained in:
Anish Sarkar 2026-03-21 13:20:13 +05:30
parent 772150eb66
commit de8841fb86
110 changed files with 2673 additions and 1918 deletions

View file

@ -298,8 +298,7 @@ async def create_surfsense_deep_agent(
# Disable Google Drive action tools if no Google Drive connector is configured
has_google_drive_connector = (
available_connectors is not None
and "GOOGLE_DRIVE_FILE" in available_connectors
available_connectors is not None and "GOOGLE_DRIVE_FILE" in available_connectors
)
if not has_google_drive_connector:
google_drive_tools = [
@ -337,8 +336,7 @@ async def create_surfsense_deep_agent(
# Disable Jira action tools if no Jira connector is configured
has_jira_connector = (
available_connectors is not None
and "JIRA_CONNECTOR" in available_connectors
available_connectors is not None and "JIRA_CONNECTOR" in available_connectors
)
if not has_jira_connector:
jira_tools = [

View file

@ -43,11 +43,16 @@ def create_create_confluence_page_tool(
logger.info(f"create_confluence_page called: title='{title}'")
if db_session is None or search_space_id is None or user_id is None:
return {"status": "error", "message": "Confluence tool not properly configured."}
return {
"status": "error",
"message": "Confluence tool not properly configured.",
}
try:
metadata_service = ConfluenceToolMetadataService(db_session)
context = await metadata_service.get_creation_context(search_space_id, user_id)
context = await metadata_service.get_creation_context(
search_space_id, user_id
)
if "error" in context:
return {"status": "error", "message": context["error"]}
@ -60,22 +65,28 @@ def create_create_confluence_page_tool(
"connector_type": "confluence",
}
approval = interrupt({
"type": "confluence_page_creation",
"action": {
"tool": "create_confluence_page",
"params": {
"title": title,
"content": content,
"space_id": space_id,
"connector_id": connector_id,
approval = interrupt(
{
"type": "confluence_page_creation",
"action": {
"tool": "create_confluence_page",
"params": {
"title": title,
"content": content,
"space_id": space_id,
"connector_id": connector_id,
},
},
},
"context": context,
})
"context": context,
}
)
decisions_raw = approval.get("decisions", []) if isinstance(approval, dict) else []
decisions = decisions_raw if isinstance(decisions_raw, list) else [decisions_raw]
decisions_raw = (
approval.get("decisions", []) if isinstance(approval, dict) else []
)
decisions = (
decisions_raw if isinstance(decisions_raw, list) else [decisions_raw]
)
decisions = [d for d in decisions if isinstance(d, dict)]
if not decisions:
return {"status": "error", "message": "No approval decision received"}
@ -84,7 +95,10 @@ def create_create_confluence_page_tool(
decision_type = decision.get("type") or decision.get("decision_type")
if decision_type == "reject":
return {"status": "rejected", "message": "User declined. The page was not created."}
return {
"status": "rejected",
"message": "User declined. The page was not created.",
}
final_params: dict[str, Any] = {}
edited_action = decision.get("edited_action")
@ -114,12 +128,16 @@ def create_create_confluence_page_tool(
select(SearchSourceConnector).filter(
SearchSourceConnector.search_space_id == search_space_id,
SearchSourceConnector.user_id == user_id,
SearchSourceConnector.connector_type == SearchSourceConnectorType.CONFLUENCE_CONNECTOR,
SearchSourceConnector.connector_type
== SearchSourceConnectorType.CONFLUENCE_CONNECTOR,
)
)
connector = result.scalars().first()
if not connector:
return {"status": "error", "message": "No Confluence connector found."}
return {
"status": "error",
"message": "No Confluence connector found.",
}
actual_connector_id = connector.id
else:
result = await db_session.execute(
@ -127,15 +145,21 @@ def create_create_confluence_page_tool(
SearchSourceConnector.id == actual_connector_id,
SearchSourceConnector.search_space_id == search_space_id,
SearchSourceConnector.user_id == user_id,
SearchSourceConnector.connector_type == SearchSourceConnectorType.CONFLUENCE_CONNECTOR,
SearchSourceConnector.connector_type
== SearchSourceConnectorType.CONFLUENCE_CONNECTOR,
)
)
connector = result.scalars().first()
if not connector:
return {"status": "error", "message": "Selected Confluence connector is invalid."}
return {
"status": "error",
"message": "Selected Confluence connector is invalid.",
}
try:
client = ConfluenceHistoryConnector(session=db_session, connector_id=actual_connector_id)
client = ConfluenceHistoryConnector(
session=db_session, connector_id=actual_connector_id
)
api_result = await client.create_page(
space_id=final_space_id,
title=final_title,
@ -143,7 +167,10 @@ def create_create_confluence_page_tool(
)
await client.close()
except Exception as api_err:
if "http 403" in str(api_err).lower() or "status code 403" in str(api_err).lower():
if (
"http 403" in str(api_err).lower()
or "status code 403" in str(api_err).lower()
):
try:
_conn = connector
_conn.config = {**_conn.config, "auth_expired": True}
@ -163,6 +190,7 @@ def create_create_confluence_page_tool(
kb_message_suffix = ""
try:
from app.services.confluence import ConfluenceKBSyncService
kb_service = ConfluenceKBSyncService(db_session)
kb_result = await kb_service.sync_after_create(
page_id=page_id,
@ -189,9 +217,13 @@ def create_create_confluence_page_tool(
except Exception as e:
from langgraph.errors import GraphInterrupt
if isinstance(e, GraphInterrupt):
raise
logger.error(f"Error creating Confluence page: {e}", exc_info=True)
return {"status": "error", "message": "Something went wrong while creating the page."}
return {
"status": "error",
"message": "Something went wrong while creating the page.",
}
return create_confluence_page

View file

@ -39,14 +39,21 @@ def create_delete_confluence_page_tool(
- If status is "not_found", relay the message to the user.
- If status is "insufficient_permissions", inform user to re-authenticate.
"""
logger.info(f"delete_confluence_page called: page_title_or_id='{page_title_or_id}'")
logger.info(
f"delete_confluence_page called: page_title_or_id='{page_title_or_id}'"
)
if db_session is None or search_space_id is None or user_id is None:
return {"status": "error", "message": "Confluence tool not properly configured."}
return {
"status": "error",
"message": "Confluence tool not properly configured.",
}
try:
metadata_service = ConfluenceToolMetadataService(db_session)
context = await metadata_service.get_deletion_context(search_space_id, user_id, page_title_or_id)
context = await metadata_service.get_deletion_context(
search_space_id, user_id, page_title_or_id
)
if "error" in context:
error_msg = context["error"]
@ -67,21 +74,27 @@ def create_delete_confluence_page_tool(
document_id = page_data["document_id"]
connector_id_from_context = context.get("account", {}).get("id")
approval = interrupt({
"type": "confluence_page_deletion",
"action": {
"tool": "delete_confluence_page",
"params": {
"page_id": page_id,
"connector_id": connector_id_from_context,
"delete_from_kb": delete_from_kb,
approval = interrupt(
{
"type": "confluence_page_deletion",
"action": {
"tool": "delete_confluence_page",
"params": {
"page_id": page_id,
"connector_id": connector_id_from_context,
"delete_from_kb": delete_from_kb,
},
},
},
"context": context,
})
"context": context,
}
)
decisions_raw = approval.get("decisions", []) if isinstance(approval, dict) else []
decisions = decisions_raw if isinstance(decisions_raw, list) else [decisions_raw]
decisions_raw = (
approval.get("decisions", []) if isinstance(approval, dict) else []
)
decisions = (
decisions_raw if isinstance(decisions_raw, list) else [decisions_raw]
)
decisions = [d for d in decisions if isinstance(d, dict)]
if not decisions:
return {"status": "error", "message": "No approval decision received"}
@ -90,7 +103,10 @@ def create_delete_confluence_page_tool(
decision_type = decision.get("type") or decision.get("decision_type")
if decision_type == "reject":
return {"status": "rejected", "message": "User declined. The page was not deleted."}
return {
"status": "rejected",
"message": "User declined. The page was not deleted.",
}
final_params: dict[str, Any] = {}
edited_action = decision.get("edited_action")
@ -102,33 +118,47 @@ def create_delete_confluence_page_tool(
final_params = decision["args"]
final_page_id = final_params.get("page_id", page_id)
final_connector_id = final_params.get("connector_id", connector_id_from_context)
final_connector_id = final_params.get(
"connector_id", connector_id_from_context
)
final_delete_from_kb = final_params.get("delete_from_kb", delete_from_kb)
from sqlalchemy.future import select
from app.db import SearchSourceConnector, SearchSourceConnectorType
if not final_connector_id:
return {"status": "error", "message": "No connector found for this page."}
return {
"status": "error",
"message": "No connector found for this page.",
}
result = await db_session.execute(
select(SearchSourceConnector).filter(
SearchSourceConnector.id == final_connector_id,
SearchSourceConnector.search_space_id == search_space_id,
SearchSourceConnector.user_id == user_id,
SearchSourceConnector.connector_type == SearchSourceConnectorType.CONFLUENCE_CONNECTOR,
SearchSourceConnector.connector_type
== SearchSourceConnectorType.CONFLUENCE_CONNECTOR,
)
)
connector = result.scalars().first()
if not connector:
return {"status": "error", "message": "Selected Confluence connector is invalid."}
return {
"status": "error",
"message": "Selected Confluence connector is invalid.",
}
try:
client = ConfluenceHistoryConnector(session=db_session, connector_id=final_connector_id)
client = ConfluenceHistoryConnector(
session=db_session, connector_id=final_connector_id
)
await client.delete_page(final_page_id)
await client.close()
except Exception as api_err:
if "http 403" in str(api_err).lower() or "status code 403" in str(api_err).lower():
if (
"http 403" in str(api_err).lower()
or "status code 403" in str(api_err).lower()
):
try:
connector.config = {**connector.config, "auth_expired": True}
flag_modified(connector, "config")
@ -146,6 +176,7 @@ def create_delete_confluence_page_tool(
if final_delete_from_kb and document_id:
try:
from app.db import Document
doc_result = await db_session.execute(
select(Document).filter(Document.id == document_id)
)
@ -171,9 +202,13 @@ def create_delete_confluence_page_tool(
except Exception as e:
from langgraph.errors import GraphInterrupt
if isinstance(e, GraphInterrupt):
raise
logger.error(f"Error deleting Confluence page: {e}", exc_info=True)
return {"status": "error", "message": "Something went wrong while deleting the page."}
return {
"status": "error",
"message": "Something went wrong while deleting the page.",
}
return delete_confluence_page

View file

@ -41,14 +41,21 @@ def create_update_confluence_page_tool(
- If status is "not_found", relay the message to the user.
- If status is "insufficient_permissions", inform user to re-authenticate.
"""
logger.info(f"update_confluence_page called: page_title_or_id='{page_title_or_id}'")
logger.info(
f"update_confluence_page called: page_title_or_id='{page_title_or_id}'"
)
if db_session is None or search_space_id is None or user_id is None:
return {"status": "error", "message": "Confluence tool not properly configured."}
return {
"status": "error",
"message": "Confluence tool not properly configured.",
}
try:
metadata_service = ConfluenceToolMetadataService(db_session)
context = await metadata_service.get_update_context(search_space_id, user_id, page_title_or_id)
context = await metadata_service.get_update_context(
search_space_id, user_id, page_title_or_id
)
if "error" in context:
error_msg = context["error"]
@ -71,24 +78,30 @@ def create_update_confluence_page_tool(
document_id = page_data.get("document_id")
connector_id_from_context = context.get("account", {}).get("id")
approval = interrupt({
"type": "confluence_page_update",
"action": {
"tool": "update_confluence_page",
"params": {
"page_id": page_id,
"document_id": document_id,
"new_title": new_title,
"new_content": new_content,
"version": current_version,
"connector_id": connector_id_from_context,
approval = interrupt(
{
"type": "confluence_page_update",
"action": {
"tool": "update_confluence_page",
"params": {
"page_id": page_id,
"document_id": document_id,
"new_title": new_title,
"new_content": new_content,
"version": current_version,
"connector_id": connector_id_from_context,
},
},
},
"context": context,
})
"context": context,
}
)
decisions_raw = approval.get("decisions", []) if isinstance(approval, dict) else []
decisions = decisions_raw if isinstance(decisions_raw, list) else [decisions_raw]
decisions_raw = (
approval.get("decisions", []) if isinstance(approval, dict) else []
)
decisions = (
decisions_raw if isinstance(decisions_raw, list) else [decisions_raw]
)
decisions = [d for d in decisions if isinstance(d, dict)]
if not decisions:
return {"status": "error", "message": "No approval decision received"}
@ -97,7 +110,10 @@ def create_update_confluence_page_tool(
decision_type = decision.get("type") or decision.get("decision_type")
if decision_type == "reject":
return {"status": "rejected", "message": "User declined. The page was not updated."}
return {
"status": "rejected",
"message": "User declined. The page was not updated.",
}
final_params: dict[str, Any] = {}
edited_action = decision.get("edited_action")
@ -114,29 +130,40 @@ def create_update_confluence_page_tool(
if final_content is None:
final_content = current_body
final_version = final_params.get("version", current_version)
final_connector_id = final_params.get("connector_id", connector_id_from_context)
final_connector_id = final_params.get(
"connector_id", connector_id_from_context
)
final_document_id = final_params.get("document_id", document_id)
from sqlalchemy.future import select
from app.db import SearchSourceConnector, SearchSourceConnectorType
if not final_connector_id:
return {"status": "error", "message": "No connector found for this page."}
return {
"status": "error",
"message": "No connector found for this page.",
}
result = await db_session.execute(
select(SearchSourceConnector).filter(
SearchSourceConnector.id == final_connector_id,
SearchSourceConnector.search_space_id == search_space_id,
SearchSourceConnector.user_id == user_id,
SearchSourceConnector.connector_type == SearchSourceConnectorType.CONFLUENCE_CONNECTOR,
SearchSourceConnector.connector_type
== SearchSourceConnectorType.CONFLUENCE_CONNECTOR,
)
)
connector = result.scalars().first()
if not connector:
return {"status": "error", "message": "Selected Confluence connector is invalid."}
return {
"status": "error",
"message": "Selected Confluence connector is invalid.",
}
try:
client = ConfluenceHistoryConnector(session=db_session, connector_id=final_connector_id)
client = ConfluenceHistoryConnector(
session=db_session, connector_id=final_connector_id
)
await client.update_page(
page_id=final_page_id,
title=final_title,
@ -145,7 +172,10 @@ def create_update_confluence_page_tool(
)
await client.close()
except Exception as api_err:
if "http 403" in str(api_err).lower() or "status code 403" in str(api_err).lower():
if (
"http 403" in str(api_err).lower()
or "status code 403" in str(api_err).lower()
):
try:
connector.config = {**connector.config, "auth_expired": True}
flag_modified(connector, "config")
@ -163,6 +193,7 @@ def create_update_confluence_page_tool(
if final_document_id:
try:
from app.services.confluence import ConfluenceKBSyncService
kb_service = ConfluenceKBSyncService(db_session)
kb_result = await kb_service.sync_after_update(
document_id=final_document_id,
@ -171,12 +202,18 @@ def create_update_confluence_page_tool(
search_space_id=search_space_id,
)
if kb_result["status"] == "success":
kb_message_suffix = " Your knowledge base has also been updated."
kb_message_suffix = (
" Your knowledge base has also been updated."
)
else:
kb_message_suffix = " The knowledge base will be updated in the next sync."
kb_message_suffix = (
" The knowledge base will be updated in the next sync."
)
except Exception as kb_err:
logger.warning(f"KB sync after update failed: {kb_err}")
kb_message_suffix = " The knowledge base will be updated in the next sync."
kb_message_suffix = (
" The knowledge base will be updated in the next sync."
)
return {
"status": "success",
@ -186,9 +223,13 @@ def create_update_confluence_page_tool(
except Exception as e:
from langgraph.errors import GraphInterrupt
if isinstance(e, GraphInterrupt):
raise
logger.error(f"Error updating Confluence page: {e}", exc_info=True)
return {"status": "error", "message": "Something went wrong while updating the page."}
return {
"status": "error",
"message": "Something went wrong while updating the page.",
}
return update_confluence_page

View file

@ -55,9 +55,7 @@ def create_create_gmail_draft_tool(
- "Draft an email to alice@example.com about the meeting"
- "Compose a reply to Bob about the project update"
"""
logger.info(
f"create_gmail_draft called: to='{to}', subject='{subject}'"
)
logger.info(f"create_gmail_draft called: to='{to}', subject='{subject}'")
if db_session is None or search_space_id is None or user_id is None:
return {
@ -187,7 +185,10 @@ def create_create_gmail_draft_tool(
f"Creating Gmail draft: to='{final_to}', subject='{final_subject}', connector={actual_connector_id}"
)
if connector.connector_type == SearchSourceConnectorType.COMPOSIO_GMAIL_CONNECTOR:
if (
connector.connector_type
== SearchSourceConnectorType.COMPOSIO_GMAIL_CONNECTOR
):
from app.utils.google_credentials import build_composio_credentials
cca_id = connector.config.get("composio_connected_account_id")
@ -251,10 +252,12 @@ def create_create_gmail_draft_tool(
try:
created = await asyncio.get_event_loop().run_in_executor(
None,
lambda: gmail_service.users()
.drafts()
.create(userId="me", body={"message": {"raw": raw}})
.execute(),
lambda: (
gmail_service.users()
.drafts()
.create(userId="me", body={"message": {"raw": raw}})
.execute()
),
)
except Exception as api_err:
from googleapiclient.errors import HttpError
@ -289,9 +292,7 @@ def create_create_gmail_draft_tool(
}
raise
logger.info(
f"Gmail draft created: id={created.get('id')}"
)
logger.info(f"Gmail draft created: id={created.get('id')}")
kb_message_suffix = ""
try:

View file

@ -56,9 +56,7 @@ def create_send_gmail_email_tool(
- "Send an email to alice@example.com about the meeting"
- "Email Bob the project update"
"""
logger.info(
f"send_gmail_email called: to='{to}', subject='{subject}'"
)
logger.info(f"send_gmail_email called: to='{to}', subject='{subject}'")
if db_session is None or search_space_id is None or user_id is None:
return {
@ -188,7 +186,10 @@ def create_send_gmail_email_tool(
f"Sending Gmail email: to='{final_to}', subject='{final_subject}', connector={actual_connector_id}"
)
if connector.connector_type == SearchSourceConnectorType.COMPOSIO_GMAIL_CONNECTOR:
if (
connector.connector_type
== SearchSourceConnectorType.COMPOSIO_GMAIL_CONNECTOR
):
from app.utils.google_credentials import build_composio_credentials
cca_id = connector.config.get("composio_connected_account_id")
@ -252,10 +253,12 @@ def create_send_gmail_email_tool(
try:
sent = await asyncio.get_event_loop().run_in_executor(
None,
lambda: gmail_service.users()
.messages()
.send(userId="me", body={"raw": raw})
.execute(),
lambda: (
gmail_service.users()
.messages()
.send(userId="me", body={"raw": raw})
.execute()
),
)
except Exception as api_err:
from googleapiclient.errors import HttpError

View file

@ -186,7 +186,10 @@ def create_trash_gmail_email_tool(
f"Trashing Gmail email: message_id='{final_message_id}', connector={final_connector_id}"
)
if connector.connector_type == SearchSourceConnectorType.COMPOSIO_GMAIL_CONNECTOR:
if (
connector.connector_type
== SearchSourceConnectorType.COMPOSIO_GMAIL_CONNECTOR
):
from app.utils.google_credentials import build_composio_credentials
cca_id = connector.config.get("composio_connected_account_id")
@ -241,10 +244,12 @@ def create_trash_gmail_email_tool(
try:
await asyncio.get_event_loop().run_in_executor(
None,
lambda: gmail_service.users()
.messages()
.trash(userId="me", id=final_message_id)
.execute(),
lambda: (
gmail_service.users()
.messages()
.trash(userId="me", id=final_message_id)
.execute()
),
)
except Exception as api_err:
from googleapiclient.errors import HttpError
@ -257,7 +262,10 @@ def create_trash_gmail_email_tool(
from sqlalchemy.orm.attributes import flag_modified
if not connector.config.get("auth_expired"):
connector.config = {**connector.config, "auth_expired": True}
connector.config = {
**connector.config,
"auth_expired": True,
}
flag_modified(connector, "config")
await db_session.commit()
except Exception:
@ -273,9 +281,7 @@ def create_trash_gmail_email_tool(
}
raise
logger.info(
f"Gmail email trashed: message_id={final_message_id}"
)
logger.info(f"Gmail email trashed: message_id={final_message_id}")
trash_result: dict[str, Any] = {
"status": "success",

View file

@ -216,7 +216,10 @@ def create_update_gmail_draft_tool(
f"Updating Gmail draft: subject='{final_subject}', connector={final_connector_id}"
)
if connector.connector_type == SearchSourceConnectorType.COMPOSIO_GMAIL_CONNECTOR:
if (
connector.connector_type
== SearchSourceConnectorType.COMPOSIO_GMAIL_CONNECTOR
):
from app.utils.google_credentials import build_composio_credentials
cca_id = connector.config.get("composio_connected_account_id")
@ -299,14 +302,16 @@ def create_update_gmail_draft_tool(
try:
updated = await asyncio.get_event_loop().run_in_executor(
None,
lambda: gmail_service.users()
.drafts()
.update(
userId="me",
id=final_draft_id,
body={"message": {"raw": raw}},
)
.execute(),
lambda: (
gmail_service.users()
.drafts()
.update(
userId="me",
id=final_draft_id,
body={"message": {"raw": raw}},
)
.execute()
),
)
except Exception as api_err:
from googleapiclient.errors import HttpError
@ -369,7 +374,9 @@ def create_update_gmail_draft_tool(
document.document_metadata = meta
flag_modified(document, "document_metadata")
await db_session.commit()
kb_message_suffix = " Your knowledge base has also been updated."
kb_message_suffix = (
" Your knowledge base has also been updated."
)
logger.info(
f"KB document {document_id} updated for draft {final_draft_id}"
)

View file

@ -78,7 +78,9 @@ def create_create_calendar_event_tool(
accounts = context.get("accounts", [])
if accounts and all(a.get("auth_expired") for a in accounts):
logger.warning("All Google Calendar accounts have expired authentication")
logger.warning(
"All Google Calendar accounts have expired authentication"
)
return {
"status": "auth_error",
"message": "All connected Google Calendar accounts need re-authentication. Please re-authenticate in your connector settings.",
@ -194,7 +196,10 @@ def create_create_calendar_event_tool(
f"Creating calendar event: summary='{final_summary}', connector={actual_connector_id}"
)
if connector.connector_type == SearchSourceConnectorType.COMPOSIO_GOOGLE_CALENDAR_CONNECTOR:
if (
connector.connector_type
== SearchSourceConnectorType.COMPOSIO_GOOGLE_CALENDAR_CONNECTOR
):
from app.utils.google_credentials import build_composio_credentials
cca_id = connector.config.get("composio_connected_account_id")
@ -216,7 +221,9 @@ def create_create_calendar_event_tool(
token_encryption = TokenEncryption(app_config.SECRET_KEY)
for key in ("token", "refresh_token", "client_secret"):
if config_data.get(key):
config_data[key] = token_encryption.decrypt_token(config_data[key])
config_data[key] = token_encryption.decrypt_token(
config_data[key]
)
exp = config_data.get("expiry", "")
if exp:
@ -254,9 +261,11 @@ def create_create_calendar_event_tool(
try:
created = await asyncio.get_event_loop().run_in_executor(
None,
lambda: service.events()
.insert(calendarId="primary", body=event_body)
.execute(),
lambda: (
service.events()
.insert(calendarId="primary", body=event_body)
.execute()
),
)
except Exception as api_err:
from googleapiclient.errors import HttpError

View file

@ -187,7 +187,10 @@ def create_delete_calendar_event_tool(
f"Deleting calendar event: event_id='{final_event_id}', connector={actual_connector_id}"
)
if connector.connector_type == SearchSourceConnectorType.COMPOSIO_GOOGLE_CALENDAR_CONNECTOR:
if (
connector.connector_type
== SearchSourceConnectorType.COMPOSIO_GOOGLE_CALENDAR_CONNECTOR
):
from app.utils.google_credentials import build_composio_credentials
cca_id = connector.config.get("composio_connected_account_id")
@ -209,7 +212,9 @@ def create_delete_calendar_event_tool(
token_encryption = TokenEncryption(app_config.SECRET_KEY)
for key in ("token", "refresh_token", "client_secret"):
if config_data.get(key):
config_data[key] = token_encryption.decrypt_token(config_data[key])
config_data[key] = token_encryption.decrypt_token(
config_data[key]
)
exp = config_data.get("expiry", "")
if exp:
@ -232,9 +237,11 @@ def create_delete_calendar_event_tool(
try:
await asyncio.get_event_loop().run_in_executor(
None,
lambda: service.events()
.delete(calendarId="primary", eventId=final_event_id)
.execute(),
lambda: (
service.events()
.delete(calendarId="primary", eventId=final_event_id)
.execute()
),
)
except Exception as api_err:
from googleapiclient.errors import HttpError
@ -269,9 +276,7 @@ def create_delete_calendar_event_tool(
}
raise
logger.info(
f"Calendar event deleted: event_id={final_event_id}"
)
logger.info(f"Calendar event deleted: event_id={final_event_id}")
delete_result: dict[str, Any] = {
"status": "success",

View file

@ -58,9 +58,7 @@ def create_update_calendar_event_tool(
- "Reschedule the team standup to 3pm"
- "Change the location of my dentist appointment"
"""
logger.info(
f"update_calendar_event called: event_ref='{event_title_or_id}'"
)
logger.info(f"update_calendar_event called: event_ref='{event_title_or_id}'")
if db_session is None or search_space_id is None or user_id is None:
return {
@ -83,9 +81,7 @@ def create_update_calendar_event_tool(
return {"status": "error", "message": error_msg}
if context.get("auth_expired"):
logger.warning(
"Google Calendar account has expired authentication"
)
logger.warning("Google Calendar account has expired authentication")
return {
"status": "auth_error",
"message": "The Google Calendar account for this event needs re-authentication. Please re-authenticate in your connector settings.",
@ -162,8 +158,12 @@ def create_update_calendar_event_tool(
"connector_id", connector_id_from_context
)
final_new_summary = final_params.get("new_summary", new_summary)
final_new_start_datetime = final_params.get("new_start_datetime", new_start_datetime)
final_new_end_datetime = final_params.get("new_end_datetime", new_end_datetime)
final_new_start_datetime = final_params.get(
"new_start_datetime", new_start_datetime
)
final_new_end_datetime = final_params.get(
"new_end_datetime", new_end_datetime
)
final_new_description = final_params.get("new_description", new_description)
final_new_location = final_params.get("new_location", new_location)
final_new_attendees = final_params.get("new_attendees", new_attendees)
@ -204,7 +204,10 @@ def create_update_calendar_event_tool(
f"Updating calendar event: event_id='{final_event_id}', connector={actual_connector_id}"
)
if connector.connector_type == SearchSourceConnectorType.COMPOSIO_GOOGLE_CALENDAR_CONNECTOR:
if (
connector.connector_type
== SearchSourceConnectorType.COMPOSIO_GOOGLE_CALENDAR_CONNECTOR
):
from app.utils.google_credentials import build_composio_credentials
cca_id = connector.config.get("composio_connected_account_id")
@ -226,7 +229,9 @@ def create_update_calendar_event_tool(
token_encryption = TokenEncryption(app_config.SECRET_KEY)
for key in ("token", "refresh_token", "client_secret"):
if config_data.get(key):
config_data[key] = token_encryption.decrypt_token(config_data[key])
config_data[key] = token_encryption.decrypt_token(
config_data[key]
)
exp = config_data.get("expiry", "")
if exp:
@ -250,11 +255,25 @@ def create_update_calendar_event_tool(
if final_new_summary is not None:
update_body["summary"] = final_new_summary
if final_new_start_datetime is not None:
tz = context.get("timezone", "UTC") if isinstance(context, dict) else "UTC"
update_body["start"] = {"dateTime": final_new_start_datetime, "timeZone": tz}
tz = (
context.get("timezone", "UTC")
if isinstance(context, dict)
else "UTC"
)
update_body["start"] = {
"dateTime": final_new_start_datetime,
"timeZone": tz,
}
if final_new_end_datetime is not None:
tz = context.get("timezone", "UTC") if isinstance(context, dict) else "UTC"
update_body["end"] = {"dateTime": final_new_end_datetime, "timeZone": tz}
tz = (
context.get("timezone", "UTC")
if isinstance(context, dict)
else "UTC"
)
update_body["end"] = {
"dateTime": final_new_end_datetime,
"timeZone": tz,
}
if final_new_description is not None:
update_body["description"] = final_new_description
if final_new_location is not None:
@ -273,9 +292,15 @@ def create_update_calendar_event_tool(
try:
updated = await asyncio.get_event_loop().run_in_executor(
None,
lambda: service.events()
.patch(calendarId="primary", eventId=final_event_id, body=update_body)
.execute(),
lambda: (
service.events()
.patch(
calendarId="primary",
eventId=final_event_id,
body=update_body,
)
.execute()
),
)
except Exception as api_err:
from googleapiclient.errors import HttpError
@ -310,9 +335,7 @@ def create_update_calendar_event_tool(
}
raise
logger.info(
f"Calendar event updated: event_id={final_event_id}"
)
logger.info(f"Calendar event updated: event_id={final_event_id}")
kb_message_suffix = ""
if document_id is not None:
@ -328,7 +351,9 @@ def create_update_calendar_event_tool(
user_id=user_id,
)
if kb_result["status"] == "success":
kb_message_suffix = " Your knowledge base has also been updated."
kb_message_suffix = (
" Your knowledge base has also been updated."
)
else:
kb_message_suffix = " The knowledge base will be updated in the next scheduled sync."
except Exception as kb_err:

View file

@ -208,7 +208,10 @@ def create_create_google_drive_file_tool(
)
pre_built_creds = None
if connector.connector_type == SearchSourceConnectorType.COMPOSIO_GOOGLE_DRIVE_CONNECTOR:
if (
connector.connector_type
== SearchSourceConnectorType.COMPOSIO_GOOGLE_DRIVE_CONNECTOR
):
from app.utils.google_credentials import build_composio_credentials
cca_id = connector.config.get("composio_connected_account_id")

View file

@ -187,7 +187,10 @@ def create_delete_google_drive_file_tool(
)
pre_built_creds = None
if connector.connector_type == SearchSourceConnectorType.COMPOSIO_GOOGLE_DRIVE_CONNECTOR:
if (
connector.connector_type
== SearchSourceConnectorType.COMPOSIO_GOOGLE_DRIVE_CONNECTOR
):
from app.utils.google_credentials import build_composio_credentials
cca_id = connector.config.get("composio_connected_account_id")
@ -210,7 +213,10 @@ def create_delete_google_drive_file_tool(
from sqlalchemy.orm.attributes import flag_modified
if not connector.config.get("auth_expired"):
connector.config = {**connector.config, "auth_expired": True}
connector.config = {
**connector.config,
"auth_expired": True,
}
flag_modified(connector, "config")
await db_session.commit()
except Exception:

View file

@ -45,14 +45,18 @@ def create_create_jira_issue_tool(
- If status is "rejected", the user declined. Do NOT retry.
- If status is "insufficient_permissions", inform user to re-authenticate.
"""
logger.info(f"create_jira_issue called: project_key='{project_key}', summary='{summary}'")
logger.info(
f"create_jira_issue called: project_key='{project_key}', summary='{summary}'"
)
if db_session is None or search_space_id is None or user_id is None:
return {"status": "error", "message": "Jira tool not properly configured."}
try:
metadata_service = JiraToolMetadataService(db_session)
context = await metadata_service.get_creation_context(search_space_id, user_id)
context = await metadata_service.get_creation_context(
search_space_id, user_id
)
if "error" in context:
return {"status": "error", "message": context["error"]}
@ -65,24 +69,30 @@ def create_create_jira_issue_tool(
"connector_type": "jira",
}
approval = interrupt({
"type": "jira_issue_creation",
"action": {
"tool": "create_jira_issue",
"params": {
"project_key": project_key,
"summary": summary,
"issue_type": issue_type,
"description": description,
"priority": priority,
"connector_id": connector_id,
approval = interrupt(
{
"type": "jira_issue_creation",
"action": {
"tool": "create_jira_issue",
"params": {
"project_key": project_key,
"summary": summary,
"issue_type": issue_type,
"description": description,
"priority": priority,
"connector_id": connector_id,
},
},
},
"context": context,
})
"context": context,
}
)
decisions_raw = approval.get("decisions", []) if isinstance(approval, dict) else []
decisions = decisions_raw if isinstance(decisions_raw, list) else [decisions_raw]
decisions_raw = (
approval.get("decisions", []) if isinstance(approval, dict) else []
)
decisions = (
decisions_raw if isinstance(decisions_raw, list) else [decisions_raw]
)
decisions = [d for d in decisions if isinstance(d, dict)]
if not decisions:
return {"status": "error", "message": "No approval decision received"}
@ -91,7 +101,10 @@ def create_create_jira_issue_tool(
decision_type = decision.get("type") or decision.get("decision_type")
if decision_type == "reject":
return {"status": "rejected", "message": "User declined. The issue was not created."}
return {
"status": "rejected",
"message": "User declined. The issue was not created.",
}
final_params: dict[str, Any] = {}
edited_action = decision.get("edited_action")
@ -123,7 +136,8 @@ def create_create_jira_issue_tool(
select(SearchSourceConnector).filter(
SearchSourceConnector.search_space_id == search_space_id,
SearchSourceConnector.user_id == user_id,
SearchSourceConnector.connector_type == SearchSourceConnectorType.JIRA_CONNECTOR,
SearchSourceConnector.connector_type
== SearchSourceConnectorType.JIRA_CONNECTOR,
)
)
connector = result.scalars().first()
@ -136,15 +150,21 @@ def create_create_jira_issue_tool(
SearchSourceConnector.id == actual_connector_id,
SearchSourceConnector.search_space_id == search_space_id,
SearchSourceConnector.user_id == user_id,
SearchSourceConnector.connector_type == SearchSourceConnectorType.JIRA_CONNECTOR,
SearchSourceConnector.connector_type
== SearchSourceConnectorType.JIRA_CONNECTOR,
)
)
connector = result.scalars().first()
if not connector:
return {"status": "error", "message": "Selected Jira connector is invalid."}
return {
"status": "error",
"message": "Selected Jira connector is invalid.",
}
try:
jira_history = JiraHistoryConnector(session=db_session, connector_id=actual_connector_id)
jira_history = JiraHistoryConnector(
session=db_session, connector_id=actual_connector_id
)
jira_client = await jira_history._get_jira_client()
api_result = await asyncio.to_thread(
jira_client.create_issue,
@ -175,6 +195,7 @@ def create_create_jira_issue_tool(
kb_message_suffix = ""
try:
from app.services.jira import JiraKBSyncService
kb_service = JiraKBSyncService(db_session)
kb_result = await kb_service.sync_after_create(
issue_id=issue_key,
@ -202,9 +223,13 @@ def create_create_jira_issue_tool(
except Exception as e:
from langgraph.errors import GraphInterrupt
if isinstance(e, GraphInterrupt):
raise
logger.error(f"Error creating Jira issue: {e}", exc_info=True)
return {"status": "error", "message": "Something went wrong while creating the issue."}
return {
"status": "error",
"message": "Something went wrong while creating the issue.",
}
return create_jira_issue

View file

@ -40,14 +40,18 @@ def create_delete_jira_issue_tool(
- If status is "not_found", relay the message to the user.
- If status is "insufficient_permissions", inform user to re-authenticate.
"""
logger.info(f"delete_jira_issue called: issue_title_or_key='{issue_title_or_key}'")
logger.info(
f"delete_jira_issue called: issue_title_or_key='{issue_title_or_key}'"
)
if db_session is None or search_space_id is None or user_id is None:
return {"status": "error", "message": "Jira tool not properly configured."}
try:
metadata_service = JiraToolMetadataService(db_session)
context = await metadata_service.get_deletion_context(search_space_id, user_id, issue_title_or_key)
context = await metadata_service.get_deletion_context(
search_space_id, user_id, issue_title_or_key
)
if "error" in context:
error_msg = context["error"]
@ -67,21 +71,27 @@ def create_delete_jira_issue_tool(
document_id = issue_data["document_id"]
connector_id_from_context = context.get("account", {}).get("id")
approval = interrupt({
"type": "jira_issue_deletion",
"action": {
"tool": "delete_jira_issue",
"params": {
"issue_key": issue_key,
"connector_id": connector_id_from_context,
"delete_from_kb": delete_from_kb,
approval = interrupt(
{
"type": "jira_issue_deletion",
"action": {
"tool": "delete_jira_issue",
"params": {
"issue_key": issue_key,
"connector_id": connector_id_from_context,
"delete_from_kb": delete_from_kb,
},
},
},
"context": context,
})
"context": context,
}
)
decisions_raw = approval.get("decisions", []) if isinstance(approval, dict) else []
decisions = decisions_raw if isinstance(decisions_raw, list) else [decisions_raw]
decisions_raw = (
approval.get("decisions", []) if isinstance(approval, dict) else []
)
decisions = (
decisions_raw if isinstance(decisions_raw, list) else [decisions_raw]
)
decisions = [d for d in decisions if isinstance(d, dict)]
if not decisions:
return {"status": "error", "message": "No approval decision received"}
@ -90,7 +100,10 @@ def create_delete_jira_issue_tool(
decision_type = decision.get("type") or decision.get("decision_type")
if decision_type == "reject":
return {"status": "rejected", "message": "User declined. The issue was not deleted."}
return {
"status": "rejected",
"message": "User declined. The issue was not deleted.",
}
final_params: dict[str, Any] = {}
edited_action = decision.get("edited_action")
@ -102,29 +115,40 @@ def create_delete_jira_issue_tool(
final_params = decision["args"]
final_issue_key = final_params.get("issue_key", issue_key)
final_connector_id = final_params.get("connector_id", connector_id_from_context)
final_connector_id = final_params.get(
"connector_id", connector_id_from_context
)
final_delete_from_kb = final_params.get("delete_from_kb", delete_from_kb)
from sqlalchemy.future import select
from app.db import SearchSourceConnector, SearchSourceConnectorType
if not final_connector_id:
return {"status": "error", "message": "No connector found for this issue."}
return {
"status": "error",
"message": "No connector found for this issue.",
}
result = await db_session.execute(
select(SearchSourceConnector).filter(
SearchSourceConnector.id == final_connector_id,
SearchSourceConnector.search_space_id == search_space_id,
SearchSourceConnector.user_id == user_id,
SearchSourceConnector.connector_type == SearchSourceConnectorType.JIRA_CONNECTOR,
SearchSourceConnector.connector_type
== SearchSourceConnectorType.JIRA_CONNECTOR,
)
)
connector = result.scalars().first()
if not connector:
return {"status": "error", "message": "Selected Jira connector is invalid."}
return {
"status": "error",
"message": "Selected Jira connector is invalid.",
}
try:
jira_history = JiraHistoryConnector(session=db_session, connector_id=final_connector_id)
jira_history = JiraHistoryConnector(
session=db_session, connector_id=final_connector_id
)
jira_client = await jira_history._get_jira_client()
await asyncio.to_thread(jira_client.delete_issue, final_issue_key)
except Exception as api_err:
@ -146,6 +170,7 @@ def create_delete_jira_issue_tool(
if final_delete_from_kb and document_id:
try:
from app.db import Document
doc_result = await db_session.execute(
select(Document).filter(Document.id == document_id)
)
@ -171,9 +196,13 @@ def create_delete_jira_issue_tool(
except Exception as e:
from langgraph.errors import GraphInterrupt
if isinstance(e, GraphInterrupt):
raise
logger.error(f"Error deleting Jira issue: {e}", exc_info=True)
return {"status": "error", "message": "Something went wrong while deleting the issue."}
return {
"status": "error",
"message": "Something went wrong while deleting the issue.",
}
return delete_jira_issue

View file

@ -44,14 +44,18 @@ def create_update_jira_issue_tool(
- If status is "not_found", relay the message and ask user to verify.
- If status is "insufficient_permissions", inform user to re-authenticate.
"""
logger.info(f"update_jira_issue called: issue_title_or_key='{issue_title_or_key}'")
logger.info(
f"update_jira_issue called: issue_title_or_key='{issue_title_or_key}'"
)
if db_session is None or search_space_id is None or user_id is None:
return {"status": "error", "message": "Jira tool not properly configured."}
try:
metadata_service = JiraToolMetadataService(db_session)
context = await metadata_service.get_update_context(search_space_id, user_id, issue_title_or_key)
context = await metadata_service.get_update_context(
search_space_id, user_id, issue_title_or_key
)
if "error" in context:
error_msg = context["error"]
@ -71,24 +75,30 @@ def create_update_jira_issue_tool(
document_id = issue_data.get("document_id")
connector_id_from_context = context.get("account", {}).get("id")
approval = interrupt({
"type": "jira_issue_update",
"action": {
"tool": "update_jira_issue",
"params": {
"issue_key": issue_key,
"document_id": document_id,
"new_summary": new_summary,
"new_description": new_description,
"new_priority": new_priority,
"connector_id": connector_id_from_context,
approval = interrupt(
{
"type": "jira_issue_update",
"action": {
"tool": "update_jira_issue",
"params": {
"issue_key": issue_key,
"document_id": document_id,
"new_summary": new_summary,
"new_description": new_description,
"new_priority": new_priority,
"connector_id": connector_id_from_context,
},
},
},
"context": context,
})
"context": context,
}
)
decisions_raw = approval.get("decisions", []) if isinstance(approval, dict) else []
decisions = decisions_raw if isinstance(decisions_raw, list) else [decisions_raw]
decisions_raw = (
approval.get("decisions", []) if isinstance(approval, dict) else []
)
decisions = (
decisions_raw if isinstance(decisions_raw, list) else [decisions_raw]
)
decisions = [d for d in decisions if isinstance(d, dict)]
if not decisions:
return {"status": "error", "message": "No approval decision received"}
@ -97,7 +107,10 @@ def create_update_jira_issue_tool(
decision_type = decision.get("type") or decision.get("decision_type")
if decision_type == "reject":
return {"status": "rejected", "message": "User declined. The issue was not updated."}
return {
"status": "rejected",
"message": "User declined. The issue was not updated.",
}
final_params: dict[str, Any] = {}
edited_action = decision.get("edited_action")
@ -112,26 +125,35 @@ def create_update_jira_issue_tool(
final_summary = final_params.get("new_summary", new_summary)
final_description = final_params.get("new_description", new_description)
final_priority = final_params.get("new_priority", new_priority)
final_connector_id = final_params.get("connector_id", connector_id_from_context)
final_connector_id = final_params.get(
"connector_id", connector_id_from_context
)
final_document_id = final_params.get("document_id", document_id)
from sqlalchemy.future import select
from app.db import SearchSourceConnector, SearchSourceConnectorType
if not final_connector_id:
return {"status": "error", "message": "No connector found for this issue."}
return {
"status": "error",
"message": "No connector found for this issue.",
}
result = await db_session.execute(
select(SearchSourceConnector).filter(
SearchSourceConnector.id == final_connector_id,
SearchSourceConnector.search_space_id == search_space_id,
SearchSourceConnector.user_id == user_id,
SearchSourceConnector.connector_type == SearchSourceConnectorType.JIRA_CONNECTOR,
SearchSourceConnector.connector_type
== SearchSourceConnectorType.JIRA_CONNECTOR,
)
)
connector = result.scalars().first()
if not connector:
return {"status": "error", "message": "Selected Jira connector is invalid."}
return {
"status": "error",
"message": "Selected Jira connector is invalid.",
}
fields: dict[str, Any] = {}
if final_summary:
@ -140,7 +162,12 @@ def create_update_jira_issue_tool(
fields["description"] = {
"type": "doc",
"version": 1,
"content": [{"type": "paragraph", "content": [{"type": "text", "text": final_description}]}],
"content": [
{
"type": "paragraph",
"content": [{"type": "text", "text": final_description}],
}
],
}
if final_priority:
fields["priority"] = {"name": final_priority}
@ -149,9 +176,13 @@ def create_update_jira_issue_tool(
return {"status": "error", "message": "No changes specified."}
try:
jira_history = JiraHistoryConnector(session=db_session, connector_id=final_connector_id)
jira_history = JiraHistoryConnector(
session=db_session, connector_id=final_connector_id
)
jira_client = await jira_history._get_jira_client()
await asyncio.to_thread(jira_client.update_issue, final_issue_key, fields)
await asyncio.to_thread(
jira_client.update_issue, final_issue_key, fields
)
except Exception as api_err:
if "status code 403" in str(api_err).lower():
try:
@ -171,6 +202,7 @@ def create_update_jira_issue_tool(
if final_document_id:
try:
from app.services.jira import JiraKBSyncService
kb_service = JiraKBSyncService(db_session)
kb_result = await kb_service.sync_after_update(
document_id=final_document_id,
@ -179,12 +211,18 @@ def create_update_jira_issue_tool(
search_space_id=search_space_id,
)
if kb_result["status"] == "success":
kb_message_suffix = " Your knowledge base has also been updated."
kb_message_suffix = (
" Your knowledge base has also been updated."
)
else:
kb_message_suffix = " The knowledge base will be updated in the next sync."
kb_message_suffix = (
" The knowledge base will be updated in the next sync."
)
except Exception as kb_err:
logger.warning(f"KB sync after update failed: {kb_err}")
kb_message_suffix = " The knowledge base will be updated in the next sync."
kb_message_suffix = (
" The knowledge base will be updated in the next sync."
)
return {
"status": "success",
@ -194,9 +232,13 @@ def create_update_jira_issue_tool(
except Exception as e:
from langgraph.errors import GraphInterrupt
if isinstance(e, GraphInterrupt):
raise
logger.error(f"Error updating Jira issue: {e}", exc_info=True)
return {"status": "error", "message": "Something went wrong while updating the issue."}
return {
"status": "error",
"message": "Something went wrong while updating the issue.",
}
return update_jira_issue

View file

@ -43,6 +43,7 @@ _DEGENERATE_QUERY_RE = re.compile(
# a real search. We want breadth (many docs) over depth (many chunks).
_BROWSE_MAX_CHUNKS_PER_DOC = 5
def _is_degenerate_query(query: str) -> bool:
"""Return True when the query carries no meaningful search signal.
@ -82,7 +83,9 @@ async def _browse_recent_documents(
base_conditions = [Document.search_space_id == search_space_id]
if document_type is not None:
type_list = document_type if isinstance(document_type, list) else [document_type]
type_list = (
document_type if isinstance(document_type, list) else [document_type]
)
doc_type_enums = []
for dt in type_list:
if isinstance(dt, str):

View file

@ -245,7 +245,9 @@ def create_create_notion_page_tool(
user_id=user_id,
)
if kb_result["status"] == "success":
kb_message_suffix = " Your knowledge base has also been updated."
kb_message_suffix = (
" Your knowledge base has also been updated."
)
else:
kb_message_suffix = " This page will be added to your knowledge base in the next scheduled sync."
except Exception as kb_err:

View file

@ -280,7 +280,9 @@ def create_delete_notion_page_tool(
return {
"status": "auth_error",
"message": str(e),
"connector_id": connector_id_from_context if "connector_id_from_context" in dir() else None,
"connector_id": connector_id_from_context
if "connector_id_from_context" in dir()
else None,
"connector_type": "notion",
}
if isinstance(e, ValueError | NotionAPIError):

View file

@ -281,7 +281,9 @@ def create_update_notion_page_tool(
return {
"status": "auth_error",
"message": str(e),
"connector_id": connector_id_from_context if "connector_id_from_context" in dir() else None,
"connector_id": connector_id_from_context
if "connector_id_from_context" in dir()
else None,
"connector_type": "notion",
}
if isinstance(e, ValueError | NotionAPIError):

View file

@ -341,7 +341,7 @@ if config.NEXT_FRONTEND_URL:
allowed_origins.append(www_url)
allowed_origins.extend(
[ # For local development and desktop app
[ # For local development and desktop app
"http://localhost:3000",
"http://127.0.0.1:3000",
]

View file

@ -190,7 +190,9 @@ class ConfluenceHistoryConnector:
)
# Lazy import to avoid circular dependency
from app.routes.confluence_add_connector_route import refresh_confluence_token
from app.routes.confluence_add_connector_route import (
refresh_confluence_token,
)
connector = await refresh_confluence_token(self._session, connector)
@ -375,13 +377,9 @@ class ConfluenceHistoryConnector:
url, headers=headers, json=json_payload, params=params
)
elif method_upper == "DELETE":
response = await http_client.delete(
url, headers=headers, params=params
)
response = await http_client.delete(url, headers=headers, params=params)
else:
response = await http_client.get(
url, headers=headers, params=params
)
response = await http_client.get(url, headers=headers, params=params)
response.raise_for_status()
if response.status_code == 204 or not response.text:

View file

@ -60,9 +60,7 @@ class GoogleCalendarConnector:
has_standard_refresh = bool(self._credentials.refresh_token)
if has_standard_refresh:
if not all(
[self._credentials.client_id, self._credentials.client_secret]
):
if not all([self._credentials.client_id, self._credentials.client_secret]):
raise ValueError(
"Google OAuth credentials (client_id, client_secret) must be set"
)

View file

@ -89,9 +89,7 @@ class GoogleGmailConnector:
has_standard_refresh = bool(self._credentials.refresh_token)
if has_standard_refresh:
if not all(
[self._credentials.client_id, self._credentials.client_secret]
):
if not all([self._credentials.client_id, self._credentials.client_secret]):
raise ValueError(
"Google OAuth credentials (client_id, client_secret) must be set"
)
@ -139,17 +137,13 @@ class GoogleGmailConnector:
from app.utils.oauth_security import TokenEncryption
creds_dict = json.loads(self._credentials.to_json())
token_encrypted = connector.config.get(
"_token_encrypted", False
)
token_encrypted = connector.config.get("_token_encrypted", False)
if token_encrypted and config.SECRET_KEY:
token_encryption = TokenEncryption(config.SECRET_KEY)
if creds_dict.get("token"):
creds_dict["token"] = (
token_encryption.encrypt_token(
creds_dict["token"]
)
creds_dict["token"] = token_encryption.encrypt_token(
creds_dict["token"]
)
if creds_dict.get("refresh_token"):
creds_dict["refresh_token"] = (

View file

@ -219,7 +219,9 @@ class ChucksHybridSearchRetriever:
# Add document type filter if provided (single string or list of strings)
if document_type is not None:
type_list = document_type if isinstance(document_type, list) else [document_type]
type_list = (
document_type if isinstance(document_type, list) else [document_type]
)
doc_type_enums = []
for dt in type_list:
if isinstance(dt, str):

View file

@ -199,7 +199,9 @@ class DocumentHybridSearchRetriever:
# Add document type filter if provided (single string or list of strings)
if document_type is not None:
type_list = document_type if isinstance(document_type, list) else [document_type]
type_list = (
document_type if isinstance(document_type, list) else [document_type]
)
doc_type_enums = []
for dt in type_list:
if isinstance(dt, str):

View file

@ -461,10 +461,14 @@ async def reauth_composio_connector(
return_url: Optional frontend path to redirect to after completion
"""
if not ComposioService.is_enabled():
raise HTTPException(status_code=503, detail="Composio integration is not enabled.")
raise HTTPException(
status_code=503, detail="Composio integration is not enabled."
)
if not config.SECRET_KEY:
raise HTTPException(status_code=500, detail="SECRET_KEY not configured for OAuth security.")
raise HTTPException(
status_code=500, detail="SECRET_KEY not configured for OAuth security."
)
try:
result = await session.execute(
@ -502,7 +506,9 @@ async def reauth_composio_connector(
callback_base = config.COMPOSIO_REDIRECT_URI
if not callback_base:
backend_url = config.BACKEND_URL or "http://localhost:8000"
callback_base = f"{backend_url}/api/v1/auth/composio/connector/reauth/callback"
callback_base = (
f"{backend_url}/api/v1/auth/composio/connector/reauth/callback"
)
else:
# Replace the normal callback path with the reauth one
callback_base = callback_base.replace(
@ -524,8 +530,13 @@ async def reauth_composio_connector(
connector.config = {**connector.config, "auth_expired": False}
flag_modified(connector, "config")
await session.commit()
logger.info(f"Composio account {connected_account_id} refreshed server-side (no redirect needed)")
return {"success": True, "message": "Authentication refreshed successfully."}
logger.info(
f"Composio account {connected_account_id} refreshed server-side (no redirect needed)"
)
return {
"success": True,
"message": "Authentication refreshed successfully.",
}
logger.info(f"Initiating Composio re-auth for connector {connector_id}")
return {"auth_url": refresh_result["redirect_url"]}
@ -679,9 +690,7 @@ async def list_composio_drive_folders(
)
credentials = build_composio_credentials(composio_connected_account_id)
drive_client = GoogleDriveClient(
session, connector_id, credentials=credentials
)
drive_client = GoogleDriveClient(session, connector_id, credentials=credentials)
items, error = await list_folder_contents(drive_client, parent_id=parent_id)
@ -699,11 +708,17 @@ async def list_composio_drive_folders(
connector.config = {**connector.config, "auth_expired": True}
flag_modified(connector, "config")
await session.commit()
logger.info(f"Marked Composio connector {connector_id} as auth_expired")
logger.info(
f"Marked Composio connector {connector_id} as auth_expired"
)
except Exception:
logger.warning(f"Failed to persist auth_expired for connector {connector_id}", exc_info=True)
logger.warning(
f"Failed to persist auth_expired for connector {connector_id}",
exc_info=True,
)
raise HTTPException(
status_code=400, detail="Google Drive authentication expired. Please re-authenticate."
status_code=400,
detail="Google Drive authentication expired. Please re-authenticate.",
)
raise HTTPException(
status_code=500, detail=f"Failed to list folder contents: {error}"
@ -736,11 +751,17 @@ async def list_composio_drive_folders(
connector.config = {**connector.config, "auth_expired": True}
flag_modified(connector, "config")
await session.commit()
logger.info(f"Marked Composio connector {connector_id} as auth_expired")
logger.info(
f"Marked Composio connector {connector_id} as auth_expired"
)
except Exception:
logger.warning(f"Failed to persist auth_expired for connector {connector_id}", exc_info=True)
logger.warning(
f"Failed to persist auth_expired for connector {connector_id}",
exc_info=True,
)
raise HTTPException(
status_code=400, detail="Google Drive authentication expired. Please re-authenticate."
status_code=400,
detail="Google Drive authentication expired. Please re-authenticate.",
) from e
raise HTTPException(
status_code=500, detail=f"Failed to list Drive contents: {e!s}"

View file

@ -520,9 +520,13 @@ async def list_google_drive_folders(
await session.commit()
logger.info(f"Marked connector {connector_id} as auth_expired")
except Exception:
logger.warning(f"Failed to persist auth_expired for connector {connector_id}", exc_info=True)
logger.warning(
f"Failed to persist auth_expired for connector {connector_id}",
exc_info=True,
)
raise HTTPException(
status_code=400, detail="Google Drive authentication expired. Please re-authenticate."
status_code=400,
detail="Google Drive authentication expired. Please re-authenticate.",
)
raise HTTPException(
status_code=500, detail=f"Failed to list folder contents: {error}"
@ -562,9 +566,13 @@ async def list_google_drive_folders(
await session.commit()
logger.info(f"Marked connector {connector_id} as auth_expired")
except Exception:
logger.warning(f"Failed to persist auth_expired for connector {connector_id}", exc_info=True)
logger.warning(
f"Failed to persist auth_expired for connector {connector_id}",
exc_info=True,
)
raise HTTPException(
status_code=400, detail="Google Drive authentication expired. Please re-authenticate."
status_code=400,
detail="Google Drive authentication expired. Please re-authenticate.",
) from e
raise HTTPException(
status_code=500, detail=f"Failed to list Drive contents: {e!s}"

View file

@ -580,7 +580,9 @@ async def refresh_linear_token(
credentials_dict = credentials.to_dict()
credentials_dict["_token_encrypted"] = True
if connector.config.get("organization_name"):
credentials_dict["organization_name"] = connector.config["organization_name"]
credentials_dict["organization_name"] = connector.config[
"organization_name"
]
credentials_dict.pop("auth_expired", None)
connector.config = credentials_dict
flag_modified(connector, "config")

View file

@ -2374,7 +2374,11 @@ async def run_google_drive_indexing(
# Index each folder with indexing options
for folder in items.folders:
try:
indexed_count, skipped_count, error_message = await index_google_drive_files(
(
indexed_count,
skipped_count,
error_message,
) = await index_google_drive_files(
session,
connector_id,
search_space_id,
@ -2429,7 +2433,9 @@ async def run_google_drive_indexing(
)
if _is_auth_error(error_message):
await _persist_auth_expired(session, connector_id)
error_message = "Google Drive authentication expired. Please re-authenticate."
error_message = (
"Google Drive authentication expired. Please re-authenticate."
)
else:
# Update notification to storing stage
if notification:

View file

@ -283,9 +283,7 @@ class ComposioService:
timeout=timeout,
)
status = getattr(account, "status", "UNKNOWN")
logger.info(
f"Composio account {connected_account_id} is now {status}"
)
logger.info(f"Composio account {connected_account_id} is now {status}")
return status
except Exception as e:
logger.error(

View file

@ -67,7 +67,10 @@ class ConfluenceKBSyncService:
content_hash = unique_hash
user_llm = await get_user_long_context_llm(
self.db_session, user_id, search_space_id, disable_streaming=True,
self.db_session,
user_id,
search_space_id,
disable_streaming=True,
)
doc_metadata_for_summary = {
@ -116,17 +119,26 @@ class ConfluenceKBSyncService:
logger.info(
"KB sync after create succeeded: doc_id=%s, page=%s",
document.id, page_title,
document.id,
page_title,
)
return {"status": "success"}
except Exception as e:
error_str = str(e).lower()
if "duplicate key value violates unique constraint" in error_str or "uniqueviolationerror" in error_str:
if (
"duplicate key value violates unique constraint" in error_str
or "uniqueviolationerror" in error_str
):
await self.db_session.rollback()
return {"status": "error", "message": "Duplicate document detected"}
logger.error("KB sync after create failed for page %s: %s", page_title, e, exc_info=True)
logger.error(
"KB sync after create failed for page %s: %s",
page_title,
e,
exc_info=True,
)
await self.db_session.rollback()
return {"status": "error", "message": str(e)}
@ -215,11 +227,14 @@ class ConfluenceKBSyncService:
logger.info(
"KB sync successful for document %s (%s)",
document_id, page_title,
document_id,
page_title,
)
return {"status": "success"}
except Exception as e:
logger.error("KB sync failed for document %s: %s", document_id, e, exc_info=True)
logger.error(
"KB sync failed for document %s: %s", document_id, e, exc_info=True
)
await self.db_session.rollback()
return {"status": "error", "message": str(e)}

View file

@ -83,7 +83,7 @@ class ConfluenceToolMetadataService:
async def _check_account_health(self, connector: SearchSourceConnector) -> bool:
"""Check if the Confluence connector auth is still valid.
Returns True if auth is expired/invalid, False if healthy.
"""
try:
@ -112,7 +112,7 @@ class ConfluenceToolMetadataService:
async def get_creation_context(self, search_space_id: int, user_id: str) -> dict:
"""Return context needed to create a new Confluence page.
Fetches all connected accounts, and for the first healthy one fetches spaces.
"""
connectors = await self._get_all_confluence_connectors(search_space_id, user_id)
@ -126,10 +126,12 @@ class ConfluenceToolMetadataService:
for connector in connectors:
auth_expired = await self._check_account_health(connector)
workspace = ConfluenceWorkspace.from_connector(connector)
accounts.append({
**workspace.to_dict(),
"auth_expired": auth_expired,
})
accounts.append(
{
**workspace.to_dict(),
"auth_expired": auth_expired,
}
)
if not auth_expired and not fetched_context:
try:
@ -146,7 +148,8 @@ class ConfluenceToolMetadataService:
except Exception as e:
logger.warning(
"Failed to fetch Confluence spaces for connector %s: %s",
connector.id, e,
connector.id,
e,
)
return {
@ -158,7 +161,7 @@ class ConfluenceToolMetadataService:
self, search_space_id: int, user_id: str, page_ref: str
) -> dict:
"""Return context needed to update an indexed Confluence page.
Resolves the page from KB, then fetches current content and version from API.
"""
document = await self._resolve_page(search_space_id, user_id, page_ref)
@ -191,7 +194,11 @@ class ConfluenceToolMetadataService:
await client.close()
except Exception as e:
error_str = str(e).lower()
if "401" in error_str or "403" in error_str or "authentication" in error_str:
if (
"401" in error_str
or "403" in error_str
or "authentication" in error_str
):
return {
"error": f"Failed to fetch Confluence page: {e!s}",
"auth_expired": True,
@ -207,7 +214,9 @@ class ConfluenceToolMetadataService:
body_storage = storage.get("value", "")
version_obj = page_data.get("version", {})
version_number = version_obj.get("number", 1) if isinstance(version_obj, dict) else 1
version_number = (
version_obj.get("number", 1) if isinstance(version_obj, dict) else 1
)
return {
"account": {**workspace.to_dict(), "auth_expired": False},
@ -263,9 +272,7 @@ class ConfluenceToolMetadataService:
Document.document_type == DocumentType.CONFLUENCE_CONNECTOR,
SearchSourceConnector.user_id == user_id,
or_(
func.lower(
Document.document_metadata.op("->>")("page_title")
)
func.lower(Document.document_metadata.op("->>")("page_title"))
== ref_lower,
func.lower(Document.title) == ref_lower,
),

View file

@ -183,10 +183,12 @@ class GmailToolMetadataService:
and_(
SearchSourceConnector.search_space_id == search_space_id,
SearchSourceConnector.user_id == user_id,
SearchSourceConnector.connector_type.in_([
SearchSourceConnectorType.GOOGLE_GMAIL_CONNECTOR,
SearchSourceConnectorType.COMPOSIO_GMAIL_CONNECTOR,
]),
SearchSourceConnector.connector_type.in_(
[
SearchSourceConnectorType.GOOGLE_GMAIL_CONNECTOR,
SearchSourceConnectorType.COMPOSIO_GMAIL_CONNECTOR,
]
),
)
)
.order_by(SearchSourceConnector.last_indexed_at.desc())
@ -223,9 +225,7 @@ class GmailToolMetadataService:
service = build("gmail", "v1", credentials=creds)
profile = await asyncio.get_event_loop().run_in_executor(
None,
lambda: service.users()
.getProfile(userId="me")
.execute(),
lambda: service.users().getProfile(userId="me").execute(),
)
acc_dict["email"] = profile.get("emailAddress", "")
except Exception:
@ -306,10 +306,12 @@ class GmailToolMetadataService:
draft = await asyncio.get_event_loop().run_in_executor(
None,
lambda: service.users()
.drafts()
.get(userId="me", id=draft_id, format="full")
.execute(),
lambda: (
service.users()
.drafts()
.get(userId="me", id=draft_id, format="full")
.execute()
),
)
payload = draft.get("message", {}).get("payload", {})
@ -422,15 +424,15 @@ class GmailToolMetadataService:
.filter(
and_(
Document.search_space_id == search_space_id,
Document.document_type.in_([
DocumentType.GOOGLE_GMAIL_CONNECTOR,
DocumentType.COMPOSIO_GMAIL_CONNECTOR,
]),
Document.document_type.in_(
[
DocumentType.GOOGLE_GMAIL_CONNECTOR,
DocumentType.COMPOSIO_GMAIL_CONNECTOR,
]
),
SearchSourceConnector.user_id == user_id,
or_(
func.lower(
cast(Document.document_metadata["subject"], String)
)
func.lower(cast(Document.document_metadata["subject"], String))
== func.lower(email_ref),
func.lower(Document.title) == func.lower(email_ref),
),

View file

@ -8,7 +8,12 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select
from sqlalchemy.orm.attributes import flag_modified
from app.db import Document, DocumentType, SearchSourceConnector, SearchSourceConnectorType
from app.db import (
Document,
DocumentType,
SearchSourceConnector,
SearchSourceConnectorType,
)
from app.services.llm_service import get_user_long_context_llm
from app.utils.document_converters import (
create_document_chunks,
@ -107,7 +112,9 @@ class GoogleCalendarKBSyncService:
)
else:
logger.warning("No LLM configured -- using fallback summary")
summary_content = f"Google Calendar Event: {event_summary}\n\n{indexable_content}"
summary_content = (
f"Google Calendar Event: {event_summary}\n\n{indexable_content}"
)
summary_embedding = embed_text(summary_content)
chunks = await create_document_chunks(indexable_content)
@ -201,12 +208,16 @@ class GoogleCalendarKBSyncService:
None, lambda: build("calendar", "v3", credentials=creds)
)
calendar_id = (document.document_metadata or {}).get("calendar_id", "primary")
calendar_id = (document.document_metadata or {}).get(
"calendar_id", "primary"
)
live_event = await loop.run_in_executor(
None,
lambda: service.events()
.get(calendarId=calendar_id, eventId=event_id)
.execute(),
lambda: (
service.events()
.get(calendarId=calendar_id, eventId=event_id)
.execute()
),
)
event_summary = live_event.get("summary", "")
@ -220,7 +231,10 @@ class GoogleCalendarKBSyncService:
end_time = end_data.get("dateTime", end_data.get("date", ""))
attendees = [
{"email": a.get("email", ""), "responseStatus": a.get("responseStatus", "")}
{
"email": a.get("email", ""),
"responseStatus": a.get("responseStatus", ""),
}
for a in live_event.get("attendees", [])
]
@ -252,7 +266,9 @@ class GoogleCalendarKBSyncService:
indexable_content, user_llm, doc_metadata_for_summary
)
else:
summary_content = f"Google Calendar Event: {event_summary}\n\n{indexable_content}"
summary_content = (
f"Google Calendar Event: {event_summary}\n\n{indexable_content}"
)
summary_embedding = embed_text(summary_content)
chunks = await create_document_chunks(indexable_content)
@ -313,7 +329,10 @@ class GoogleCalendarKBSyncService:
if not connector:
raise ValueError(f"Connector {connector_id} not found")
if connector.connector_type == SearchSourceConnectorType.COMPOSIO_GOOGLE_CALENDAR_CONNECTOR:
if (
connector.connector_type
== SearchSourceConnectorType.COMPOSIO_GOOGLE_CALENDAR_CONNECTOR
):
cca_id = connector.config.get("composio_connected_account_id")
if cca_id:
return build_composio_credentials(cca_id)
@ -328,11 +347,17 @@ class GoogleCalendarKBSyncService:
if token_encrypted and app_config.SECRET_KEY:
token_encryption = TokenEncryption(app_config.SECRET_KEY)
if config_data.get("token"):
config_data["token"] = token_encryption.decrypt_token(config_data["token"])
config_data["token"] = token_encryption.decrypt_token(
config_data["token"]
)
if config_data.get("refresh_token"):
config_data["refresh_token"] = token_encryption.decrypt_token(config_data["refresh_token"])
config_data["refresh_token"] = token_encryption.decrypt_token(
config_data["refresh_token"]
)
if config_data.get("client_secret"):
config_data["client_secret"] = token_encryption.decrypt_token(config_data["client_secret"])
config_data["client_secret"] = token_encryption.decrypt_token(
config_data["client_secret"]
)
exp = config_data.get("expiry", "")
if exp:

View file

@ -37,7 +37,9 @@ class GoogleCalendarAccount:
name: str
@classmethod
def from_connector(cls, connector: SearchSourceConnector) -> "GoogleCalendarAccount":
def from_connector(
cls, connector: SearchSourceConnector
) -> "GoogleCalendarAccount":
return cls(id=connector.id, name=connector.name)
def to_dict(self) -> dict:
@ -93,7 +95,10 @@ class GoogleCalendarToolMetadataService:
self._db_session = db_session
async def _build_credentials(self, connector: SearchSourceConnector) -> Credentials:
if connector.connector_type == SearchSourceConnectorType.COMPOSIO_GOOGLE_CALENDAR_CONNECTOR:
if (
connector.connector_type
== SearchSourceConnectorType.COMPOSIO_GOOGLE_CALENDAR_CONNECTOR
):
cca_id = connector.config.get("composio_connected_account_id")
if cca_id:
return build_composio_credentials(cca_id)
@ -108,11 +113,17 @@ class GoogleCalendarToolMetadataService:
if token_encrypted and app_config.SECRET_KEY:
token_encryption = TokenEncryption(app_config.SECRET_KEY)
if config_data.get("token"):
config_data["token"] = token_encryption.decrypt_token(config_data["token"])
config_data["token"] = token_encryption.decrypt_token(
config_data["token"]
)
if config_data.get("refresh_token"):
config_data["refresh_token"] = token_encryption.decrypt_token(config_data["refresh_token"])
config_data["refresh_token"] = token_encryption.decrypt_token(
config_data["refresh_token"]
)
if config_data.get("client_secret"):
config_data["client_secret"] = token_encryption.decrypt_token(config_data["client_secret"])
config_data["client_secret"] = token_encryption.decrypt_token(
config_data["client_secret"]
)
exp = config_data.get("expiry", "")
if exp:
@ -149,10 +160,12 @@ class GoogleCalendarToolMetadataService:
loop = asyncio.get_event_loop()
await loop.run_in_executor(
None,
lambda: build("calendar", "v3", credentials=creds)
.calendarList()
.list(maxResults=1)
.execute(),
lambda: (
build("calendar", "v3", credentials=creds)
.calendarList()
.list(maxResults=1)
.execute()
),
)
return False
except Exception as e:
@ -252,11 +265,13 @@ class GoogleCalendarToolMetadataService:
None, lambda: service.calendarList().list().execute()
)
for cal in cal_list.get("items", []):
calendars.append({
"id": cal.get("id", ""),
"summary": cal.get("summary", ""),
"primary": cal.get("primary", False),
})
calendars.append(
{
"id": cal.get("id", ""),
"summary": cal.get("summary", ""),
"primary": cal.get("primary", False),
}
)
tz_setting = await loop.run_in_executor(
None,
@ -314,23 +329,34 @@ class GoogleCalendarToolMetadataService:
calendar_id = event.calendar_id or "primary"
live_event = await loop.run_in_executor(
None,
lambda: service.events()
.get(calendarId=calendar_id, eventId=event.event_id)
.execute(),
lambda: (
service.events()
.get(calendarId=calendar_id, eventId=event.event_id)
.execute()
),
)
event_dict["summary"] = live_event.get("summary", event_dict["summary"])
event_dict["description"] = live_event.get("description", event_dict["description"])
event_dict["description"] = live_event.get(
"description", event_dict["description"]
)
event_dict["location"] = live_event.get("location", event_dict["location"])
start_data = live_event.get("start", {})
event_dict["start"] = start_data.get("dateTime", start_data.get("date", event_dict["start"]))
event_dict["start"] = start_data.get(
"dateTime", start_data.get("date", event_dict["start"])
)
end_data = live_event.get("end", {})
event_dict["end"] = end_data.get("dateTime", end_data.get("date", event_dict["end"]))
event_dict["end"] = end_data.get(
"dateTime", end_data.get("date", event_dict["end"])
)
event_dict["attendees"] = [
{"email": a.get("email", ""), "responseStatus": a.get("responseStatus", "")}
{
"email": a.get("email", ""),
"responseStatus": a.get("responseStatus", ""),
}
for a in live_event.get("attendees", [])
]
except Exception:

View file

@ -56,7 +56,9 @@ class GoogleDriveKBSyncService:
indexable_content = (content or "").strip()
if not indexable_content:
indexable_content = f"Google Drive file: {file_name} (type: {mime_type})"
indexable_content = (
f"Google Drive file: {file_name} (type: {mime_type})"
)
content_hash = generate_content_hash(indexable_content, search_space_id)
@ -93,7 +95,9 @@ class GoogleDriveKBSyncService:
)
else:
logger.warning("No LLM configured — using fallback summary")
summary_content = f"Google Drive File: {file_name}\n\n{indexable_content}"
summary_content = (
f"Google Drive File: {file_name}\n\n{indexable_content}"
)
summary_embedding = embed_text(summary_content)
chunks = await create_document_chunks(indexable_content)

View file

@ -133,10 +133,12 @@ class GoogleDriveToolMetadataService:
and_(
SearchSourceConnector.id == document.connector_id,
SearchSourceConnector.user_id == user_id,
SearchSourceConnector.connector_type.in_([
SearchSourceConnectorType.GOOGLE_DRIVE_CONNECTOR,
SearchSourceConnectorType.COMPOSIO_GOOGLE_DRIVE_CONNECTOR,
]),
SearchSourceConnector.connector_type.in_(
[
SearchSourceConnectorType.GOOGLE_DRIVE_CONNECTOR,
SearchSourceConnectorType.COMPOSIO_GOOGLE_DRIVE_CONNECTOR,
]
),
)
)
)
@ -168,10 +170,12 @@ class GoogleDriveToolMetadataService:
and_(
SearchSourceConnector.search_space_id == search_space_id,
SearchSourceConnector.user_id == user_id,
SearchSourceConnector.connector_type.in_([
SearchSourceConnectorType.GOOGLE_DRIVE_CONNECTOR,
SearchSourceConnectorType.COMPOSIO_GOOGLE_DRIVE_CONNECTOR,
]),
SearchSourceConnector.connector_type.in_(
[
SearchSourceConnectorType.GOOGLE_DRIVE_CONNECTOR,
SearchSourceConnectorType.COMPOSIO_GOOGLE_DRIVE_CONNECTOR,
]
),
)
)
.order_by(SearchSourceConnector.last_indexed_at.desc())

View file

@ -53,7 +53,8 @@ class JiraKBSyncService:
if existing:
logger.info(
"Document for Jira issue %s already exists (doc_id=%s), skipping",
issue_identifier, existing.id,
issue_identifier,
existing.id,
)
return {"status": "success"}
@ -61,7 +62,9 @@ class JiraKBSyncService:
if not indexable_content:
indexable_content = f"Jira Issue {issue_identifier}: {issue_title}"
issue_content = f"# {issue_identifier}: {issue_title}\n\n{indexable_content}"
issue_content = (
f"# {issue_identifier}: {issue_title}\n\n{indexable_content}"
)
content_hash = generate_content_hash(issue_content, search_space_id)
@ -73,7 +76,10 @@ class JiraKBSyncService:
content_hash = unique_hash
user_llm = await get_user_long_context_llm(
self.db_session, user_id, search_space_id, disable_streaming=True,
self.db_session,
user_id,
search_space_id,
disable_streaming=True,
)
doc_metadata_for_summary = {
@ -88,7 +94,9 @@ class JiraKBSyncService:
issue_content, user_llm, doc_metadata_for_summary
)
else:
summary_content = f"Jira Issue {issue_identifier}: {issue_title}\n\n{issue_content}"
summary_content = (
f"Jira Issue {issue_identifier}: {issue_title}\n\n{issue_content}"
)
summary_embedding = embed_text(summary_content)
chunks = await create_document_chunks(issue_content)
@ -122,17 +130,26 @@ class JiraKBSyncService:
logger.info(
"KB sync after create succeeded: doc_id=%s, issue=%s",
document.id, issue_identifier,
document.id,
issue_identifier,
)
return {"status": "success"}
except Exception as e:
error_str = str(e).lower()
if "duplicate key value violates unique constraint" in error_str or "uniqueviolationerror" in error_str:
if (
"duplicate key value violates unique constraint" in error_str
or "uniqueviolationerror" in error_str
):
await self.db_session.rollback()
return {"status": "error", "message": "Duplicate document detected"}
logger.error("KB sync after create failed for issue %s: %s", issue_identifier, e, exc_info=True)
logger.error(
"KB sync after create failed for issue %s: %s",
issue_identifier,
e,
exc_info=True,
)
await self.db_session.rollback()
return {"status": "error", "message": str(e)}
@ -189,14 +206,18 @@ class JiraKBSyncService:
issue_content, user_llm, doc_meta
)
else:
summary_content = f"Jira Issue {issue_identifier}: {issue_title}\n\n{issue_content}"
summary_content = (
f"Jira Issue {issue_identifier}: {issue_title}\n\n{issue_content}"
)
summary_embedding = embed_text(summary_content)
chunks = await create_document_chunks(issue_content)
document.title = f"{issue_identifier}: {issue_title}"
document.content = summary_content
document.content_hash = generate_content_hash(issue_content, search_space_id)
document.content_hash = generate_content_hash(
issue_content, search_space_id
)
document.embedding = summary_embedding
from sqlalchemy.orm.attributes import flag_modified
@ -219,11 +240,15 @@ class JiraKBSyncService:
logger.info(
"KB sync successful for document %s (%s: %s)",
document_id, issue_identifier, issue_title,
document_id,
issue_identifier,
issue_title,
)
return {"status": "success"}
except Exception as e:
logger.error("KB sync failed for document %s: %s", document_id, e, exc_info=True)
logger.error(
"KB sync failed for document %s: %s", document_id, e, exc_info=True
)
await self.db_session.rollback()
return {"status": "error", "message": str(e)}

View file

@ -87,7 +87,7 @@ class JiraToolMetadataService:
async def _check_account_health(self, connector: SearchSourceConnector) -> bool:
"""Check if the Jira connector auth is still valid.
Returns True if auth is expired/invalid, False if healthy.
"""
try:
@ -98,9 +98,7 @@ class JiraToolMetadataService:
await asyncio.to_thread(jira_client.get_myself)
return False
except Exception as e:
logger.warning(
"Jira connector %s health check failed: %s", connector.id, e
)
logger.warning("Jira connector %s health check failed: %s", connector.id, e)
try:
connector.config = {**connector.config, "auth_expired": True}
flag_modified(connector, "config")
@ -116,7 +114,7 @@ class JiraToolMetadataService:
async def get_creation_context(self, search_space_id: int, user_id: str) -> dict:
"""Return context needed to create a new Jira issue.
Fetches all connected Jira accounts, and for the first healthy one
fetches projects, issue types, and priorities.
"""
@ -165,7 +163,8 @@ class JiraToolMetadataService:
except Exception as e:
logger.warning(
"Failed to fetch Jira context for connector %s: %s",
connector.id, e,
connector.id,
e,
)
return {
@ -179,7 +178,7 @@ class JiraToolMetadataService:
self, search_space_id: int, user_id: str, issue_ref: str
) -> dict:
"""Return context needed to update an indexed Jira issue.
Resolves the issue from the KB, then fetches current details from the Jira API.
"""
document = await self._resolve_issue(search_space_id, user_id, issue_ref)
@ -209,13 +208,15 @@ class JiraToolMetadataService:
session=self._db_session, connector_id=connector.id
)
jira_client = await jira_history._get_jira_client()
issue_data = await asyncio.to_thread(
jira_client.get_issue, issue.issue_id
)
issue_data = await asyncio.to_thread(jira_client.get_issue, issue.issue_id)
formatted = jira_client.format_issue(issue_data)
except Exception as e:
error_str = str(e).lower()
if "401" in error_str or "403" in error_str or "authentication" in error_str:
if (
"401" in error_str
or "403" in error_str
or "authentication" in error_str
):
return {
"error": f"Failed to fetch Jira issue: {e!s}",
"auth_expired": True,

View file

@ -66,7 +66,9 @@ class LinearKBSyncService:
if not indexable_content:
indexable_content = f"Linear Issue {issue_identifier}: {issue_title}"
issue_content = f"# {issue_identifier}: {issue_title}\n\n{indexable_content}"
issue_content = (
f"# {issue_identifier}: {issue_title}\n\n{indexable_content}"
)
content_hash = generate_content_hash(issue_content, search_space_id)

View file

@ -190,7 +190,11 @@ class LinearToolMetadataService:
issue_api = await self._fetch_issue_context(linear_client, issue.id)
except Exception as e:
error_str = str(e).lower()
if "401" in error_str or "authentication" in error_str or "re-authenticate" in error_str:
if (
"401" in error_str
or "authentication" in error_str
or "re-authenticate" in error_str
):
return {
"error": f"Failed to fetch Linear issue context: {e!s}",
"auth_expired": True,

View file

@ -102,7 +102,10 @@ class NotionToolMetadataService:
)
db_connector = result.scalar_one_or_none()
if db_connector and not db_connector.config.get("auth_expired"):
db_connector.config = {**db_connector.config, "auth_expired": True}
db_connector.config = {
**db_connector.config,
"auth_expired": True,
}
flag_modified(db_connector, "config")
await self._db_session.commit()
await self._db_session.refresh(db_connector)

View file

@ -114,9 +114,7 @@ async def index_google_calendar_events(
# Build credentials based on connector type
if connector.connector_type in COMPOSIO_GOOGLE_CONNECTOR_TYPES:
connected_account_id = connector.config.get(
"composio_connected_account_id"
)
connected_account_id = connector.config.get("composio_connected_account_id")
if not connected_account_id:
await task_logger.log_task_failure(
log_entry,
@ -396,10 +394,19 @@ async def index_google_calendar_events(
session, legacy_hash
)
if existing_document:
existing_document.unique_identifier_hash = unique_identifier_hash
if existing_document.document_type == DocumentType.COMPOSIO_GOOGLE_CALENDAR_CONNECTOR:
existing_document.document_type = DocumentType.GOOGLE_CALENDAR_CONNECTOR
logger.info(f"Migrated legacy Composio Calendar document: {event_id}")
existing_document.unique_identifier_hash = (
unique_identifier_hash
)
if (
existing_document.document_type
== DocumentType.COMPOSIO_GOOGLE_CALENDAR_CONNECTOR
):
existing_document.document_type = (
DocumentType.GOOGLE_CALENDAR_CONNECTOR
)
logger.info(
f"Migrated legacy Composio Calendar document: {event_id}"
)
if existing_document:
# Document exists - check if content has changed

View file

@ -121,13 +121,13 @@ async def index_google_drive_files(
# Build credentials based on connector type
pre_built_credentials = None
if connector.connector_type in COMPOSIO_GOOGLE_CONNECTOR_TYPES:
connected_account_id = connector.config.get(
"composio_connected_account_id"
)
connected_account_id = connector.config.get("composio_connected_account_id")
if not connected_account_id:
error_msg = f"Composio connected_account_id not found for connector {connector_id}"
await task_logger.log_task_failure(
log_entry, error_msg, "Missing Composio account",
log_entry,
error_msg,
"Missing Composio account",
{"error_type": "MissingComposioAccount"},
)
return 0, 0, error_msg
@ -355,13 +355,13 @@ async def index_google_drive_single_file(
pre_built_credentials = None
if connector.connector_type in COMPOSIO_GOOGLE_CONNECTOR_TYPES:
connected_account_id = connector.config.get(
"composio_connected_account_id"
)
connected_account_id = connector.config.get("composio_connected_account_id")
if not connected_account_id:
error_msg = f"Composio connected_account_id not found for connector {connector_id}"
await task_logger.log_task_failure(
log_entry, error_msg, "Missing Composio account",
log_entry,
error_msg,
"Missing Composio account",
{"error_type": "MissingComposioAccount"},
)
return 0, error_msg
@ -611,7 +611,11 @@ async def _index_full_scan(
if not files_to_process and first_listing_error:
error_lower = first_listing_error.lower()
if "401" in first_listing_error or "invalid credentials" in error_lower or "authError" in first_listing_error:
if (
"401" in first_listing_error
or "invalid credentials" in error_lower
or "authError" in first_listing_error
):
raise Exception(
f"Google Drive authentication failed. Please re-authenticate. "
f"(Error: {first_listing_error})"
@ -704,7 +708,11 @@ async def _index_with_delta_sync(
if error:
logger.error(f"Error fetching changes: {error}")
error_lower = error.lower()
if "401" in error or "invalid credentials" in error_lower or "authError" in error:
if (
"401" in error
or "invalid credentials" in error_lower
or "authError" in error
):
raise Exception(
f"Google Drive authentication failed. Please re-authenticate. "
f"(Error: {error})"
@ -872,7 +880,10 @@ async def _create_pending_document_for_file(
)
if existing_document:
existing_document.unique_identifier_hash = unique_identifier_hash
if existing_document.document_type == DocumentType.COMPOSIO_GOOGLE_DRIVE_CONNECTOR:
if (
existing_document.document_type
== DocumentType.COMPOSIO_GOOGLE_DRIVE_CONNECTOR
):
existing_document.document_type = DocumentType.GOOGLE_DRIVE_FILE
logger.info(f"Migrated legacy Composio document to native type: {file_id}")
@ -984,10 +995,12 @@ async def _check_rename_only_update(
result = await session.execute(
select(Document).where(
Document.search_space_id == search_space_id,
Document.document_type.in_([
DocumentType.GOOGLE_DRIVE_FILE,
DocumentType.COMPOSIO_GOOGLE_DRIVE_CONNECTOR,
]),
Document.document_type.in_(
[
DocumentType.GOOGLE_DRIVE_FILE,
DocumentType.COMPOSIO_GOOGLE_DRIVE_CONNECTOR,
]
),
cast(Document.document_metadata["google_drive_file_id"], String)
== file_id,
)
@ -1000,7 +1013,10 @@ async def _check_rename_only_update(
if existing_document:
if existing_document.unique_identifier_hash != primary_hash:
existing_document.unique_identifier_hash = primary_hash
if existing_document.document_type == DocumentType.COMPOSIO_GOOGLE_DRIVE_CONNECTOR:
if (
existing_document.document_type
== DocumentType.COMPOSIO_GOOGLE_DRIVE_CONNECTOR
):
existing_document.document_type = DocumentType.GOOGLE_DRIVE_FILE
logger.info(f"Migrated legacy Composio Drive document: {file_id}")
@ -1232,10 +1248,12 @@ async def _remove_document(session: AsyncSession, file_id: str, search_space_id:
result = await session.execute(
select(Document).where(
Document.search_space_id == search_space_id,
Document.document_type.in_([
DocumentType.GOOGLE_DRIVE_FILE,
DocumentType.COMPOSIO_GOOGLE_DRIVE_CONNECTOR,
]),
Document.document_type.in_(
[
DocumentType.GOOGLE_DRIVE_FILE,
DocumentType.COMPOSIO_GOOGLE_DRIVE_CONNECTOR,
]
),
cast(Document.document_metadata["google_drive_file_id"], String)
== file_id,
)

View file

@ -119,9 +119,7 @@ async def index_google_gmail_messages(
# Build credentials based on connector type
if connector.connector_type in COMPOSIO_GOOGLE_CONNECTOR_TYPES:
connected_account_id = connector.config.get(
"composio_connected_account_id"
)
connected_account_id = connector.config.get("composio_connected_account_id")
if not connected_account_id:
await task_logger.log_task_failure(
log_entry,
@ -323,10 +321,19 @@ async def index_google_gmail_messages(
session, legacy_hash
)
if existing_document:
existing_document.unique_identifier_hash = unique_identifier_hash
if existing_document.document_type == DocumentType.COMPOSIO_GMAIL_CONNECTOR:
existing_document.document_type = DocumentType.GOOGLE_GMAIL_CONNECTOR
logger.info(f"Migrated legacy Composio Gmail document: {message_id}")
existing_document.unique_identifier_hash = (
unique_identifier_hash
)
if (
existing_document.document_type
== DocumentType.COMPOSIO_GMAIL_CONNECTOR
):
existing_document.document_type = (
DocumentType.GOOGLE_GMAIL_CONNECTOR
)
logger.info(
f"Migrated legacy Composio Gmail document: {message_id}"
)
if existing_document:
# Document exists - check if content has changed

View file

@ -1270,9 +1270,16 @@ async def process_file_in_background(
print("Error deleting temp file", e)
pass
enable_summary = connector.get("enable_summary", True) if connector else True
enable_summary = (
connector.get("enable_summary", True) if connector else True
)
result = await add_received_file_document_using_unstructured(
session, filename, docs, search_space_id, user_id, connector,
session,
filename,
docs,
search_space_id,
user_id,
connector,
enable_summary=enable_summary,
)
@ -1414,7 +1421,9 @@ async def process_file_in_background(
# Extract text content from the markdown documents
markdown_content = doc.text
enable_summary = connector.get("enable_summary", True) if connector else True
enable_summary = (
connector.get("enable_summary", True) if connector else True
)
doc_result = await add_received_file_document_using_llamacloud(
session,
filename,
@ -1569,7 +1578,9 @@ async def process_file_in_background(
session, notification, stage="chunking"
)
enable_summary = connector.get("enable_summary", True) if connector else True
enable_summary = (
connector.get("enable_summary", True) if connector else True
)
doc_result = await add_received_file_document_using_docling(
session,
filename,

View file

@ -156,9 +156,7 @@ async def committed_google_data(async_engine):
session.add(user)
await session.flush()
space = SearchSpace(
name=f"Google Test {uuid.uuid4().hex[:6]}", user_id=user.id
)
space = SearchSpace(name=f"Google Test {uuid.uuid4().hex[:6]}", user_id=user.id)
session.add(space)
await session.flush()
space_id = space.id
@ -215,7 +213,9 @@ async def committed_google_data(async_engine):
def patched_session_factory(async_engine, monkeypatch):
"""Replace ``async_session_maker`` in connector_service with one bound to the test engine."""
test_maker = async_sessionmaker(async_engine, expire_on_commit=False)
monkeypatch.setattr("app.services.connector_service.async_session_maker", test_maker)
monkeypatch.setattr(
"app.services.connector_service.async_session_maker", test_maker
)
return test_maker

View file

@ -14,7 +14,12 @@ import pytest_asyncio
from app.db import SearchSourceConnectorType
from .conftest import cleanup_space, make_session_factory, mock_task_logger, seed_connector
from .conftest import (
cleanup_space,
make_session_factory,
mock_task_logger,
seed_connector,
)
pytestmark = pytest.mark.integration
@ -52,8 +57,10 @@ async def native_calendar(async_engine):
async_engine,
connector_type=SearchSourceConnectorType.GOOGLE_CALENDAR_CONNECTOR,
config={
"token": "fake", "refresh_token": "fake",
"client_id": "fake", "client_secret": "fake",
"token": "fake",
"refresh_token": "fake",
"client_id": "fake",
"client_secret": "fake",
"token_uri": "https://oauth2.googleapis.com/token",
},
name_prefix="cal-native",
@ -66,10 +73,16 @@ async def native_calendar(async_engine):
@patch(f"{_INDEXER_MODULE}.GoogleCalendarConnector")
@patch(f"{_INDEXER_MODULE}.build_composio_credentials")
async def test_composio_calendar_uses_composio_credentials(
mock_build_creds, mock_cal_cls, mock_tl_cls, async_engine, composio_calendar,
mock_build_creds,
mock_cal_cls,
mock_tl_cls,
async_engine,
composio_calendar,
):
"""Calendar indexer calls build_composio_credentials for a Composio connector."""
from app.tasks.connector_indexers.google_calendar_indexer import index_google_calendar_events
from app.tasks.connector_indexers.google_calendar_indexer import (
index_google_calendar_events,
)
data = composio_calendar
mock_creds = MagicMock(name="composio-creds")
@ -77,14 +90,18 @@ async def test_composio_calendar_uses_composio_credentials(
mock_tl_cls.return_value = mock_task_logger()
mock_cal_instance = MagicMock()
mock_cal_instance.get_all_primary_calendar_events = AsyncMock(return_value=([], None))
mock_cal_instance.get_all_primary_calendar_events = AsyncMock(
return_value=([], None)
)
mock_cal_cls.return_value = mock_cal_instance
maker = make_session_factory(async_engine)
async with maker() as session:
await index_google_calendar_events(
session=session, connector_id=data["connector_id"],
search_space_id=data["search_space_id"], user_id=data["user_id"],
session=session,
connector_id=data["connector_id"],
search_space_id=data["search_space_id"],
user_id=data["user_id"],
)
mock_build_creds.assert_called_once_with(_COMPOSIO_ACCOUNT_ID)
@ -96,10 +113,15 @@ async def test_composio_calendar_uses_composio_credentials(
@patch(f"{_INDEXER_MODULE}.TaskLoggingService")
@patch(f"{_INDEXER_MODULE}.build_composio_credentials")
async def test_composio_calendar_without_account_id_returns_error(
mock_build_creds, mock_tl_cls, async_engine, composio_calendar_no_id,
mock_build_creds,
mock_tl_cls,
async_engine,
composio_calendar_no_id,
):
"""Calendar indexer returns error when Composio connector lacks connected_account_id."""
from app.tasks.connector_indexers.google_calendar_indexer import index_google_calendar_events
from app.tasks.connector_indexers.google_calendar_indexer import (
index_google_calendar_events,
)
data = composio_calendar_no_id
mock_tl_cls.return_value = mock_task_logger()
@ -107,8 +129,10 @@ async def test_composio_calendar_without_account_id_returns_error(
maker = make_session_factory(async_engine)
async with maker() as session:
count, _skipped, error = await index_google_calendar_events(
session=session, connector_id=data["connector_id"],
search_space_id=data["search_space_id"], user_id=data["user_id"],
session=session,
connector_id=data["connector_id"],
search_space_id=data["search_space_id"],
user_id=data["user_id"],
)
assert count == 0
@ -121,23 +145,33 @@ async def test_composio_calendar_without_account_id_returns_error(
@patch(f"{_INDEXER_MODULE}.GoogleCalendarConnector")
@patch(f"{_INDEXER_MODULE}.build_composio_credentials")
async def test_native_calendar_does_not_use_composio_credentials(
mock_build_creds, mock_cal_cls, mock_tl_cls, async_engine, native_calendar,
mock_build_creds,
mock_cal_cls,
mock_tl_cls,
async_engine,
native_calendar,
):
"""Calendar indexer does NOT call build_composio_credentials for a native connector."""
from app.tasks.connector_indexers.google_calendar_indexer import index_google_calendar_events
from app.tasks.connector_indexers.google_calendar_indexer import (
index_google_calendar_events,
)
data = native_calendar
mock_tl_cls.return_value = mock_task_logger()
mock_cal_instance = MagicMock()
mock_cal_instance.get_all_primary_calendar_events = AsyncMock(return_value=([], None))
mock_cal_instance.get_all_primary_calendar_events = AsyncMock(
return_value=([], None)
)
mock_cal_cls.return_value = mock_cal_instance
maker = make_session_factory(async_engine)
async with maker() as session:
await index_google_calendar_events(
session=session, connector_id=data["connector_id"],
search_space_id=data["search_space_id"], user_id=data["user_id"],
session=session,
connector_id=data["connector_id"],
search_space_id=data["search_space_id"],
user_id=data["user_id"],
)
mock_build_creds.assert_not_called()

View file

@ -14,7 +14,12 @@ import pytest_asyncio
from app.db import SearchSourceConnectorType
from .conftest import cleanup_space, make_session_factory, mock_task_logger, seed_connector
from .conftest import (
cleanup_space,
make_session_factory,
mock_task_logger,
seed_connector,
)
pytestmark = pytest.mark.integration
@ -129,7 +134,9 @@ async def test_composio_connector_without_account_id_returns_error(
assert count == 0
assert error is not None
assert "composio_connected_account_id" in error.lower() or "composio" in error.lower()
assert (
"composio_connected_account_id" in error.lower() or "composio" in error.lower()
)
mock_build_creds.assert_not_called()

View file

@ -14,7 +14,12 @@ import pytest_asyncio
from app.db import SearchSourceConnectorType
from .conftest import cleanup_space, make_session_factory, mock_task_logger, seed_connector
from .conftest import (
cleanup_space,
make_session_factory,
mock_task_logger,
seed_connector,
)
pytestmark = pytest.mark.integration
@ -52,8 +57,10 @@ async def native_gmail(async_engine):
async_engine,
connector_type=SearchSourceConnectorType.GOOGLE_GMAIL_CONNECTOR,
config={
"token": "fake", "refresh_token": "fake",
"client_id": "fake", "client_secret": "fake",
"token": "fake",
"refresh_token": "fake",
"client_id": "fake",
"client_secret": "fake",
"token_uri": "https://oauth2.googleapis.com/token",
},
name_prefix="gmail-native",
@ -66,10 +73,16 @@ async def native_gmail(async_engine):
@patch(f"{_INDEXER_MODULE}.GoogleGmailConnector")
@patch(f"{_INDEXER_MODULE}.build_composio_credentials")
async def test_composio_gmail_uses_composio_credentials(
mock_build_creds, mock_gmail_cls, mock_tl_cls, async_engine, composio_gmail,
mock_build_creds,
mock_gmail_cls,
mock_tl_cls,
async_engine,
composio_gmail,
):
"""Gmail indexer calls build_composio_credentials for a Composio connector."""
from app.tasks.connector_indexers.google_gmail_indexer import index_google_gmail_messages
from app.tasks.connector_indexers.google_gmail_indexer import (
index_google_gmail_messages,
)
data = composio_gmail
mock_creds = MagicMock(name="composio-creds")
@ -83,8 +96,10 @@ async def test_composio_gmail_uses_composio_credentials(
maker = make_session_factory(async_engine)
async with maker() as session:
await index_google_gmail_messages(
session=session, connector_id=data["connector_id"],
search_space_id=data["search_space_id"], user_id=data["user_id"],
session=session,
connector_id=data["connector_id"],
search_space_id=data["search_space_id"],
user_id=data["user_id"],
)
mock_build_creds.assert_called_once_with(_COMPOSIO_ACCOUNT_ID)
@ -96,10 +111,15 @@ async def test_composio_gmail_uses_composio_credentials(
@patch(f"{_INDEXER_MODULE}.TaskLoggingService")
@patch(f"{_INDEXER_MODULE}.build_composio_credentials")
async def test_composio_gmail_without_account_id_returns_error(
mock_build_creds, mock_tl_cls, async_engine, composio_gmail_no_id,
mock_build_creds,
mock_tl_cls,
async_engine,
composio_gmail_no_id,
):
"""Gmail indexer returns error when Composio connector lacks connected_account_id."""
from app.tasks.connector_indexers.google_gmail_indexer import index_google_gmail_messages
from app.tasks.connector_indexers.google_gmail_indexer import (
index_google_gmail_messages,
)
data = composio_gmail_no_id
mock_tl_cls.return_value = mock_task_logger()
@ -107,8 +127,10 @@ async def test_composio_gmail_without_account_id_returns_error(
maker = make_session_factory(async_engine)
async with maker() as session:
count, _skipped, error = await index_google_gmail_messages(
session=session, connector_id=data["connector_id"],
search_space_id=data["search_space_id"], user_id=data["user_id"],
session=session,
connector_id=data["connector_id"],
search_space_id=data["search_space_id"],
user_id=data["user_id"],
)
assert count == 0
@ -121,10 +143,16 @@ async def test_composio_gmail_without_account_id_returns_error(
@patch(f"{_INDEXER_MODULE}.GoogleGmailConnector")
@patch(f"{_INDEXER_MODULE}.build_composio_credentials")
async def test_native_gmail_does_not_use_composio_credentials(
mock_build_creds, mock_gmail_cls, mock_tl_cls, async_engine, native_gmail,
mock_build_creds,
mock_gmail_cls,
mock_tl_cls,
async_engine,
native_gmail,
):
"""Gmail indexer does NOT call build_composio_credentials for a native connector."""
from app.tasks.connector_indexers.google_gmail_indexer import index_google_gmail_messages
from app.tasks.connector_indexers.google_gmail_indexer import (
index_google_gmail_messages,
)
data = native_gmail
mock_tl_cls.return_value = mock_task_logger()
@ -136,8 +164,10 @@ async def test_native_gmail_does_not_use_composio_credentials(
maker = make_session_factory(async_engine)
async with maker() as session:
await index_google_gmail_messages(
session=session, connector_id=data["connector_id"],
search_space_id=data["search_space_id"], user_id=data["user_id"],
session=session,
connector_id=data["connector_id"],
search_space_id=data["search_space_id"],
user_id=data["user_id"],
)
mock_build_creds.assert_not_called()

View file

@ -39,9 +39,7 @@ async def test_list_of_types_returns_both_matching_doc_types(
assert "FILE" not in returned_types
async def test_single_string_type_returns_only_that_type(
db_session, seed_google_docs
):
async def test_single_string_type_returns_only_that_type(db_session, seed_google_docs):
"""Searching with a single string type returns only documents of that exact type."""
space_id = seed_google_docs["search_space"].id

View file

@ -64,7 +64,9 @@ async def test_gmail_accepts_valid_composio_credentials(mock_build):
mock_build.return_value = mock_service
connector = GoogleGmailConnector(
creds, session=MagicMock(), user_id="test-user",
creds,
session=MagicMock(),
user_id="test-user",
)
profile, error = await connector.get_user_profile()
@ -76,7 +78,9 @@ async def test_gmail_accepts_valid_composio_credentials(mock_build):
@patch("app.connectors.google_gmail_connector.Request")
@patch("app.connectors.google_gmail_connector.build")
async def test_gmail_refreshes_expired_composio_credentials(mock_build, mock_request_cls):
async def test_gmail_refreshes_expired_composio_credentials(
mock_build, mock_request_cls
):
"""GoogleGmailConnector handles expired Composio credentials via refresh_handler
without attempting DB persistence."""
from app.connectors.google_gmail_connector import GoogleGmailConnector
@ -95,7 +99,9 @@ async def test_gmail_refreshes_expired_composio_credentials(mock_build, mock_req
mock_session = AsyncMock()
connector = GoogleGmailConnector(
creds, session=mock_session, user_id="test-user",
creds,
session=mock_session,
user_id="test-user",
)
profile, error = await connector.get_user_profile()
@ -128,7 +134,9 @@ async def test_calendar_accepts_valid_composio_credentials(mock_build):
mock_build.return_value = mock_service
connector = GoogleCalendarConnector(
creds, session=MagicMock(), user_id="test-user",
creds,
session=MagicMock(),
user_id="test-user",
)
calendars, error = await connector.get_calendars()
@ -141,7 +149,9 @@ async def test_calendar_accepts_valid_composio_credentials(mock_build):
@patch("app.connectors.google_calendar_connector.Request")
@patch("app.connectors.google_calendar_connector.build")
async def test_calendar_refreshes_expired_composio_credentials(mock_build, mock_request_cls):
async def test_calendar_refreshes_expired_composio_credentials(
mock_build, mock_request_cls
):
"""GoogleCalendarConnector handles expired Composio credentials via refresh_handler
without attempting DB persistence."""
from app.connectors.google_calendar_connector import GoogleCalendarConnector
@ -157,7 +167,9 @@ async def test_calendar_refreshes_expired_composio_credentials(mock_build, mock_
mock_session = AsyncMock()
connector = GoogleCalendarConnector(
creds, session=mock_session, user_id="test-user",
creds,
session=mock_session,
user_id="test-user",
)
calendars, error = await connector.get_calendars()
@ -191,7 +203,9 @@ async def test_drive_client_uses_prebuilt_composio_credentials(mock_build):
mock_build.return_value = mock_service
client = GoogleDriveClient(
session=MagicMock(), connector_id=999, credentials=creds,
session=MagicMock(),
connector_id=999,
credentials=creds,
)
files, next_token, error = await client.list_files()
@ -218,7 +232,9 @@ async def test_drive_client_prebuilt_creds_skip_db_loading(mock_build, mock_get_
mock_build.return_value = mock_service
client = GoogleDriveClient(
session=MagicMock(), connector_id=999, credentials=creds,
session=MagicMock(),
connector_id=999,
credentials=creds,
)
await client.list_files()

View file

@ -20,8 +20,14 @@ def test_drive_indexer_accepts_both_native_and_composio():
ACCEPTED_DRIVE_CONNECTOR_TYPES,
)
assert SearchSourceConnectorType.GOOGLE_DRIVE_CONNECTOR in ACCEPTED_DRIVE_CONNECTOR_TYPES
assert SearchSourceConnectorType.COMPOSIO_GOOGLE_DRIVE_CONNECTOR in ACCEPTED_DRIVE_CONNECTOR_TYPES
assert (
SearchSourceConnectorType.GOOGLE_DRIVE_CONNECTOR
in ACCEPTED_DRIVE_CONNECTOR_TYPES
)
assert (
SearchSourceConnectorType.COMPOSIO_GOOGLE_DRIVE_CONNECTOR
in ACCEPTED_DRIVE_CONNECTOR_TYPES
)
def test_gmail_indexer_accepts_both_native_and_composio():
@ -30,8 +36,14 @@ def test_gmail_indexer_accepts_both_native_and_composio():
ACCEPTED_GMAIL_CONNECTOR_TYPES,
)
assert SearchSourceConnectorType.GOOGLE_GMAIL_CONNECTOR in ACCEPTED_GMAIL_CONNECTOR_TYPES
assert SearchSourceConnectorType.COMPOSIO_GMAIL_CONNECTOR in ACCEPTED_GMAIL_CONNECTOR_TYPES
assert (
SearchSourceConnectorType.GOOGLE_GMAIL_CONNECTOR
in ACCEPTED_GMAIL_CONNECTOR_TYPES
)
assert (
SearchSourceConnectorType.COMPOSIO_GMAIL_CONNECTOR
in ACCEPTED_GMAIL_CONNECTOR_TYPES
)
def test_calendar_indexer_accepts_both_native_and_composio():
@ -40,14 +52,29 @@ def test_calendar_indexer_accepts_both_native_and_composio():
ACCEPTED_CALENDAR_CONNECTOR_TYPES,
)
assert SearchSourceConnectorType.GOOGLE_CALENDAR_CONNECTOR in ACCEPTED_CALENDAR_CONNECTOR_TYPES
assert SearchSourceConnectorType.COMPOSIO_GOOGLE_CALENDAR_CONNECTOR in ACCEPTED_CALENDAR_CONNECTOR_TYPES
assert (
SearchSourceConnectorType.GOOGLE_CALENDAR_CONNECTOR
in ACCEPTED_CALENDAR_CONNECTOR_TYPES
)
assert (
SearchSourceConnectorType.COMPOSIO_GOOGLE_CALENDAR_CONNECTOR
in ACCEPTED_CALENDAR_CONNECTOR_TYPES
)
def test_composio_connector_types_set_covers_all_google_services():
"""COMPOSIO_GOOGLE_CONNECTOR_TYPES should contain all three Composio Google types."""
from app.utils.google_credentials import COMPOSIO_GOOGLE_CONNECTOR_TYPES
assert SearchSourceConnectorType.COMPOSIO_GOOGLE_DRIVE_CONNECTOR in COMPOSIO_GOOGLE_CONNECTOR_TYPES
assert SearchSourceConnectorType.COMPOSIO_GMAIL_CONNECTOR in COMPOSIO_GOOGLE_CONNECTOR_TYPES
assert SearchSourceConnectorType.COMPOSIO_GOOGLE_CALENDAR_CONNECTOR in COMPOSIO_GOOGLE_CONNECTOR_TYPES
assert (
SearchSourceConnectorType.COMPOSIO_GOOGLE_DRIVE_CONNECTOR
in COMPOSIO_GOOGLE_CONNECTOR_TYPES
)
assert (
SearchSourceConnectorType.COMPOSIO_GMAIL_CONNECTOR
in COMPOSIO_GOOGLE_CONNECTOR_TYPES
)
assert (
SearchSourceConnectorType.COMPOSIO_GOOGLE_CALENDAR_CONNECTOR
in COMPOSIO_GOOGLE_CONNECTOR_TYPES
)

View file

@ -213,11 +213,7 @@ export function LocalLoginForm() {
disabled={isLoggingIn}
className="w-full rounded-md bg-blue-600 px-4 py-1.5 md:py-2 text-white shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 transition-all text-sm md:text-base flex items-center justify-center gap-2"
>
{isLoggingIn ? (
<Spinner size="sm" className="text-white" />
) : (
t("sign_in")
)}
{isLoggingIn ? <Spinner size="sm" className="text-white" /> : t("sign_in")}
</button>
</form>

View file

@ -16,10 +16,7 @@ export async function GET(
connectorId: searchParams.get("connectorId"),
});
const redirectUrl = new URL(
`/dashboard/${search_space_id}/new-chat`,
request.url
);
const redirectUrl = new URL(`/dashboard/${search_space_id}/new-chat`, request.url);
const response = NextResponse.redirect(redirectUrl, { status: 302 });
response.cookies.set(OAUTH_RESULT_COOKIE, result, {

View file

@ -1,5 +1,6 @@
"use client";
import { useAtomValue, useSetAtom } from "jotai";
import {
AlertCircle,
CheckCircle2,
@ -16,12 +17,11 @@ import {
Trash2,
User,
} from "lucide-react";
import { useAtomValue, useSetAtom } from "jotai";
import { useTranslations } from "next-intl";
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { membersAtom } from "@/atoms/members/members-query.atoms";
import { openEditorPanelAtom } from "@/atoms/editor/editor-panel.atom";
import { toast } from "sonner";
import { openEditorPanelAtom } from "@/atoms/editor/editor-panel.atom";
import { membersAtom } from "@/atoms/members/members-query.atoms";
import { useDocumentUploadDialog } from "@/components/assistant-ui/document-upload-popup";
import { JsonMetadataViewer } from "@/components/json-metadata-viewer";
import { MarkdownViewer } from "@/components/markdown-viewer";
@ -35,14 +35,9 @@ import {
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import {
Drawer,
@ -51,7 +46,12 @@ import {
DrawerHeader,
DrawerTitle,
} from "@/components/ui/drawer";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Skeleton } from "@/components/ui/skeleton";
import { Spinner } from "@/components/ui/spinner";
import {

View file

@ -37,21 +37,26 @@ import { Thread } from "@/components/assistant-ui/thread";
import { MobileEditorPanel } from "@/components/editor-panel/editor-panel";
import { MobileHitlEditPanel } from "@/components/hitl-edit-panel/hitl-edit-panel";
import { MobileReportPanel } from "@/components/report-panel/report-panel";
import {
CreateConfluencePageToolUI,
DeleteConfluencePageToolUI,
UpdateConfluencePageToolUI,
} from "@/components/tool-ui/confluence";
import type { ThinkingStep } from "@/components/tool-ui/deepagent-thinking";
import { DisplayImageToolUI } from "@/components/tool-ui/display-image";
import { GeneratePodcastToolUI } from "@/components/tool-ui/generate-podcast";
import { GenerateReportToolUI } from "@/components/tool-ui/generate-report";
import {
CreateCalendarEventToolUI,
UpdateCalendarEventToolUI,
DeleteCalendarEventToolUI,
} from "@/components/tool-ui/google-calendar";
import {
CreateGmailDraftToolUI,
SendGmailEmailToolUI,
TrashGmailEmailToolUI,
UpdateGmailDraftToolUI,
} from "@/components/tool-ui/gmail";
import {
CreateCalendarEventToolUI,
DeleteCalendarEventToolUI,
UpdateCalendarEventToolUI,
} from "@/components/tool-ui/google-calendar";
import {
CreateGoogleDriveFileToolUI,
DeleteGoogleDriveFileToolUI,
@ -61,11 +66,6 @@ import {
DeleteJiraIssueToolUI,
UpdateJiraIssueToolUI,
} from "@/components/tool-ui/jira";
import {
CreateConfluencePageToolUI,
DeleteConfluencePageToolUI,
UpdateConfluencePageToolUI,
} from "@/components/tool-ui/confluence";
import {
CreateLinearIssueToolUI,
DeleteLinearIssueToolUI,

View file

@ -20,7 +20,6 @@ import {
UserPlus,
Users,
} from "lucide-react";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { useCallback, useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import {
@ -44,6 +43,7 @@ import {
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import { Calendar as CalendarComponent } from "@/components/ui/calendar";
import {

View file

@ -1,7 +1,7 @@
"use client";
import posthog from "posthog-js";
import NextError from "next/error";
import posthog from "posthog-js";
import { useEffect } from "react";
export default function GlobalError({

View file

@ -14,7 +14,9 @@ interface HitlEditPanelState {
content: string;
toolName: string;
extraFields?: ExtraField[];
onSave: ((title: string, content: string, extraFieldValues?: Record<string, string>) => void) | null;
onSave:
| ((title: string, content: string, extraFieldValues?: Record<string, string>) => void)
| null;
onClose: (() => void) | null;
}

View file

@ -214,11 +214,7 @@ export const ConnectorIndicator = forwardRef<ConnectorIndicatorHandle, Connector
if (!searchSpaceId) return null;
return (
<Dialog
open={isOpen}
modal={false}
onOpenChange={handleOpenChange}
>
<Dialog open={isOpen} modal={false} onOpenChange={handleOpenChange}>
{showTrigger && (
<TooltipIconButton
data-joyride="connector-icon"
@ -354,11 +350,12 @@ export const ConnectorIndicator = forwardRef<ConnectorIndicatorHandle, Connector
onBack={handleBackFromEdit}
onQuickIndex={(() => {
const cfg = connectorConfig || editingConnector.config;
const isDrive = editingConnector.connector_type === "GOOGLE_DRIVE_CONNECTOR" ||
const isDrive =
editingConnector.connector_type === "GOOGLE_DRIVE_CONNECTOR" ||
editingConnector.connector_type === "COMPOSIO_GOOGLE_DRIVE_CONNECTOR";
const hasDriveItems = isDrive
? ((cfg?.selected_folders as unknown[]) ?? []).length > 0 ||
((cfg?.selected_files as unknown[]) ?? []).length > 0
((cfg?.selected_files as unknown[]) ?? []).length > 0
: true;
if (!hasDriveItems) return undefined;
return () => {
@ -376,37 +373,37 @@ export const ConnectorIndicator = forwardRef<ConnectorIndicatorHandle, Connector
onNameChange={setConnectorName}
/>
) : indexingConfig ? (
<IndexingConfigurationView
config={indexingConfig}
connector={
indexingConnector
? {
...indexingConnector,
config: indexingConnectorConfig || indexingConnector.config,
}
: undefined
}
startDate={startDate}
endDate={endDate}
periodicEnabled={periodicEnabled}
frequencyMinutes={frequencyMinutes}
enableSummary={enableSummary}
isStartingIndexing={isStartingIndexing}
isFromOAuth={isFromOAuth}
onStartDateChange={setStartDate}
onEndDateChange={setEndDate}
onPeriodicEnabledChange={setPeriodicEnabled}
onFrequencyChange={setFrequencyMinutes}
onEnableSummaryChange={setEnableSummary}
onConfigChange={setIndexingConnectorConfig}
onStartIndexing={() => {
if (indexingConfig.connectorId) {
startIndexing(indexingConfig.connectorId);
<IndexingConfigurationView
config={indexingConfig}
connector={
indexingConnector
? {
...indexingConnector,
config: indexingConnectorConfig || indexingConnector.config,
}
: undefined
}
handleStartIndexing(() => refreshConnectors());
}}
onSkip={handleSkipIndexing}
/>
startDate={startDate}
endDate={endDate}
periodicEnabled={periodicEnabled}
frequencyMinutes={frequencyMinutes}
enableSummary={enableSummary}
isStartingIndexing={isStartingIndexing}
isFromOAuth={isFromOAuth}
onStartDateChange={setStartDate}
onEndDateChange={setEndDate}
onPeriodicEnabledChange={setPeriodicEnabled}
onFrequencyChange={setFrequencyMinutes}
onEnableSummaryChange={setEnableSummary}
onConfigChange={setIndexingConnectorConfig}
onStartIndexing={() => {
if (indexingConfig.connectorId) {
startIndexing(indexingConfig.connectorId);
}
handleStartIndexing(() => refreshConnectors());
}}
onSkip={handleSkipIndexing}
/>
) : (
<Tabs
value={activeTab}

View file

@ -1,7 +1,6 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { Info } from "lucide-react";
import type { FC } from "react";
import { useId, useRef, useState } from "react";
@ -26,6 +25,7 @@ import {
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import {
Select,
SelectContent,

View file

@ -82,10 +82,7 @@ function getFileIconFromName(fileName: string, className: string = "size-3.5 shr
return <File className={`${className} text-gray-500`} />;
}
export const ComposioDriveConfig: FC<ConnectorConfigProps> = ({
connector,
onConfigChange,
}) => {
export const ComposioDriveConfig: FC<ConnectorConfigProps> = ({ connector, onConfigChange }) => {
const isIndexable = connector.config?.is_indexable as boolean;
const existingFolders =
@ -236,47 +233,48 @@ export const ComposioDriveConfig: FC<ConnectorConfigProps> = ({
</div>
)}
{isAuthExpired && (
<p className="text-xs text-amber-600 dark:text-amber-500">
Your Google Drive authentication has expired. Please re-authenticate using the button below.
</p>
)}
{isAuthExpired && (
<p className="text-xs text-amber-600 dark:text-amber-500">
Your Google Drive authentication has expired. Please re-authenticate using the button
below.
</p>
)}
{isEditMode ? (
<div className="space-y-2">
<button
type="button"
onClick={() => setIsFolderTreeOpen(!isFolderTreeOpen)}
className="flex items-center gap-2 text-xs sm:text-sm text-muted-foreground hover:text-foreground transition-colors w-fit"
>
Change Selection
{isFolderTreeOpen ? (
<ChevronDown className="size-4" />
) : (
<ChevronRight className="size-4" />
{isEditMode ? (
<div className="space-y-2">
<button
type="button"
onClick={() => setIsFolderTreeOpen(!isFolderTreeOpen)}
className="flex items-center gap-2 text-xs sm:text-sm text-muted-foreground hover:text-foreground transition-colors w-fit"
>
Change Selection
{isFolderTreeOpen ? (
<ChevronDown className="size-4" />
) : (
<ChevronRight className="size-4" />
)}
</button>
{isFolderTreeOpen && (
<ComposioDriveFolderTree
connectorId={connector.id}
selectedFolders={selectedFolders}
onSelectFolders={handleSelectFolders}
selectedFiles={selectedFiles}
onSelectFiles={handleSelectFiles}
onAuthError={handleAuthError}
/>
)}
</button>
{isFolderTreeOpen && (
<ComposioDriveFolderTree
connectorId={connector.id}
selectedFolders={selectedFolders}
onSelectFolders={handleSelectFolders}
selectedFiles={selectedFiles}
onSelectFiles={handleSelectFiles}
onAuthError={handleAuthError}
/>
)}
</div>
) : (
<ComposioDriveFolderTree
connectorId={connector.id}
selectedFolders={selectedFolders}
onSelectFolders={handleSelectFolders}
selectedFiles={selectedFiles}
onSelectFiles={handleSelectFiles}
onAuthError={handleAuthError}
/>
)}
</div>
) : (
<ComposioDriveFolderTree
connectorId={connector.id}
selectedFolders={selectedFolders}
onSelectFolders={handleSelectFolders}
selectedFiles={selectedFiles}
onSelectFiles={handleSelectFiles}
onAuthError={handleAuthError}
/>
)}
</div>
{/* Indexing Options */}

View file

@ -1,12 +1,12 @@
"use client";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { KeyRound, Server } from "lucide-react";
import type { FC } from "react";
import { useEffect, useId, useRef, useState } from "react";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import type { ConnectorConfigProps } from "../index";
export interface ElasticsearchConfigProps extends ConnectorConfigProps {

View file

@ -231,26 +231,25 @@ export const GoogleDriveConfig: FC<ConnectorConfigProps> = ({ connector, onConfi
</div>
)}
<Button
type="button"
variant="outline"
onClick={openPicker}
disabled={pickerLoading || isAuthExpired}
className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20 hover:bg-slate-400/10 dark:hover:bg-white/10 text-xs sm:text-sm h-8 sm:h-9"
>
{pickerLoading && <Spinner size="xs" className="mr-1.5" />}
{totalSelected > 0 ? "Change Selection" : "Select from Google Drive"}
</Button>
<Button
type="button"
variant="outline"
onClick={openPicker}
disabled={pickerLoading || isAuthExpired}
className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20 hover:bg-slate-400/10 dark:hover:bg-white/10 text-xs sm:text-sm h-8 sm:h-9"
>
{pickerLoading && <Spinner size="xs" className="mr-1.5" />}
{totalSelected > 0 ? "Change Selection" : "Select from Google Drive"}
</Button>
{pickerError && !isAuthExpired && (
<p className="text-xs text-destructive">{pickerError}</p>
)}
{pickerError && !isAuthExpired && <p className="text-xs text-destructive">{pickerError}</p>}
{isAuthExpired && (
<p className="text-xs text-amber-600 dark:text-amber-500">
Your Google Drive authentication has expired. Please re-authenticate using the button below.
</p>
)}
{isAuthExpired && (
<p className="text-xs text-amber-600 dark:text-amber-500">
Your Google Drive authentication has expired. Please re-authenticate using the button
below.
</p>
)}
</div>
{/* Indexing Options */}

View file

@ -220,10 +220,8 @@ export const ConnectorEditView: FC<ConnectorEditViewProps> = ({
</p>
</div>
</div>
{/* Quick Index Button - hidden when auth is expired */}
{connector.is_indexable &&
onQuickIndex &&
!isAuthExpired && (
{/* Quick Index Button - hidden when auth is expired */}
{connector.is_indexable && onQuickIndex && !isAuthExpired && (
<Button
variant="secondary"
size="sm"
@ -401,31 +399,31 @@ export const ConnectorEditView: FC<ConnectorEditViewProps> = ({
Disconnect
</Button>
)}
{isAuthExpired && reauthEndpoint ? (
<Button
onClick={handleReauth}
disabled={reauthing || isDisconnecting}
className="text-xs sm:text-sm flex-1 sm:flex-initial h-12 sm:h-auto py-3 sm:py-2 bg-amber-600 hover:bg-amber-700 text-white"
>
<RefreshCw className={cn("size-3.5", reauthing && "animate-spin")} />
Re-authenticate
</Button>
) : (
<Button
onClick={onSave}
disabled={isSaving || isDisconnecting}
className="text-xs sm:text-sm flex-1 sm:flex-initial h-12 sm:h-auto py-3 sm:py-2"
>
{isSaving ? (
<>
<Spinner size="sm" className="mr-2" />
Saving
</>
) : (
"Save Changes"
)}
</Button>
)}
{isAuthExpired && reauthEndpoint ? (
<Button
onClick={handleReauth}
disabled={reauthing || isDisconnecting}
className="text-xs sm:text-sm flex-1 sm:flex-initial h-12 sm:h-auto py-3 sm:py-2 bg-amber-600 hover:bg-amber-700 text-white"
>
<RefreshCw className={cn("size-3.5", reauthing && "animate-spin")} />
Re-authenticate
</Button>
) : (
<Button
onClick={onSave}
disabled={isSaving || isDisconnecting}
className="text-xs sm:text-sm flex-1 sm:flex-initial h-12 sm:h-auto py-3 sm:py-2"
>
{isSaving ? (
<>
<Spinner size="sm" className="mr-2" />
Saving
</>
) : (
"Save Changes"
)}
</Button>
)}
</div>
</div>
);

View file

@ -261,35 +261,28 @@ export const useConnectorDialog = () => {
| (typeof COMPOSIO_CONNECTORS)[number]
| undefined;
if (result.connectorId) {
const connectorId = parseInt(result.connectorId, 10);
newConnector = fetchResult.data.find(
(c: SearchSourceConnector) => c.id === connectorId
);
if (newConnector) {
const connectorType = newConnector.connector_type;
oauthConnector =
OAUTH_CONNECTORS.find(
(c) => c.connectorType === connectorType
) ||
COMPOSIO_CONNECTORS.find(
(c) => c.connectorType === connectorType
);
if (result.connectorId) {
const connectorId = parseInt(result.connectorId, 10);
newConnector = fetchResult.data.find((c: SearchSourceConnector) => c.id === connectorId);
if (newConnector) {
const connectorType = newConnector.connector_type;
oauthConnector =
OAUTH_CONNECTORS.find((c) => c.connectorType === connectorType) ||
COMPOSIO_CONNECTORS.find((c) => c.connectorType === connectorType);
}
}
}
if (!newConnector && result.connector) {
oauthConnector =
OAUTH_CONNECTORS.find((c) => c.id === result.connector) ||
COMPOSIO_CONNECTORS.find((c) => c.id === result.connector);
if (oauthConnector) {
const oauthType = oauthConnector.connectorType;
newConnector = fetchResult.data.find(
(c: SearchSourceConnector) =>
c.connector_type === oauthType
);
if (!newConnector && result.connector) {
oauthConnector =
OAUTH_CONNECTORS.find((c) => c.id === result.connector) ||
COMPOSIO_CONNECTORS.find((c) => c.id === result.connector);
if (oauthConnector) {
const oauthType = oauthConnector.connectorType;
newConnector = fetchResult.data.find(
(c: SearchSourceConnector) => c.connector_type === oauthType
);
}
}
}
if (newConnector && oauthConnector) {
const connectorValidation = searchSourceConnector.safeParse(newConnector);
@ -599,17 +592,17 @@ export const useConnectorDialog = () => {
: `${connectorTitle} connected and syncing started!`;
toast.success(successMessage);
setIsOpen(false);
setIsOpen(false);
setIndexingConfig(null);
setIndexingConnector(null);
setIndexingConnectorConfig(null);
setIndexingConfig(null);
setIndexingConnector(null);
setIndexingConnectorConfig(null);
queryClient.invalidateQueries({
queryKey: cacheKeys.logs.summary(Number(searchSpaceId)),
});
queryClient.invalidateQueries({
queryKey: cacheKeys.logs.summary(Number(searchSpaceId)),
});
await refetchAllConnectors();
await refetchAllConnectors();
} else {
// Non-indexable connector
// For Circleback, transition to edit view to show webhook URL
@ -631,11 +624,11 @@ export const useConnectorDialog = () => {
setStartDate(undefined);
setEndDate(undefined);
toast.success(`${connectorTitle} connected successfully!`, {
description: "Configure the webhook URL in your Circleback settings.",
});
toast.success(`${connectorTitle} connected successfully!`, {
description: "Configure the webhook URL in your Circleback settings.",
});
await refetchAllConnectors();
await refetchAllConnectors();
} else {
// Other non-indexable connectors - just show success message and close
const successMessage =
@ -644,13 +637,13 @@ export const useConnectorDialog = () => {
: `${connectorTitle} connected successfully!`;
toast.success(successMessage);
await refetchAllConnectors();
await refetchAllConnectors();
setIsOpen(false);
setIsOpen(false);
setIndexingConfig(null);
setIndexingConnector(null);
setIndexingConnectorConfig(null);
setIndexingConfig(null);
setIndexingConnector(null);
setIndexingConnectorConfig(null);
}
}
}
@ -870,12 +863,12 @@ export const useConnectorDialog = () => {
);
}
toast.success(`${indexingConfig.connectorTitle} indexing started`);
toast.success(`${indexingConfig.connectorTitle} indexing started`);
setIsOpen(false);
setIsFromOAuth(false);
setIsOpen(false);
setIsFromOAuth(false);
refreshConnectors();
refreshConnectors();
queryClient.invalidateQueries({
queryKey: cacheKeys.logs.summary(Number(searchSpaceId)),
});
@ -927,21 +920,21 @@ export const useConnectorDialog = () => {
return;
}
// Track if we came from accounts list view so handleBackFromEdit can restore it
if (viewingAccountsType && viewingAccountsType.connectorType === connector.connector_type) {
setCameFromAccountsList(viewingAccountsType);
} else {
setCameFromAccountsList(null);
}
setViewingAccountsType(null);
// Track if we came from accounts list view so handleBackFromEdit can restore it
if (viewingAccountsType && viewingAccountsType.connectorType === connector.connector_type) {
setCameFromAccountsList(viewingAccountsType);
} else {
setCameFromAccountsList(null);
}
setViewingAccountsType(null);
// Track if we came from MCP list view so handleBackFromEdit can restore it
if (viewingMCPList && connector.connector_type === "MCP_CONNECTOR") {
setCameFromMCPList(true);
} else {
setCameFromMCPList(false);
}
setViewingMCPList(false);
// Track if we came from MCP list view so handleBackFromEdit can restore it
if (viewingMCPList && connector.connector_type === "MCP_CONNECTOR") {
setCameFromMCPList(true);
} else {
setCameFromMCPList(false);
}
setViewingMCPList(false);
// Track index with date range opened event
if (connector.is_indexable) {
@ -952,15 +945,15 @@ export const useConnectorDialog = () => {
);
}
setEditingConnector(connector);
setConnectorName(connector.name);
setPeriodicEnabled(!connector.is_indexable ? false : connector.periodic_indexing_enabled);
setFrequencyMinutes(connector.indexing_frequency_minutes?.toString() || "1440");
setEnableSummary(connector.enable_summary ?? false);
setStartDate(undefined);
setEndDate(undefined);
},
[searchSpaceId, viewingAccountsType, viewingMCPList, handleViewMCPList, activeTab]
setEditingConnector(connector);
setConnectorName(connector.name);
setPeriodicEnabled(!connector.is_indexable ? false : connector.periodic_indexing_enabled);
setFrequencyMinutes(connector.indexing_frequency_minutes?.toString() || "1440");
setEnableSummary(connector.enable_summary ?? false);
setStartDate(undefined);
setEndDate(undefined);
},
[searchSpaceId, viewingAccountsType, viewingMCPList, handleViewMCPList, activeTab]
);
// Handle saving connector changes
@ -1139,35 +1132,35 @@ export const useConnectorDialog = () => {
: indexingDescription,
});
setIsOpen(false);
setIsOpen(false);
refreshConnectors();
queryClient.invalidateQueries({
queryKey: cacheKeys.logs.summary(Number(searchSpaceId)),
});
} catch (error) {
console.error("Error saving connector:", error);
toast.error("Failed to save connector changes");
} finally {
setIsSaving(false);
}
},
[
editingConnector,
searchSpaceId,
isSaving,
startDate,
endDate,
indexConnector,
updateConnector,
periodicEnabled,
frequencyMinutes,
enableSummary,
getFrequencyLabel,
connectorConfig,
connectorName,
setIsOpen,
]
refreshConnectors();
queryClient.invalidateQueries({
queryKey: cacheKeys.logs.summary(Number(searchSpaceId)),
});
} catch (error) {
console.error("Error saving connector:", error);
toast.error("Failed to save connector changes");
} finally {
setIsSaving(false);
}
},
[
editingConnector,
searchSpaceId,
isSaving,
startDate,
endDate,
indexConnector,
updateConnector,
periodicEnabled,
frequencyMinutes,
enableSummary,
getFrequencyLabel,
connectorConfig,
connectorName,
setIsOpen,
]
);
// Handle disconnecting connector
@ -1194,19 +1187,19 @@ export const useConnectorDialog = () => {
: `${editingConnector.name} disconnected successfully`
);
if (editingConnector.connector_type === "MCP_CONNECTOR" && cameFromMCPList) {
setViewingMCPList(true);
setEditingConnector(null);
setConnectorName(null);
setConnectorConfig(null);
} else {
setEditingConnector(null);
setConnectorName(null);
setConnectorConfig(null);
setIsOpen(false);
}
if (editingConnector.connector_type === "MCP_CONNECTOR" && cameFromMCPList) {
setViewingMCPList(true);
setEditingConnector(null);
setConnectorName(null);
setConnectorConfig(null);
} else {
setEditingConnector(null);
setConnectorName(null);
setConnectorConfig(null);
setIsOpen(false);
}
refreshConnectors();
refreshConnectors();
queryClient.invalidateQueries({
queryKey: cacheKeys.logs.summary(Number(searchSpaceId)),
});
@ -1312,13 +1305,13 @@ export const useConnectorDialog = () => {
setEditingConnector(null);
setConnectorName(null);
setConnectorConfig(null);
setConnectingConnectorType(null);
setViewingAccountsType(null);
setViewingMCPList(false);
setCameFromAccountsList(null);
setCameFromMCPList(false);
setConnectCameFromMCPList(false);
setStartDate(undefined);
setConnectingConnectorType(null);
setViewingAccountsType(null);
setViewingMCPList(false);
setCameFromAccountsList(null);
setCameFromMCPList(false);
setConnectCameFromMCPList(false);
setStartDate(undefined);
setEndDate(undefined);
setPeriodicEnabled(false);
setFrequencyMinutes("1440");

View file

@ -203,8 +203,7 @@ export const ConnectorAccountsListView: FC<ConnectorAccountsListViewProps> = ({
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{typeConnectors.map((connector) => {
const isIndexing = indexingConnectorIds.has(connector.id);
const isAuthExpired =
!!reauthEndpoint && connector.config?.auth_expired === true;
const isAuthExpired = !!reauthEndpoint && connector.config?.auth_expired === true;
return (
<div
@ -252,7 +251,9 @@ export const ConnectorAccountsListView: FC<ConnectorAccountsListViewProps> = ({
onClick={() => handleReauth(connector.id)}
disabled={reauthingId === connector.id}
>
<RefreshCw className={cn("size-3.5", reauthingId === connector.id && "animate-spin")} />
<RefreshCw
className={cn("size-3.5", reauthingId === connector.id && "animate-spin")}
/>
Re-authenticate
</Button>
) : (

View file

@ -32,7 +32,9 @@ export const ThinkingStepsDisplay: FC<{ steps: ThinkingStep[]; isThreadRunning?:
const inProgressStep = steps.find((s) => getEffectiveStatus(s) === "in_progress");
const allCompleted =
steps.length > 0 && !isThreadRunning && steps.every((s) => getEffectiveStatus(s) === "completed");
steps.length > 0 &&
!isThreadRunning &&
steps.every((s) => getEffectiveStatus(s) === "completed");
const isProcessing = isThreadRunning && !allCompleted;
// Auto-collapse when all tasks are completed
@ -127,7 +129,7 @@ export const ThinkingStepsDisplay: FC<{ steps: ThinkingStep[]; isThreadRunning?:
effectiveStatus === "pending" && "text-muted-foreground/60"
)}
>
{step.title}
{step.title}
</div>
{/* Step items (sub-content) */}

View file

@ -90,7 +90,11 @@ import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover
import { Switch } from "@/components/ui/switch";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
import { CONNECTOR_ICON_TO_TYPES, CONNECTOR_TOOL_ICON_PATHS, getToolIcon } from "@/contracts/enums/toolIcons";
import {
CONNECTOR_ICON_TO_TYPES,
CONNECTOR_TOOL_ICON_PATHS,
getToolIcon,
} from "@/contracts/enums/toolIcons";
import type { Document } from "@/contracts/types/document.types";
import { useBatchCommentsPreload } from "@/hooks/use-comments";
import { useCommentsElectric } from "@/hooks/use-comments-electric";
@ -735,71 +739,75 @@ const ComposerAction: FC<ComposerActionProps> = ({ isBlockedByOtherUser = false
</span>
</div>
<div className="overflow-y-auto pb-6" onScroll={handleToolsScroll}>
{groupedTools.filter((g) => !g.connectorIcon).map((group) => (
<div key={group.label}>
<div className="px-4 pt-3 pb-1 text-xs text-muted-foreground/80 font-medium select-none">
{group.label}
{groupedTools
.filter((g) => !g.connectorIcon)
.map((group) => (
<div key={group.label}>
<div className="px-4 pt-3 pb-1 text-xs text-muted-foreground/80 font-medium select-none">
{group.label}
</div>
{group.tools.map((tool) => {
const isDisabled = disabledTools.includes(tool.name);
const ToolIcon = getToolIcon(tool.name);
return (
<div
key={tool.name}
className="flex w-full items-center gap-3 px-4 py-2 hover:bg-muted-foreground/10 transition-colors"
>
<ToolIcon className="size-4 shrink-0 text-muted-foreground" />
<span className="flex-1 min-w-0 text-sm font-medium truncate">
{formatToolName(tool.name)}
</span>
<Switch
checked={!isDisabled}
onCheckedChange={() => toggleTool(tool.name)}
className="shrink-0"
/>
</div>
);
})}
</div>
{group.tools.map((tool) => {
const isDisabled = disabledTools.includes(tool.name);
const ToolIcon = getToolIcon(tool.name);
return (
<div
key={tool.name}
className="flex w-full items-center gap-3 px-4 py-2 hover:bg-muted-foreground/10 transition-colors"
>
<ToolIcon className="size-4 shrink-0 text-muted-foreground" />
<span className="flex-1 min-w-0 text-sm font-medium truncate">
{formatToolName(tool.name)}
</span>
<Switch
checked={!isDisabled}
onCheckedChange={() => toggleTool(tool.name)}
className="shrink-0"
/>
</div>
);
})}
</div>
))}
))}
{groupedTools.some((g) => g.connectorIcon) && (
<div>
<div className="px-4 pt-3 pb-1 text-xs text-muted-foreground/80 font-medium select-none">
Connector Actions
</div>
{groupedTools.filter((g) => g.connectorIcon).map((group) => {
const iconKey = group.connectorIcon ?? "";
const iconInfo = CONNECTOR_TOOL_ICON_PATHS[iconKey];
const toolNames = group.tools.map((t) => t.name);
const allDisabled = toolNames.every((n) => disabledTools.includes(n));
return (
<div
key={group.label}
className="flex w-full items-center gap-3 px-4 py-2 hover:bg-muted-foreground/10 transition-colors"
>
{iconInfo ? (
<Image
src={iconInfo.src}
alt={iconInfo.alt}
width={18}
height={18}
className="size-[18px] shrink-0 select-none pointer-events-none"
draggable={false}
{groupedTools
.filter((g) => g.connectorIcon)
.map((group) => {
const iconKey = group.connectorIcon ?? "";
const iconInfo = CONNECTOR_TOOL_ICON_PATHS[iconKey];
const toolNames = group.tools.map((t) => t.name);
const allDisabled = toolNames.every((n) => disabledTools.includes(n));
return (
<div
key={group.label}
className="flex w-full items-center gap-3 px-4 py-2 hover:bg-muted-foreground/10 transition-colors"
>
{iconInfo ? (
<Image
src={iconInfo.src}
alt={iconInfo.alt}
width={18}
height={18}
className="size-[18px] shrink-0 select-none pointer-events-none"
draggable={false}
/>
) : (
<Wrench className="size-4 shrink-0 text-muted-foreground" />
)}
<span className="flex-1 min-w-0 text-sm font-medium truncate">
{group.label}
</span>
<Switch
checked={!allDisabled}
onCheckedChange={() => toggleToolGroup(toolNames)}
className="shrink-0"
/>
) : (
<Wrench className="size-4 shrink-0 text-muted-foreground" />
)}
<span className="flex-1 min-w-0 text-sm font-medium truncate">
{group.label}
</span>
<Switch
checked={!allDisabled}
onCheckedChange={() => toggleToolGroup(toolNames)}
className="shrink-0"
/>
</div>
);
})}
</div>
);
})}
</div>
)}
{!filteredTools?.length && (
@ -857,82 +865,87 @@ const ComposerAction: FC<ComposerActionProps> = ({ isBlockedByOtherUser = false
WebkitMaskImage: `linear-gradient(to bottom, ${toolsScrollPos === "top" ? "black" : "transparent"}, black 16px, black calc(100% - 16px), ${toolsScrollPos === "bottom" ? "black" : "transparent"})`,
}}
>
{groupedTools.filter((g) => !g.connectorIcon).map((group) => (
<div key={group.label}>
<div className="px-2.5 sm:px-3 pt-2 pb-0.5 text-[10px] sm:text-xs text-muted-foreground/80 font-normal select-none">
{group.label}
{groupedTools
.filter((g) => !g.connectorIcon)
.map((group) => (
<div key={group.label}>
<div className="px-2.5 sm:px-3 pt-2 pb-0.5 text-[10px] sm:text-xs text-muted-foreground/80 font-normal select-none">
{group.label}
</div>
{group.tools.map((tool) => {
const isDisabled = disabledTools.includes(tool.name);
const ToolIcon = getToolIcon(tool.name);
const row = (
<div className="flex w-full items-center gap-2 sm:gap-3 px-2.5 sm:px-3 py-1 sm:py-1.5 hover:bg-muted-foreground/10 transition-colors">
<ToolIcon className="size-3.5 sm:size-4 shrink-0 text-muted-foreground" />
<span className="flex-1 min-w-0 text-xs sm:text-sm font-medium truncate">
{formatToolName(tool.name)}
</span>
<Switch
checked={!isDisabled}
onCheckedChange={() => toggleTool(tool.name)}
className="shrink-0 scale-[0.6] sm:scale-75"
/>
</div>
);
return (
<Tooltip key={tool.name}>
<TooltipTrigger asChild>{row}</TooltipTrigger>
<TooltipContent side="right" className="max-w-64 text-xs">
{tool.description}
</TooltipContent>
</Tooltip>
);
})}
</div>
{group.tools.map((tool) => {
const isDisabled = disabledTools.includes(tool.name);
const ToolIcon = getToolIcon(tool.name);
const row = (
<div className="flex w-full items-center gap-2 sm:gap-3 px-2.5 sm:px-3 py-1 sm:py-1.5 hover:bg-muted-foreground/10 transition-colors">
<ToolIcon className="size-3.5 sm:size-4 shrink-0 text-muted-foreground" />
<span className="flex-1 min-w-0 text-xs sm:text-sm font-medium truncate">
{formatToolName(tool.name)}
</span>
<Switch
checked={!isDisabled}
onCheckedChange={() => toggleTool(tool.name)}
className="shrink-0 scale-[0.6] sm:scale-75"
/>
</div>
);
return (
<Tooltip key={tool.name}>
<TooltipTrigger asChild>{row}</TooltipTrigger>
<TooltipContent side="right" className="max-w-64 text-xs">
{tool.description}
</TooltipContent>
</Tooltip>
);
})}
</div>
))}
))}
{groupedTools.some((g) => g.connectorIcon) && (
<div>
<div className="px-2.5 sm:px-3 pt-2 pb-0.5 text-[10px] sm:text-xs text-muted-foreground/80 font-normal select-none">
Connector Actions
</div>
{groupedTools.filter((g) => g.connectorIcon).map((group) => {
const iconKey = group.connectorIcon ?? "";
const iconInfo = CONNECTOR_TOOL_ICON_PATHS[iconKey];
const toolNames = group.tools.map((t) => t.name);
const allDisabled = toolNames.every((n) => disabledTools.includes(n));
const groupDef = TOOL_GROUPS.find((g) => g.label === group.label);
const row = (
<div className="flex w-full items-center gap-2 sm:gap-3 px-2.5 sm:px-3 py-1 sm:py-1.5 hover:bg-muted-foreground/10 transition-colors">
{iconInfo ? (
<Image
src={iconInfo.src}
alt={iconInfo.alt}
width={16}
height={16}
className="size-3.5 sm:size-4 shrink-0 select-none pointer-events-none"
draggable={false}
{groupedTools
.filter((g) => g.connectorIcon)
.map((group) => {
const iconKey = group.connectorIcon ?? "";
const iconInfo = CONNECTOR_TOOL_ICON_PATHS[iconKey];
const toolNames = group.tools.map((t) => t.name);
const allDisabled = toolNames.every((n) => disabledTools.includes(n));
const groupDef = TOOL_GROUPS.find((g) => g.label === group.label);
const row = (
<div className="flex w-full items-center gap-2 sm:gap-3 px-2.5 sm:px-3 py-1 sm:py-1.5 hover:bg-muted-foreground/10 transition-colors">
{iconInfo ? (
<Image
src={iconInfo.src}
alt={iconInfo.alt}
width={16}
height={16}
className="size-3.5 sm:size-4 shrink-0 select-none pointer-events-none"
draggable={false}
/>
) : (
<Wrench className="size-3.5 sm:size-4 shrink-0 text-muted-foreground" />
)}
<span className="flex-1 min-w-0 text-xs sm:text-sm font-medium truncate">
{group.label}
</span>
<Switch
checked={!allDisabled}
onCheckedChange={() => toggleToolGroup(toolNames)}
className="shrink-0 scale-[0.6] sm:scale-75"
/>
) : (
<Wrench className="size-3.5 sm:size-4 shrink-0 text-muted-foreground" />
)}
<span className="flex-1 min-w-0 text-xs sm:text-sm font-medium truncate">
{group.label}
</span>
<Switch
checked={!allDisabled}
onCheckedChange={() => toggleToolGroup(toolNames)}
className="shrink-0 scale-[0.6] sm:scale-75"
/>
</div>
);
return (
<Tooltip key={group.label}>
<TooltipTrigger asChild>{row}</TooltipTrigger>
<TooltipContent side="right" className="max-w-72 text-xs">
{groupDef?.tooltip ?? group.tools.map((t) => t.description).join(" · ")}
</TooltipContent>
</Tooltip>
);
})}
</div>
);
return (
<Tooltip key={group.label}>
<TooltipTrigger asChild>{row}</TooltipTrigger>
<TooltipContent side="right" className="max-w-72 text-xs">
{groupDef?.tooltip ??
group.tools.map((t) => t.description).join(" · ")}
</TooltipContent>
</Tooltip>
);
})}
</div>
)}
{!filteredTools?.length && (

View file

@ -78,14 +78,21 @@ export function ComposioDriveFolderTree({
}: ComposioDriveFolderTreeProps) {
const [itemStates, setItemStates] = useState<Map<string, ItemTreeNode>>(new Map());
const { data: rootData, isLoading: isLoadingRoot, error: rootError } = useComposioDriveFolders({
const {
data: rootData,
isLoading: isLoadingRoot,
error: rootError,
} = useComposioDriveFolders({
connectorId,
});
useEffect(() => {
if (rootError && onAuthError) {
const msg = rootError instanceof Error ? rootError.message : String(rootError);
if (msg.toLowerCase().includes("authentication expired") || msg.toLowerCase().includes("re-authenticate")) {
if (
msg.toLowerCase().includes("authentication expired") ||
msg.toLowerCase().includes("re-authenticate")
) {
onAuthError(msg);
}
}
@ -363,19 +370,21 @@ export function ComposioDriveFolderTree({
{!isLoadingRoot && rootItems.map((item) => renderItem(item, 0))}
</div>
{!isLoadingRoot && rootError && (
<div className="text-center text-xs sm:text-sm text-amber-600 dark:text-amber-500 py-4 sm:py-8">
{(rootError instanceof Error ? rootError.message : String(rootError)).includes("authentication expired")
? "Google Drive authentication has expired. Please re-authenticate above."
: "Failed to load Google Drive contents."}
</div>
)}
{!isLoadingRoot && rootError && (
<div className="text-center text-xs sm:text-sm text-amber-600 dark:text-amber-500 py-4 sm:py-8">
{(rootError instanceof Error ? rootError.message : String(rootError)).includes(
"authentication expired"
)
? "Google Drive authentication has expired. Please re-authenticate above."
: "Failed to load Google Drive contents."}
</div>
)}
{!isLoadingRoot && !rootError && rootItems.length === 0 && (
<div className="text-center text-xs sm:text-sm text-muted-foreground py-4 sm:py-8">
No files or folders found in your Google Drive
</div>
)}
{!isLoadingRoot && !rootError && rootItems.length === 0 && (
<div className="text-center text-xs sm:text-sm text-muted-foreground py-4 sm:py-8">
No files or folders found in your Google Drive
</div>
)}
</div>
</ScrollArea>
</div>

View file

@ -1,18 +1,15 @@
"use client";
import { TagInput, type Tag as TagType } from "emblor";
import { format } from "date-fns";
import { TagInput, type Tag as TagType } from "emblor";
import { useAtomValue, useSetAtom } from "jotai";
import { CalendarIcon, XIcon } from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
closeHitlEditPanelAtom,
hitlEditPanelAtom,
} from "@/atoms/chat/hitl-edit-panel.atom";
import type { ExtraField } from "@/atoms/chat/hitl-edit-panel.atom";
import { Calendar } from "@/components/ui/calendar";
import { closeHitlEditPanelAtom, hitlEditPanelAtom } from "@/atoms/chat/hitl-edit-panel.atom";
import { PlateEditor } from "@/components/editor/plate-editor";
import { Button } from "@/components/ui/button";
import { Calendar } from "@/components/ui/calendar";
import { Drawer, DrawerContent, DrawerHandle, DrawerTitle } from "@/components/ui/drawer";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@ -58,14 +55,9 @@ function EmailsTagField({
onChangeRef.current(tagsToEmailString(tags));
}, [tags]);
const handleSetTags = useCallback(
(newTags: TagType[] | ((prev: TagType[]) => TagType[])) => {
setTags((prev) =>
typeof newTags === "function" ? newTags(prev) : newTags
);
},
[]
);
const handleSetTags = useCallback((newTags: TagType[] | ((prev: TagType[]) => TagType[])) => {
setTags((prev) => (typeof newTags === "function" ? newTags(prev) : newTags));
}, []);
const handleAddTag = useCallback(
(text: string) => {
@ -265,7 +257,10 @@ export function HitlEditPanelContent({
<div className="flex flex-col gap-3 px-4 py-3 border-b">
{extraFields.map((field) => (
<div key={field.key} className="flex flex-col gap-1.5">
<Label htmlFor={`extra-field-${field.key}`} className="text-xs font-medium text-muted-foreground">
<Label
htmlFor={`extra-field-${field.key}`}
className="text-xs font-medium text-muted-foreground"
>
{field.label}
</Label>
{field.type === "emails" ? (
@ -360,9 +355,7 @@ function MobileHitlEditDrawer() {
overlayClassName="z-80"
>
<DrawerHandle />
<DrawerTitle className="sr-only">
Edit {panelState.toolName}
</DrawerTitle>
<DrawerTitle className="sr-only">Edit {panelState.toolName}</DrawerTitle>
<div className="min-h-0 flex-1 flex flex-col overflow-hidden">
<HitlEditPanelContent
title={panelState.title}

View file

@ -3,7 +3,6 @@
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useAtom, useAtomValue, useSetAtom } from "jotai";
import { AlertTriangle, Inbox, Megaphone, SquareLibrary } from "lucide-react";
import { Spinner } from "@/components/ui/spinner";
import { useParams, usePathname, useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { useTheme } from "next-themes";
@ -22,6 +21,10 @@ import {
userSettingsDialogAtom,
} from "@/atoms/settings/settings-dialog.atoms";
import { currentUserAtom } from "@/atoms/user/user-query.atoms";
import { MorePagesDialog } from "@/components/settings/more-pages-dialog";
import { SearchSpaceSettingsDialog } from "@/components/settings/search-space-settings-dialog";
import { TeamDialog } from "@/components/settings/team-dialog";
import { UserSettingsDialog } from "@/components/settings/user-settings-dialog";
import {
AlertDialog,
AlertDialogAction,
@ -42,7 +45,7 @@ import {
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Spinner } from "@/components/ui/spinner";
import { useAnnouncements } from "@/hooks/use-announcements";
import { useDocumentsProcessing } from "@/hooks/use-documents-processing";
import { useInbox } from "@/hooks/use-inbox";
@ -54,10 +57,6 @@ import { deleteThread, fetchThreads, updateThread } from "@/lib/chat/thread-pers
import { cleanupElectric } from "@/lib/electric/client";
import { resetUser, trackLogout } from "@/lib/posthog/events";
import { cacheKeys } from "@/lib/query-client/cache-keys";
import { MorePagesDialog } from "@/components/settings/more-pages-dialog";
import { SearchSpaceSettingsDialog } from "@/components/settings/search-space-settings-dialog";
import { TeamDialog } from "@/components/settings/team-dialog";
import { UserSettingsDialog } from "@/components/settings/user-settings-dialog";
import type { ChatItem, NavItem, SearchSpace } from "../types/layout.types";
import { CreateSearchSpaceDialog } from "../ui/dialogs";
import { LayoutShell } from "../ui/shell";
@ -822,11 +821,7 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
disabled={isDeletingChat}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90 gap-2"
>
{isDeletingChat ? (
<Spinner size="sm" />
) : (
tCommon("delete")
)}
{isDeletingChat ? <Spinner size="sm" /> : tCommon("delete")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>

View file

@ -114,7 +114,13 @@ export function RightPanel({ documentsPanel }: RightPanelProps) {
} else if (effectiveTab === "report" && !reportOpen) {
effectiveTab = editorOpen ? "editor" : "sources";
} else if (effectiveTab === "sources" && !documentsOpen) {
effectiveTab = hitlEditOpen ? "hitl-edit" : editorOpen ? "editor" : reportOpen ? "report" : "sources";
effectiveTab = hitlEditOpen
? "hitl-edit"
: editorOpen
? "editor"
: reportOpen
? "report"
: "sources";
}
const targetWidth = PANEL_WIDTHS[effectiveTab];
@ -160,14 +166,14 @@ export function RightPanel({ documentsPanel }: RightPanelProps) {
)}
{effectiveTab === "hitl-edit" && hitlEditOpen && hitlEditState.onSave && (
<div className="h-full flex flex-col">
<HitlEditPanelContent
title={hitlEditState.title}
content={hitlEditState.content}
toolName={hitlEditState.toolName}
extraFields={hitlEditState.extraFields}
onSave={hitlEditState.onSave}
onClose={closeHitlEdit}
/>
<HitlEditPanelContent
title={hitlEditState.title}
content={hitlEditState.content}
toolName={hitlEditState.toolName}
extraFields={hitlEditState.extraFields}
onSave={hitlEditState.onSave}
onClose={closeHitlEdit}
/>
</div>
)}
</div>

View file

@ -18,6 +18,7 @@ import { useParams, useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/animated-tabs";
import { Button } from "@/components/ui/button";
import {
Dialog,
@ -36,7 +37,6 @@ import {
import { Input } from "@/components/ui/input";
import { Skeleton } from "@/components/ui/skeleton";
import { Spinner } from "@/components/ui/spinner";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/animated-tabs";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { useDebouncedValue } from "@/hooks/use-debounced-value";
import { useLongPress } from "@/hooks/use-long-press";

View file

@ -18,6 +18,7 @@ import { useParams, useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/animated-tabs";
import { Button } from "@/components/ui/button";
import {
Dialog,
@ -36,7 +37,6 @@ import {
import { Input } from "@/components/ui/input";
import { Skeleton } from "@/components/ui/skeleton";
import { Spinner } from "@/components/ui/spinner";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/animated-tabs";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { useDebouncedValue } from "@/hooks/use-debounced-value";
import { useLongPress } from "@/hooks/use-long-press";

View file

@ -15,7 +15,6 @@ import {
} from "lucide-react";
import { useCallback, useMemo, useState } from "react";
import { toast } from "sonner";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import {
createImageGenConfigMutationAtom,
deleteImageGenConfigMutationAtom,
@ -38,6 +37,7 @@ import {
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import {
@ -69,12 +69,12 @@ import { Separator } from "@/components/ui/separator";
import { Skeleton } from "@/components/ui/skeleton";
import { Spinner } from "@/components/ui/spinner";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { useMediaQuery } from "@/hooks/use-media-query";
import {
getImageGenModelsByProvider,
IMAGE_GEN_PROVIDERS,
} from "@/contracts/enums/image-gen-providers";
import type { ImageGenerationConfig } from "@/contracts/types/new-llm-config.types";
import { useMediaQuery } from "@/hooks/use-media-query";
import { getProviderIcon } from "@/lib/provider-icons";
import { cn } from "@/lib/utils";

View file

@ -13,7 +13,6 @@ import {
Wand2,
} from "lucide-react";
import { useCallback, useMemo, useState } from "react";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { membersAtom, myAccessAtom } from "@/atoms/members/members-query.atoms";
import {
createNewLLMConfigMutationAtom,
@ -36,6 +35,7 @@ import {
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";

View file

@ -2,9 +2,9 @@
import { useAtom } from "jotai";
import { useTranslations } from "next-intl";
import { TeamContent } from "@/app/dashboard/[search_space_id]/team/team-content";
import { teamDialogAtom } from "@/atoms/settings/settings-dialog.atoms";
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
import { TeamContent } from "@/app/dashboard/[search_space_id]/team/team-content";
interface TeamDialogProps {
searchSpaceId: number;

View file

@ -3,9 +3,9 @@
import { useAtom } from "jotai";
import { KeyRound, User } from "lucide-react";
import { useTranslations } from "next-intl";
import { userSettingsDialogAtom } from "@/atoms/settings/settings-dialog.atoms";
import { ApiKeyContent } from "@/app/dashboard/[search_space_id]/user-settings/components/ApiKeyContent";
import { ProfileContent } from "@/app/dashboard/[search_space_id]/user-settings/components/ProfileContent";
import { userSettingsDialogAtom } from "@/atoms/settings/settings-dialog.atoms";
import { SettingsDialog } from "@/components/settings/settings-dialog";
export function UserSettingsDialog() {

View file

@ -1,8 +1,12 @@
"use client";
import { makeAssistantToolUI } from "@assistant-ui/react";
import { useSetAtom } from "jotai";
import { CornerDownLeftIcon, Pen } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { openHitlEditPanelAtom } from "@/atoms/chat/hitl-edit-panel.atom";
import { PlateEditor } from "@/components/editor/plate-editor";
import { TextShimmerLoader } from "@/components/prompt-kit/loader";
import { Button } from "@/components/ui/button";
import {
Select,
@ -11,11 +15,7 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { PlateEditor } from "@/components/editor/plate-editor";
import { TextShimmerLoader } from "@/components/prompt-kit/loader";
import { useHitlPhase } from "@/hooks/use-hitl-phase";
import { useSetAtom } from "jotai";
import { openHitlEditPanelAtom } from "@/atoms/chat/hitl-edit-panel.atom";
interface ConfluenceAccount {
id: number;
@ -108,9 +108,7 @@ function isAuthErrorResult(result: unknown): result is AuthErrorResult {
);
}
function isInsufficientPermissionsResult(
result: unknown,
): result is InsufficientPermissionsResult {
function isInsufficientPermissionsResult(result: unknown): result is InsufficientPermissionsResult {
return (
typeof result === "object" &&
result !== null &&
@ -161,7 +159,7 @@ function ApprovalCard({
space_id: selectedSpaceId || null,
};
},
[args.title, args.content, selectedAccountId, selectedSpaceId, pendingEdits],
[args.title, args.content, selectedAccountId, selectedSpaceId, pendingEdits]
);
const handleApprove = useCallback(() => {
@ -177,7 +175,17 @@ function ApprovalCard({
args: buildFinalArgs(),
},
});
}, [phase, setProcessing, isPanelOpen, canApprove, allowedDecisions, onDecision, interruptData, buildFinalArgs, pendingEdits]);
}, [
phase,
setProcessing,
isPanelOpen,
canApprove,
allowedDecisions,
onDecision,
interruptData,
buildFinalArgs,
pendingEdits,
]);
useEffect(() => {
const handler = (e: KeyboardEvent) => {
@ -202,15 +210,16 @@ function ApprovalCard({
: "Create Confluence Page"}
</p>
{phase === "processing" ? (
<TextShimmerLoader text={pendingEdits ? "Creating page with your changes" : "Creating page"} size="sm" />
<TextShimmerLoader
text={pendingEdits ? "Creating page with your changes" : "Creating page"}
size="sm"
/>
) : phase === "complete" ? (
<p className="text-xs text-muted-foreground mt-0.5">
{pendingEdits ? "Page created with your changes" : "Page created"}
</p>
) : phase === "rejected" ? (
<p className="text-xs text-muted-foreground mt-0.5">
Page creation was cancelled
</p>
<p className="text-xs text-muted-foreground mt-0.5">Page creation was cancelled</p>
) : (
<p className="text-xs text-muted-foreground mt-0.5">
Requires your approval to proceed
@ -225,8 +234,8 @@ function ApprovalCard({
onClick={() => {
setIsPanelOpen(true);
openHitlEditPanel({
title: pendingEdits?.title ?? (args.title ?? ""),
content: pendingEdits?.content ?? (args.content ?? ""),
title: pendingEdits?.title ?? args.title ?? "",
content: pendingEdits?.content ?? args.content ?? "",
toolName: "Confluence Page",
onSave: (newTitle, newContent) => {
setIsPanelOpen(false);
@ -290,10 +299,7 @@ function ApprovalCard({
<p className="text-xs font-medium text-muted-foreground">
Space <span className="text-destructive">*</span>
</p>
<Select
value={selectedSpaceId}
onValueChange={setSelectedSpaceId}
>
<Select value={selectedSpaceId} onValueChange={setSelectedSpaceId}>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select a space" />
</SelectTrigger>
@ -379,9 +385,7 @@ function AuthErrorCard({ result }: { result: AuthErrorResult }) {
return (
<div className="my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30 select-none">
<div className="px-5 pt-5 pb-4">
<p className="text-sm font-semibold text-destructive">
All Confluence accounts expired
</p>
<p className="text-sm font-semibold text-destructive">All Confluence accounts expired</p>
</div>
<div className="mx-5 h-px bg-border/50" />
<div className="px-5 py-4">
@ -391,9 +395,7 @@ function AuthErrorCard({ result }: { result: AuthErrorResult }) {
);
}
function InsufficientPermissionsCard({
result,
}: { result: InsufficientPermissionsResult }) {
function InsufficientPermissionsCard({ result }: { result: InsufficientPermissionsResult }) {
return (
<div className="my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30 select-none">
<div className="px-5 pt-5 pb-4">
@ -474,7 +476,8 @@ export const CreateConfluencePageToolUI = makeAssistantToolUI<
}
if (isAuthErrorResult(result)) return <AuthErrorCard result={result} />;
if (isInsufficientPermissionsResult(result)) return <InsufficientPermissionsCard result={result} />;
if (isInsufficientPermissionsResult(result))
return <InsufficientPermissionsCard result={result} />;
if (isErrorResult(result)) return <ErrorCard result={result} />;
return <SuccessCard result={result as SuccessResult} />;

View file

@ -3,9 +3,9 @@
import { makeAssistantToolUI } from "@assistant-ui/react";
import { CornerDownLeftIcon } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { TextShimmerLoader } from "@/components/prompt-kit/loader";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { TextShimmerLoader } from "@/components/prompt-kit/loader";
import { useHitlPhase } from "@/hooks/use-hitl-phase";
interface InterruptResult {
@ -132,9 +132,7 @@ function isAuthErrorResult(result: unknown): result is AuthErrorResult {
);
}
function isInsufficientPermissionsResult(
result: unknown,
): result is InsufficientPermissionsResult {
function isInsufficientPermissionsResult(result: unknown): result is InsufficientPermissionsResult {
return (
typeof result === "object" &&
result !== null &&
@ -174,7 +172,15 @@ function ApprovalCard({
},
},
});
}, [phase, setProcessing, onDecision, interruptData, page?.page_id, context?.account?.id, deleteFromKb]);
}, [
phase,
setProcessing,
onDecision,
interruptData,
page?.page_id,
context?.account?.id,
deleteFromKb,
]);
useEffect(() => {
const handler = (e: KeyboardEvent) => {
@ -203,9 +209,7 @@ function ApprovalCard({
) : phase === "complete" ? (
<p className="text-xs text-muted-foreground mt-0.5">Page deleted</p>
) : phase === "rejected" ? (
<p className="text-xs text-muted-foreground mt-0.5">
Page deletion was cancelled
</p>
<p className="text-xs text-muted-foreground mt-0.5">Page deletion was cancelled</p>
) : (
<p className="text-xs text-muted-foreground mt-0.5">
Requires your approval to proceed
@ -238,9 +242,7 @@ function ApprovalCard({
<div className="w-full rounded-md border border-input bg-muted/50 px-3 py-2 text-sm space-y-1">
<div className="font-medium">{page.page_title}</div>
{page.space_id && (
<div className="text-xs text-muted-foreground">
Space: {page.space_id}
</div>
<div className="text-xs text-muted-foreground">Space: {page.space_id}</div>
)}
</div>
</div>
@ -279,11 +281,7 @@ function ApprovalCard({
<>
<div className="mx-5 h-px bg-border/50" />
<div className="px-5 py-4 flex items-center gap-2 select-none">
<Button
size="sm"
className="rounded-lg gap-1.5"
onClick={handleApprove}
>
<Button size="sm" className="rounded-lg gap-1.5" onClick={handleApprove}>
Approve
<CornerDownLeftIcon className="size-3 opacity-60" />
</Button>
@ -309,9 +307,7 @@ function AuthErrorCard({ result }: { result: AuthErrorResult }) {
return (
<div className="my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30 select-none">
<div className="px-5 pt-5 pb-4">
<p className="text-sm font-semibold text-destructive">
Confluence authentication expired
</p>
<p className="text-sm font-semibold text-destructive">Confluence authentication expired</p>
</div>
<div className="mx-5 h-px bg-border/50" />
<div className="px-5 py-4">
@ -321,9 +317,7 @@ function AuthErrorCard({ result }: { result: AuthErrorResult }) {
);
}
function InsufficientPermissionsCard({
result,
}: { result: InsufficientPermissionsResult }) {
function InsufficientPermissionsCard({ result }: { result: InsufficientPermissionsResult }) {
return (
<div className="my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30 select-none">
<div className="px-5 pt-5 pb-4">
@ -357,9 +351,7 @@ function NotFoundCard({ result }: { result: NotFoundResult }) {
return (
<div className="my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30 select-none">
<div className="px-5 pt-5 pb-4">
<p className="text-sm font-semibold text-amber-600 dark:text-amber-400">
Page not found
</p>
<p className="text-sm font-semibold text-amber-600 dark:text-amber-400">Page not found</p>
</div>
<div className="mx-5 h-px bg-border/50" />
<div className="px-5 py-4">
@ -437,7 +429,8 @@ export const DeleteConfluencePageToolUI = makeAssistantToolUI<
if (isNotFoundResult(result)) return <NotFoundCard result={result} />;
if (isAuthErrorResult(result)) return <AuthErrorCard result={result} />;
if (isInsufficientPermissionsResult(result)) return <InsufficientPermissionsCard result={result} />;
if (isInsufficientPermissionsResult(result))
return <InsufficientPermissionsCard result={result} />;
if (isWarningResult(result)) return <WarningCard result={result} />;
if (isErrorResult(result)) return <ErrorCard result={result} />;

View file

@ -4,11 +4,11 @@ import { makeAssistantToolUI } from "@assistant-ui/react";
import { useSetAtom } from "jotai";
import { CornerDownLeftIcon, Pen } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import { openHitlEditPanelAtom } from "@/atoms/chat/hitl-edit-panel.atom";
import { PlateEditor } from "@/components/editor/plate-editor";
import { TextShimmerLoader } from "@/components/prompt-kit/loader";
import { Button } from "@/components/ui/button";
import { useHitlPhase } from "@/hooks/use-hitl-phase";
import { openHitlEditPanelAtom } from "@/atoms/chat/hitl-edit-panel.atom";
interface InterruptResult {
__interrupt__: true;
@ -116,9 +116,7 @@ function isAuthErrorResult(result: unknown): result is AuthErrorResult {
);
}
function isInsufficientPermissionsResult(
result: unknown,
): result is InsufficientPermissionsResult {
function isInsufficientPermissionsResult(result: unknown): result is InsufficientPermissionsResult {
return (
typeof result === "object" &&
result !== null &&
@ -169,8 +167,7 @@ function ApprovalCard({
const canEdit = allowedDecisions.includes("edit");
const hasProposedChanges =
actionArgs.new_title || args.new_title ||
actionArgs.new_content || args.new_content;
actionArgs.new_title || args.new_title || actionArgs.new_content || args.new_content;
const buildFinalArgs = useCallback(() => {
return {
@ -196,7 +193,16 @@ function ApprovalCard({
args: buildFinalArgs(),
},
});
}, [phase, setProcessing, isPanelOpen, allowedDecisions, onDecision, interruptData, buildFinalArgs, hasPanelEdits]);
}, [
phase,
setProcessing,
isPanelOpen,
allowedDecisions,
onDecision,
interruptData,
buildFinalArgs,
hasPanelEdits,
]);
useEffect(() => {
const handler = (e: KeyboardEvent) => {
@ -221,15 +227,16 @@ function ApprovalCard({
: "Update Confluence Page"}
</p>
{phase === "processing" ? (
<TextShimmerLoader text={hasPanelEdits ? "Updating page with your changes" : "Updating page"} size="sm" />
<TextShimmerLoader
text={hasPanelEdits ? "Updating page with your changes" : "Updating page"}
size="sm"
/>
) : phase === "complete" ? (
<p className="text-xs text-muted-foreground mt-0.5">
{hasPanelEdits ? "Page updated with your changes" : "Page updated"}
</p>
) : phase === "rejected" ? (
<p className="text-xs text-muted-foreground mt-0.5">
Page update was cancelled
</p>
<p className="text-xs text-muted-foreground mt-0.5">Page update was cancelled</p>
) : (
<p className="text-xs text-muted-foreground mt-0.5">
Requires your approval to proceed
@ -293,7 +300,8 @@ function ApprovalCard({
className="max-h-[5rem] overflow-hidden text-xs text-muted-foreground"
style={{
maskImage: "linear-gradient(to bottom, black 50%, transparent 100%)",
WebkitMaskImage: "linear-gradient(to bottom, black 50%, transparent 100%)",
WebkitMaskImage:
"linear-gradient(to bottom, black 50%, transparent 100%)",
}}
>
<PlateEditor
@ -306,9 +314,7 @@ function ApprovalCard({
</div>
)}
{page.space_id && (
<div className="text-xs text-muted-foreground">
Space: {page.space_id}
</div>
<div className="text-xs text-muted-foreground">Space: {page.space_id}</div>
)}
</div>
</div>
@ -322,14 +328,18 @@ function ApprovalCard({
{/* Content preview — proposed changes */}
<div className="mx-5 h-px bg-border/50" />
<div className="px-5 pt-3">
{(hasProposedChanges || hasPanelEdits) ? (
{hasProposedChanges || hasPanelEdits ? (
<>
{(hasPanelEdits ? editedArgs.title : (actionArgs.new_title ?? args.new_title)) && (
<p className="text-sm font-medium text-foreground">
{String(hasPanelEdits ? editedArgs.title : (actionArgs.new_title ?? args.new_title))}
{String(
hasPanelEdits ? editedArgs.title : (actionArgs.new_title ?? args.new_title)
)}
</p>
)}
{(hasPanelEdits ? editedArgs.content : (actionArgs.new_content ?? args.new_content)) && (
{(hasPanelEdits
? editedArgs.content
: (actionArgs.new_content ?? args.new_content)) && (
<div
className="max-h-[7rem] overflow-hidden text-sm"
style={{
@ -338,7 +348,11 @@ function ApprovalCard({
}}
>
<PlateEditor
markdown={String(hasPanelEdits ? editedArgs.content : (actionArgs.new_content ?? args.new_content))}
markdown={String(
hasPanelEdits
? editedArgs.content
: (actionArgs.new_content ?? args.new_content)
)}
readOnly
preset="readonly"
editorVariant="none"
@ -393,9 +407,7 @@ function AuthErrorCard({ result }: { result: AuthErrorResult }) {
return (
<div className="my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30 select-none">
<div className="px-5 pt-5 pb-4">
<p className="text-sm font-semibold text-destructive">
Confluence authentication expired
</p>
<p className="text-sm font-semibold text-destructive">Confluence authentication expired</p>
</div>
<div className="mx-5 h-px bg-border/50" />
<div className="px-5 py-4">
@ -405,9 +417,7 @@ function AuthErrorCard({ result }: { result: AuthErrorResult }) {
);
}
function InsufficientPermissionsCard({
result,
}: { result: InsufficientPermissionsResult }) {
function InsufficientPermissionsCard({ result }: { result: InsufficientPermissionsResult }) {
return (
<div className="my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30 select-none">
<div className="px-5 pt-5 pb-4">
@ -441,9 +451,7 @@ function NotFoundCard({ result }: { result: NotFoundResult }) {
return (
<div className="my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30 select-none">
<div className="px-5 pt-5 pb-4">
<p className="text-sm font-semibold text-amber-600 dark:text-amber-400">
Page not found
</p>
<p className="text-sm font-semibold text-amber-600 dark:text-amber-400">Page not found</p>
</div>
<div className="mx-5 h-px bg-border/50" />
<div className="px-5 py-4">
@ -509,7 +517,8 @@ export const UpdateConfluencePageToolUI = makeAssistantToolUI<
if (isNotFoundResult(result)) return <NotFoundCard result={result} />;
if (isAuthErrorResult(result)) return <AuthErrorCard result={result} />;
if (isInsufficientPermissionsResult(result)) return <InsufficientPermissionsCard result={result} />;
if (isInsufficientPermissionsResult(result))
return <InsufficientPermissionsCard result={result} />;
if (isErrorResult(result)) return <ErrorCard result={result} />;
return <SuccessCard result={result as SuccessResult} />;

View file

@ -1,13 +1,13 @@
"use client";
import { makeAssistantToolUI } from "@assistant-ui/react";
import {
CornerDownLeftIcon,
Pen,
UserIcon,
UsersIcon,
} from "lucide-react";
import { useSetAtom } from "jotai";
import { CornerDownLeftIcon, Pen, UserIcon, UsersIcon } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import type { ExtraField } from "@/atoms/chat/hitl-edit-panel.atom";
import { openHitlEditPanelAtom } from "@/atoms/chat/hitl-edit-panel.atom";
import { PlateEditor } from "@/components/editor/plate-editor";
import { TextShimmerLoader } from "@/components/prompt-kit/loader";
import { Button } from "@/components/ui/button";
import {
Select,
@ -16,11 +16,6 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { PlateEditor } from "@/components/editor/plate-editor";
import { TextShimmerLoader } from "@/components/prompt-kit/loader";
import { useSetAtom } from "jotai";
import { openHitlEditPanelAtom } from "@/atoms/chat/hitl-edit-panel.atom";
import type { ExtraField } from "@/atoms/chat/hitl-edit-panel.atom";
import { useHitlPhase } from "@/hooks/use-hitl-phase";
interface GmailAccount {
@ -132,7 +127,11 @@ function ApprovalCard({
const [isPanelOpen, setIsPanelOpen] = useState(false);
const openHitlEditPanel = useSetAtom(openHitlEditPanelAtom);
const [pendingEdits, setPendingEdits] = useState<{
subject: string; body: string; to: string; cc: string; bcc: string;
subject: string;
body: string;
to: string;
cc: string;
bcc: string;
} | null>(null);
const accounts = interruptData.context?.accounts ?? [];
@ -175,7 +174,18 @@ function ApprovalCard({
},
},
});
}, [phase, isPanelOpen, canApprove, allowedDecisions, setProcessing, onDecision, interruptData, args, selectedAccountId, pendingEdits]);
}, [
phase,
isPanelOpen,
canApprove,
allowedDecisions,
setProcessing,
onDecision,
interruptData,
args,
selectedAccountId,
pendingEdits,
]);
useEffect(() => {
const handler = (e: KeyboardEvent) => {
@ -200,16 +210,17 @@ function ApprovalCard({
? "Gmail Draft Approved"
: "Create Gmail Draft"}
</p>
{phase === "processing" ? (
<TextShimmerLoader text={pendingEdits ? "Creating draft with your changes" : "Creating draft"} size="sm" />
{phase === "processing" ? (
<TextShimmerLoader
text={pendingEdits ? "Creating draft with your changes" : "Creating draft"}
size="sm"
/>
) : phase === "complete" ? (
<p className="text-xs text-muted-foreground mt-0.5">
{pendingEdits ? "Draft created with your changes" : "Draft created"}
</p>
) : phase === "rejected" ? (
<p className="text-xs text-muted-foreground mt-0.5">
Draft creation was cancelled
</p>
<p className="text-xs text-muted-foreground mt-0.5">Draft creation was cancelled</p>
) : (
<p className="text-xs text-muted-foreground mt-0.5">
Requires your approval to proceed
@ -225,13 +236,28 @@ function ApprovalCard({
onClick={() => {
setIsPanelOpen(true);
const extraFields: ExtraField[] = [
{ key: "to", label: "To", type: "emails", value: pendingEdits?.to ?? args.to ?? "" },
{ key: "cc", label: "CC", type: "emails", value: pendingEdits?.cc ?? args.cc ?? "" },
{ key: "bcc", label: "BCC", type: "emails", value: pendingEdits?.bcc ?? args.bcc ?? "" },
{
key: "to",
label: "To",
type: "emails",
value: pendingEdits?.to ?? args.to ?? "",
},
{
key: "cc",
label: "CC",
type: "emails",
value: pendingEdits?.cc ?? args.cc ?? "",
},
{
key: "bcc",
label: "BCC",
type: "emails",
value: pendingEdits?.bcc ?? args.bcc ?? "",
},
];
openHitlEditPanel({
title: pendingEdits?.subject ?? (args.subject ?? ""),
content: pendingEdits?.body ?? (args.body ?? ""),
title: pendingEdits?.subject ?? args.subject ?? "",
content: pendingEdits?.body ?? args.body ?? "",
toolName: "Gmail Draft",
extraFields,
onSave: (newTitle, newContent, extraFieldValues) => {
@ -322,7 +348,9 @@ function ApprovalCard({
<div className="px-5 pt-1">
{(pendingEdits?.subject ?? args.subject) != null && (
<p className="text-sm font-medium text-foreground">{pendingEdits?.subject ?? args.subject}</p>
<p className="text-sm font-medium text-foreground">
{pendingEdits?.subject ?? args.subject}
</p>
)}
{(pendingEdits?.body ?? args.body) != null && (
<div
@ -398,9 +426,7 @@ function AuthErrorCard({ result }: { result: AuthErrorResult }) {
return (
<div className="my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30 select-none">
<div className="px-5 pt-5 pb-4">
<p className="text-sm font-semibold text-destructive">
Gmail authentication expired
</p>
<p className="text-sm font-semibold text-destructive">Gmail authentication expired</p>
</div>
<div className="mx-5 h-px bg-border/50" />
<div className="px-5 py-4">

View file

@ -1,14 +1,13 @@
"use client";
import { makeAssistantToolUI } from "@assistant-ui/react";
import {
CornerDownLeftIcon,
MailIcon,
Pen,
UserIcon,
UsersIcon,
} from "lucide-react";
import { useSetAtom } from "jotai";
import { CornerDownLeftIcon, MailIcon, Pen, UserIcon, UsersIcon } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import type { ExtraField } from "@/atoms/chat/hitl-edit-panel.atom";
import { openHitlEditPanelAtom } from "@/atoms/chat/hitl-edit-panel.atom";
import { PlateEditor } from "@/components/editor/plate-editor";
import { TextShimmerLoader } from "@/components/prompt-kit/loader";
import { Button } from "@/components/ui/button";
import {
Select,
@ -17,11 +16,6 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { PlateEditor } from "@/components/editor/plate-editor";
import { TextShimmerLoader } from "@/components/prompt-kit/loader";
import { useSetAtom } from "jotai";
import { openHitlEditPanelAtom } from "@/atoms/chat/hitl-edit-panel.atom";
import type { ExtraField } from "@/atoms/chat/hitl-edit-panel.atom";
import { useHitlPhase } from "@/hooks/use-hitl-phase";
interface GmailAccount {
@ -132,7 +126,11 @@ function ApprovalCard({
const [isPanelOpen, setIsPanelOpen] = useState(false);
const openHitlEditPanel = useSetAtom(openHitlEditPanelAtom);
const [pendingEdits, setPendingEdits] = useState<{
subject: string; body: string; to: string; cc: string; bcc: string;
subject: string;
body: string;
to: string;
cc: string;
bcc: string;
} | null>(null);
const accounts = interruptData.context?.accounts ?? [];
@ -175,7 +173,18 @@ function ApprovalCard({
},
},
});
}, [phase, isPanelOpen, canApprove, allowedDecisions, setProcessing, onDecision, interruptData, args, selectedAccountId, pendingEdits]);
}, [
phase,
isPanelOpen,
canApprove,
allowedDecisions,
setProcessing,
onDecision,
interruptData,
args,
selectedAccountId,
pendingEdits,
]);
useEffect(() => {
const handler = (e: KeyboardEvent) => {
@ -200,16 +209,17 @@ function ApprovalCard({
? "Email Sending Approved"
: "Send Email"}
</p>
{phase === "processing" ? (
<TextShimmerLoader text={pendingEdits ? "Sending email with your changes" : "Sending email"} size="sm" />
{phase === "processing" ? (
<TextShimmerLoader
text={pendingEdits ? "Sending email with your changes" : "Sending email"}
size="sm"
/>
) : phase === "complete" ? (
<p className="text-xs text-muted-foreground mt-0.5">
{pendingEdits ? "Email sent with your changes" : "Email sent"}
</p>
) : phase === "rejected" ? (
<p className="text-xs text-muted-foreground mt-0.5">
Email sending was cancelled
</p>
<p className="text-xs text-muted-foreground mt-0.5">Email sending was cancelled</p>
) : (
<p className="text-xs text-muted-foreground mt-0.5">
Requires your approval to proceed
@ -225,13 +235,28 @@ function ApprovalCard({
onClick={() => {
setIsPanelOpen(true);
const extraFields: ExtraField[] = [
{ key: "to", label: "To", type: "emails", value: pendingEdits?.to ?? args.to ?? "" },
{ key: "cc", label: "CC", type: "emails", value: pendingEdits?.cc ?? args.cc ?? "" },
{ key: "bcc", label: "BCC", type: "emails", value: pendingEdits?.bcc ?? args.bcc ?? "" },
{
key: "to",
label: "To",
type: "emails",
value: pendingEdits?.to ?? args.to ?? "",
},
{
key: "cc",
label: "CC",
type: "emails",
value: pendingEdits?.cc ?? args.cc ?? "",
},
{
key: "bcc",
label: "BCC",
type: "emails",
value: pendingEdits?.bcc ?? args.bcc ?? "",
},
];
openHitlEditPanel({
title: pendingEdits?.subject ?? (args.subject ?? ""),
content: pendingEdits?.body ?? (args.body ?? ""),
title: pendingEdits?.subject ?? args.subject ?? "",
content: pendingEdits?.body ?? args.body ?? "",
toolName: "Send Email",
extraFields,
onSave: (newTitle, newContent, extraFieldValues) => {
@ -264,32 +289,32 @@ function ApprovalCard({
<p className="text-sm text-destructive">{interruptData.context.error}</p>
) : (
accounts.length > 0 && (
<div className="space-y-2">
<p className="text-xs font-medium text-muted-foreground">
Gmail Account <span className="text-destructive">*</span>
</p>
<Select value={selectedAccountId} onValueChange={setSelectedAccountId}>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select an account" />
</SelectTrigger>
<SelectContent>
{validAccounts.map((account) => (
<SelectItem key={account.id} value={String(account.id)}>
{account.name}
</SelectItem>
))}
{expiredAccounts.map((a) => (
<div
key={a.id}
className="relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 px-2 text-sm select-none opacity-50 pointer-events-none"
>
{a.name} (expired, retry after re-auth)
</div>
))}
</SelectContent>
</Select>
</div>
)
<div className="space-y-2">
<p className="text-xs font-medium text-muted-foreground">
Gmail Account <span className="text-destructive">*</span>
</p>
<Select value={selectedAccountId} onValueChange={setSelectedAccountId}>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select an account" />
</SelectTrigger>
<SelectContent>
{validAccounts.map((account) => (
<SelectItem key={account.id} value={String(account.id)}>
{account.name}
</SelectItem>
))}
{expiredAccounts.map((a) => (
<div
key={a.id}
className="relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 px-2 text-sm select-none opacity-50 pointer-events-none"
>
{a.name} (expired, retry after re-auth)
</div>
))}
</SelectContent>
</Select>
</div>
)
)}
</div>
</>
@ -320,7 +345,9 @@ function ApprovalCard({
<div className="px-5 pt-1">
{(pendingEdits?.subject ?? args.subject) != null && (
<p className="text-sm font-medium text-foreground">{pendingEdits?.subject ?? args.subject}</p>
<p className="text-sm font-medium text-foreground">
{pendingEdits?.subject ?? args.subject}
</p>
)}
{(pendingEdits?.body ?? args.body) != null && (
<div
@ -396,9 +423,7 @@ function AuthErrorCard({ result }: { result: AuthErrorResult }) {
return (
<div className="my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30 select-none">
<div className="px-5 pt-5 pb-4">
<p className="text-sm font-semibold text-destructive">
Gmail authentication expired
</p>
<p className="text-sm font-semibold text-destructive">Gmail authentication expired</p>
</div>
<div className="mx-5 h-px bg-border/50" />
<div className="px-5 py-4">

View file

@ -1,16 +1,11 @@
"use client";
import { makeAssistantToolUI } from "@assistant-ui/react";
import {
CalendarIcon,
CornerDownLeftIcon,
MailIcon,
UserIcon,
} from "lucide-react";
import { CalendarIcon, CornerDownLeftIcon, MailIcon, UserIcon } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { TextShimmerLoader } from "@/components/prompt-kit/loader";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { TextShimmerLoader } from "@/components/prompt-kit/loader";
import { useHitlPhase } from "@/hooks/use-hitl-phase";
interface GmailAccount {
@ -192,14 +187,12 @@ function ApprovalCard({
? "Email Trash Approved"
: "Trash Email"}
</p>
{phase === "processing" ? (
{phase === "processing" ? (
<TextShimmerLoader text="Trashing email" size="sm" />
) : phase === "complete" ? (
<p className="text-xs text-muted-foreground mt-0.5">Email trashed</p>
) : phase === "rejected" ? (
<p className="text-xs text-muted-foreground mt-0.5">
Email trash was cancelled
</p>
<p className="text-xs text-muted-foreground mt-0.5">Email trash was cancelled</p>
) : (
<p className="text-xs text-muted-foreground mt-0.5">
Requires your approval to proceed
@ -280,11 +273,7 @@ function ApprovalCard({
<>
<div className="mx-5 h-px bg-border/50" />
<div className="px-5 py-4 flex items-center gap-2 select-none">
<Button
size="sm"
className="rounded-lg gap-1.5"
onClick={handleApprove}
>
<Button size="sm" className="rounded-lg gap-1.5" onClick={handleApprove}>
Approve
<CornerDownLeftIcon className="size-3 opacity-60" />
</Button>
@ -324,9 +313,7 @@ function AuthErrorCard({ result }: { result: AuthErrorResult }) {
return (
<div className="my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30 select-none">
<div className="px-5 pt-5 pb-4">
<p className="text-sm font-semibold text-destructive">
Gmail authentication expired
</p>
<p className="text-sm font-semibold text-destructive">Gmail authentication expired</p>
</div>
<div className="mx-5 h-px bg-border/50" />
<div className="px-5 py-4">

View file

@ -1,20 +1,14 @@
"use client";
import { makeAssistantToolUI } from "@assistant-ui/react";
import {
CornerDownLeftIcon,
MailIcon,
Pen,
UserIcon,
UsersIcon,
} from "lucide-react";
import { useSetAtom } from "jotai";
import { CornerDownLeftIcon, MailIcon, Pen, UserIcon, UsersIcon } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import type { ExtraField } from "@/atoms/chat/hitl-edit-panel.atom";
import { openHitlEditPanelAtom } from "@/atoms/chat/hitl-edit-panel.atom";
import { PlateEditor } from "@/components/editor/plate-editor";
import { TextShimmerLoader } from "@/components/prompt-kit/loader";
import { useSetAtom } from "jotai";
import { openHitlEditPanelAtom } from "@/atoms/chat/hitl-edit-panel.atom";
import type { ExtraField } from "@/atoms/chat/hitl-edit-panel.atom";
import { Button } from "@/components/ui/button";
import { useHitlPhase } from "@/hooks/use-hitl-phase";
interface GmailAccount {
@ -127,15 +121,12 @@ function isAuthErrorResult(result: unknown): result is AuthErrorResult {
);
}
function isInsufficientPermissionsResult(
result: unknown,
): result is InsufficientPermissionsResult {
function isInsufficientPermissionsResult(result: unknown): result is InsufficientPermissionsResult {
return (
typeof result === "object" &&
result !== null &&
"status" in result &&
(result as InsufficientPermissionsResult).status ===
"insufficient_permissions"
(result as InsufficientPermissionsResult).status === "insufficient_permissions"
);
}
@ -177,17 +168,11 @@ function ApprovalCard({
const existingBody = context?.existing_body;
const reviewConfig = interruptData.review_configs?.[0];
const allowedDecisions = reviewConfig?.allowed_decisions ?? [
"approve",
"reject",
];
const allowedDecisions = reviewConfig?.allowed_decisions ?? ["approve", "reject"];
const canEdit = allowedDecisions.includes("edit");
const currentSubject =
pendingEdits?.subject ??
args.subject ??
email?.subject ??
args.draft_subject_or_id;
pendingEdits?.subject ?? args.subject ?? email?.subject ?? args.draft_subject_or_id;
const currentBody = pendingEdits?.body ?? args.body;
const currentTo = pendingEdits?.to ?? args.to ?? "";
const currentCc = pendingEdits?.cc ?? args.cc ?? "";
@ -259,23 +244,15 @@ function ApprovalCard({
</p>
{phase === "processing" ? (
<TextShimmerLoader
text={
pendingEdits
? "Updating draft with your changes"
: "Updating draft"
}
text={pendingEdits ? "Updating draft with your changes" : "Updating draft"}
size="sm"
/>
) : phase === "complete" ? (
<p className="text-xs text-muted-foreground mt-0.5">
{pendingEdits
? "Draft updated with your changes"
: "Draft updated"}
{pendingEdits ? "Draft updated with your changes" : "Draft updated"}
</p>
) : phase === "rejected" ? (
<p className="text-xs text-muted-foreground mt-0.5">
Draft update was cancelled
</p>
<p className="text-xs text-muted-foreground mt-0.5">Draft update was cancelled</p>
) : (
<p className="text-xs text-muted-foreground mt-0.5">
Requires your approval to proceed
@ -310,16 +287,12 @@ function ApprovalCard({
value: currentBcc,
},
];
openHitlEditPanel({
title: currentSubject,
content: editableBody,
toolName: "Gmail Draft",
openHitlEditPanel({
title: currentSubject,
content: editableBody,
toolName: "Gmail Draft",
extraFields,
onSave: (
newTitle,
newContent,
extraFieldValues,
) => {
onSave: (newTitle, newContent, extraFieldValues) => {
setIsPanelOpen(false);
const extras = extraFieldValues ?? {};
setPendingEdits({
@ -346,16 +319,12 @@ function ApprovalCard({
<div className="mx-5 h-px bg-border/50" />
<div className="px-5 py-4 space-y-4 select-none">
{context.error ? (
<p className="text-sm text-destructive">
{context.error}
</p>
<p className="text-sm text-destructive">{context.error}</p>
) : (
<>
{account && (
<div className="space-y-2">
<p className="text-xs font-medium text-muted-foreground">
Gmail Account
</p>
<p className="text-xs font-medium text-muted-foreground">Gmail Account</p>
<div className="w-full rounded-md border border-input bg-muted/50 px-3 py-2 text-sm">
{account.name}
</div>
@ -364,15 +333,11 @@ function ApprovalCard({
{email && (
<div className="space-y-2">
<p className="text-xs font-medium text-muted-foreground">
Draft to Update
</p>
<p className="text-xs font-medium text-muted-foreground">Draft to Update</p>
<div className="w-full rounded-md border border-input bg-muted/50 px-3 py-2 text-sm space-y-1">
<div className="flex items-center gap-1.5">
<MailIcon className="size-3 shrink-0 text-muted-foreground" />
<span className="font-medium">
{email.subject}
</span>
<span className="font-medium">{email.subject}</span>
</div>
</div>
</div>
@ -408,18 +373,14 @@ function ApprovalCard({
<div className="px-5 pt-1">
{currentSubject != null && (
<p className="text-sm font-medium text-foreground">
{currentSubject}
</p>
<p className="text-sm font-medium text-foreground">{currentSubject}</p>
)}
{editableBody ? (
<div
{editableBody ? (
<div
className="mt-2 max-h-[7rem] overflow-hidden text-sm"
style={{
maskImage:
"linear-gradient(to bottom, black 50%, transparent 100%)",
WebkitMaskImage:
"linear-gradient(to bottom, black 50%, transparent 100%)",
maskImage: "linear-gradient(to bottom, black 50%, transparent 100%)",
WebkitMaskImage: "linear-gradient(to bottom, black 50%, transparent 100%)",
}}
>
<PlateEditor
@ -477,9 +438,7 @@ function ErrorCard({ result }: { result: ErrorResult }) {
return (
<div className="my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30 select-none">
<div className="px-5 pt-5 pb-4">
<p className="text-sm font-semibold text-destructive">
Failed to update Gmail draft
</p>
<p className="text-sm font-semibold text-destructive">Failed to update Gmail draft</p>
</div>
<div className="mx-5 h-px bg-border/50" />
<div className="px-5 py-4">
@ -493,9 +452,7 @@ function AuthErrorCard({ result }: { result: AuthErrorResult }) {
return (
<div className="my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30 select-none">
<div className="px-5 pt-5 pb-4">
<p className="text-sm font-semibold text-destructive">
Gmail authentication expired
</p>
<p className="text-sm font-semibold text-destructive">Gmail authentication expired</p>
</div>
<div className="mx-5 h-px bg-border/50" />
<div className="px-5 py-4">
@ -505,9 +462,7 @@ function AuthErrorCard({ result }: { result: AuthErrorResult }) {
);
}
function InsufficientPermissionsCard({
result,
}: { result: InsufficientPermissionsResult }) {
function InsufficientPermissionsCard({ result }: { result: InsufficientPermissionsResult }) {
return (
<div className="my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30 select-none">
<div className="px-5 pt-5 pb-4">
@ -577,7 +532,7 @@ export const UpdateGmailDraftToolUI = makeAssistantToolUI<
window.dispatchEvent(
new CustomEvent("hitl-decision", {
detail: { decisions: [decision] },
}),
})
);
}}
/>

View file

@ -1,16 +1,13 @@
"use client";
import { makeAssistantToolUI } from "@assistant-ui/react";
import {
ClockIcon,
MapPinIcon,
UsersIcon,
GlobeIcon,
CornerDownLeftIcon,
Pen,
} from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useSetAtom } from "jotai";
import { ClockIcon, CornerDownLeftIcon, GlobeIcon, MapPinIcon, Pen, UsersIcon } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import type { ExtraField } from "@/atoms/chat/hitl-edit-panel.atom";
import { openHitlEditPanelAtom } from "@/atoms/chat/hitl-edit-panel.atom";
import { PlateEditor } from "@/components/editor/plate-editor";
import { TextShimmerLoader } from "@/components/prompt-kit/loader";
import { Button } from "@/components/ui/button";
import {
Select,
@ -19,11 +16,7 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { PlateEditor } from "@/components/editor/plate-editor";
import { TextShimmerLoader } from "@/components/prompt-kit/loader";
import { useHitlPhase } from "@/hooks/use-hitl-phase";
import { openHitlEditPanelAtom } from "@/atoms/chat/hitl-edit-panel.atom";
import type { ExtraField } from "@/atoms/chat/hitl-edit-panel.atom";
interface GoogleCalendarAccount {
id: number;
@ -160,8 +153,12 @@ function ApprovalCard({
const [wasEdited, setWasEdited] = useState(false);
const openHitlEditPanel = useSetAtom(openHitlEditPanelAtom);
const [pendingEdits, setPendingEdits] = useState<{
summary: string; description: string; start_datetime: string;
end_datetime: string; location: string; attendees: string;
summary: string;
description: string;
start_datetime: string;
end_datetime: string;
location: string;
attendees: string;
} | null>(null);
const accounts = interruptData.context?.accounts ?? [];
@ -236,7 +233,19 @@ function ApprovalCard({
args: finalArgs,
},
});
}, [phase, isPanelOpen, canApprove, allowedDecisions, setProcessing, onDecision, interruptData, args, selectedAccountId, selectedCalendarId, pendingEdits]);
}, [
phase,
isPanelOpen,
canApprove,
allowedDecisions,
setProcessing,
onDecision,
interruptData,
args,
selectedAccountId,
selectedCalendarId,
pendingEdits,
]);
useEffect(() => {
const handler = (e: KeyboardEvent) => {
@ -250,7 +259,10 @@ function ApprovalCard({
const attendeesList = (args.attendees as string[]) ?? [];
const displayAttendees = pendingEdits?.attendees
? pendingEdits.attendees.split(",").map((e) => e.trim()).filter(Boolean)
? pendingEdits.attendees
.split(",")
.map((e) => e.trim())
.filter(Boolean)
: attendeesList;
return (
@ -266,16 +278,17 @@ function ApprovalCard({
? "Calendar Event Approved"
: "Create Calendar Event"}
</p>
{phase === "processing" ? (
<TextShimmerLoader text={wasEdited ? "Creating event with your changes" : "Creating event"} size="sm" />
{phase === "processing" ? (
<TextShimmerLoader
text={wasEdited ? "Creating event with your changes" : "Creating event"}
size="sm"
/>
) : phase === "complete" ? (
<p className="text-xs text-muted-foreground mt-0.5">
{wasEdited ? "Event created with your changes" : "Event created"}
</p>
) : phase === "rejected" ? (
<p className="text-xs text-muted-foreground mt-0.5">
Event creation was cancelled
</p>
<p className="text-xs text-muted-foreground mt-0.5">Event creation was cancelled</p>
) : (
<p className="text-xs text-muted-foreground mt-0.5">
Requires your approval to proceed
@ -291,14 +304,34 @@ function ApprovalCard({
onClick={() => {
setIsPanelOpen(true);
const extraFields: ExtraField[] = [
{ key: "start_datetime", label: "Start", type: "datetime-local", value: pendingEdits?.start_datetime ?? args.start_datetime ?? "" },
{ key: "end_datetime", label: "End", type: "datetime-local", value: pendingEdits?.end_datetime ?? args.end_datetime ?? "" },
{ key: "location", label: "Location", type: "text", value: pendingEdits?.location ?? args.location ?? "" },
{ key: "attendees", label: "Attendees", type: "emails", value: pendingEdits?.attendees ?? attendeesList.join(", ") },
{
key: "start_datetime",
label: "Start",
type: "datetime-local",
value: pendingEdits?.start_datetime ?? args.start_datetime ?? "",
},
{
key: "end_datetime",
label: "End",
type: "datetime-local",
value: pendingEdits?.end_datetime ?? args.end_datetime ?? "",
},
{
key: "location",
label: "Location",
type: "text",
value: pendingEdits?.location ?? args.location ?? "",
},
{
key: "attendees",
label: "Attendees",
type: "emails",
value: pendingEdits?.attendees ?? attendeesList.join(", "),
},
];
openHitlEditPanel({
title: pendingEdits?.summary ?? (args.summary ?? ""),
content: pendingEdits?.description ?? (args.description ?? ""),
title: pendingEdits?.summary ?? args.summary ?? "",
content: pendingEdits?.description ?? args.description ?? "",
toolName: "Calendar Event",
extraFields,
onSave: (newTitle, newContent, extraFieldValues) => {
@ -307,10 +340,16 @@ function ApprovalCard({
setPendingEdits({
summary: newTitle,
description: newContent,
start_datetime: extras.start_datetime ?? pendingEdits?.start_datetime ?? args.start_datetime ?? "",
end_datetime: extras.end_datetime ?? pendingEdits?.end_datetime ?? args.end_datetime ?? "",
start_datetime:
extras.start_datetime ??
pendingEdits?.start_datetime ??
args.start_datetime ??
"",
end_datetime:
extras.end_datetime ?? pendingEdits?.end_datetime ?? args.end_datetime ?? "",
location: extras.location ?? pendingEdits?.location ?? args.location ?? "",
attendees: extras.attendees ?? pendingEdits?.attendees ?? attendeesList.join(", "),
attendees:
extras.attendees ?? pendingEdits?.attendees ?? attendeesList.join(", "),
});
},
onClose: () => setIsPanelOpen(false),
@ -372,7 +411,8 @@ function ApprovalCard({
<SelectContent>
{calendars.map((cal) => (
<SelectItem key={cal.id} value={cal.id}>
{cal.summary}{cal.primary ? " (primary)" : ""}
{cal.summary}
{cal.primary ? " (primary)" : ""}
</SelectItem>
))}
</SelectContent>
@ -399,16 +439,26 @@ function ApprovalCard({
<div className="mx-5 h-px bg-border/50" />
<div className="px-5 pt-3 pb-3 space-y-2">
{(pendingEdits?.summary ?? args.summary) && (
<p className="text-sm font-medium text-foreground">{pendingEdits?.summary ?? args.summary}</p>
<p className="text-sm font-medium text-foreground">
{pendingEdits?.summary ?? args.summary}
</p>
)}
{((pendingEdits?.start_datetime ?? args.start_datetime) || (pendingEdits?.end_datetime ?? args.end_datetime)) && (
{((pendingEdits?.start_datetime ?? args.start_datetime) ||
(pendingEdits?.end_datetime ?? args.end_datetime)) && (
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<ClockIcon className="size-3.5 shrink-0" />
<span>
{(pendingEdits?.start_datetime ?? args.start_datetime) ? formatDateTime(pendingEdits?.start_datetime ?? args.start_datetime) : ""}
{(pendingEdits?.start_datetime ?? args.start_datetime) && (pendingEdits?.end_datetime ?? args.end_datetime) ? " — " : ""}
{(pendingEdits?.end_datetime ?? args.end_datetime) ? formatDateTime(pendingEdits?.end_datetime ?? args.end_datetime) : ""}
{(pendingEdits?.start_datetime ?? args.start_datetime)
? formatDateTime(pendingEdits?.start_datetime ?? args.start_datetime)
: ""}
{(pendingEdits?.start_datetime ?? args.start_datetime) &&
(pendingEdits?.end_datetime ?? args.end_datetime)
? " — "
: ""}
{(pendingEdits?.end_datetime ?? args.end_datetime)
? formatDateTime(pendingEdits?.end_datetime ?? args.end_datetime)
: ""}
</span>
</div>
)}

View file

@ -1,16 +1,11 @@
"use client";
import { makeAssistantToolUI } from "@assistant-ui/react";
import {
CalendarIcon,
ClockIcon,
MapPinIcon,
CornerDownLeftIcon,
} from "lucide-react";
import { CalendarIcon, ClockIcon, CornerDownLeftIcon, MapPinIcon } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { TextShimmerLoader } from "@/components/prompt-kit/loader";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { TextShimmerLoader } from "@/components/prompt-kit/loader";
import { useHitlPhase } from "@/hooks/use-hitl-phase";
interface GoogleCalendarAccount {
@ -220,14 +215,12 @@ function ApprovalCard({
? "Calendar Event Deletion Approved"
: "Delete Calendar Event"}
</p>
{phase === "processing" ? (
{phase === "processing" ? (
<TextShimmerLoader text="Deleting event" size="sm" />
) : phase === "complete" ? (
<p className="text-xs text-muted-foreground mt-0.5">Event deleted</p>
) : phase === "rejected" ? (
<p className="text-xs text-muted-foreground mt-0.5">
Event deletion was cancelled
</p>
<p className="text-xs text-muted-foreground mt-0.5">Event deletion was cancelled</p>
) : (
<p className="text-xs text-muted-foreground mt-0.5">
Requires your approval to proceed
@ -247,7 +240,9 @@ function ApprovalCard({
<>
{account && (
<div className="space-y-2">
<p className="text-xs font-medium text-muted-foreground">Google Calendar Account</p>
<p className="text-xs font-medium text-muted-foreground">
Google Calendar Account
</p>
<div className="w-full rounded-md border border-input bg-muted/50 px-3 py-2 text-sm">
{account.name}
</div>
@ -315,11 +310,7 @@ function ApprovalCard({
<>
<div className="mx-5 h-px bg-border/50" />
<div className="px-5 py-4 flex items-center gap-2 select-none">
<Button
size="sm"
className="rounded-lg gap-1.5"
onClick={handleApprove}
>
<Button size="sm" className="rounded-lg gap-1.5" onClick={handleApprove}>
Approve
<CornerDownLeftIcon className="size-3 opacity-60" />
</Button>

View file

@ -1,3 +1,3 @@
export { CreateCalendarEventToolUI } from "./create-event";
export { UpdateCalendarEventToolUI } from "./update-event";
export { DeleteCalendarEventToolUI } from "./delete-event";
export { UpdateCalendarEventToolUI } from "./update-event";

View file

@ -1,22 +1,22 @@
"use client";
import { makeAssistantToolUI } from "@assistant-ui/react";
import { useSetAtom } from "jotai";
import {
ClockIcon,
MapPinIcon,
UsersIcon,
ArrowRightIcon,
ClockIcon,
CornerDownLeftIcon,
MapPinIcon,
Pen,
UsersIcon,
} from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { useSetAtom } from "jotai";
import { Button } from "@/components/ui/button";
import type { ExtraField } from "@/atoms/chat/hitl-edit-panel.atom";
import { openHitlEditPanelAtom } from "@/atoms/chat/hitl-edit-panel.atom";
import { PlateEditor } from "@/components/editor/plate-editor";
import { TextShimmerLoader } from "@/components/prompt-kit/loader";
import { Button } from "@/components/ui/button";
import { useHitlPhase } from "@/hooks/use-hitl-phase";
import { openHitlEditPanelAtom } from "@/atoms/chat/hitl-edit-panel.atom";
import type { ExtraField } from "@/atoms/chat/hitl-edit-panel.atom";
interface GoogleCalendarAccount {
id: number;
@ -180,8 +180,12 @@ function ApprovalCard({
const [wasEdited, setWasEdited] = useState(false);
const openHitlEditPanel = useSetAtom(openHitlEditPanelAtom);
const [pendingEdits, setPendingEdits] = useState<{
summary: string; description: string; start_datetime: string;
end_datetime: string; location: string; attendees: string;
summary: string;
description: string;
start_datetime: string;
end_datetime: string;
location: string;
attendees: string;
} | null>(null);
const reviewConfig = interruptData.review_configs[0];
@ -196,19 +200,21 @@ function ApprovalCard({
const effectiveNewSummary = actionArgs.new_summary ?? args.new_summary;
const effectiveNewStartDatetime = actionArgs.new_start_datetime ?? args.new_start_datetime;
const effectiveNewEndDatetime = actionArgs.new_end_datetime ?? args.new_end_datetime;
const effectiveNewLocation = actionArgs.new_location !== undefined
? actionArgs.new_location
: args.new_location;
const effectiveNewAttendees = proposedAttendees
?? (Array.isArray(args.new_attendees) ? args.new_attendees : null);
const effectiveNewDescription = actionArgs.new_description !== undefined
? actionArgs.new_description
: args.new_description;
const effectiveNewLocation =
actionArgs.new_location !== undefined ? actionArgs.new_location : args.new_location;
const effectiveNewAttendees =
proposedAttendees ?? (Array.isArray(args.new_attendees) ? args.new_attendees : null);
const effectiveNewDescription =
actionArgs.new_description !== undefined ? actionArgs.new_description : args.new_description;
const changes: Array<{ label: string; oldVal: string; newVal: string }> = [];
if (effectiveNewSummary && String(effectiveNewSummary) !== (event?.summary ?? "")) {
changes.push({ label: "Summary", oldVal: event?.summary ?? "", newVal: String(effectiveNewSummary) });
changes.push({
label: "Summary",
oldVal: event?.summary ?? "",
newVal: String(effectiveNewSummary),
});
}
if (effectiveNewStartDatetime && String(effectiveNewStartDatetime) !== (event?.start ?? "")) {
changes.push({
@ -224,8 +230,15 @@ function ApprovalCard({
newVal: formatDateTime(String(effectiveNewEndDatetime)),
});
}
if (effectiveNewLocation !== undefined && String(effectiveNewLocation ?? "") !== (event?.location ?? "")) {
changes.push({ label: "Location", oldVal: event?.location ?? "", newVal: String(effectiveNewLocation ?? "") });
if (
effectiveNewLocation !== undefined &&
String(effectiveNewLocation ?? "") !== (event?.location ?? "")
) {
changes.push({
label: "Location",
oldVal: event?.location ?? "",
newVal: String(effectiveNewLocation ?? ""),
});
}
if (effectiveNewAttendees) {
const oldStr = currentAttendees.join(", ");
@ -242,7 +255,10 @@ function ApprovalCard({
const buildFinalArgs = useCallback(() => {
if (pendingEdits) {
const attendeesArr = pendingEdits.attendees
? pendingEdits.attendees.split(",").map((e) => e.trim()).filter(Boolean)
? pendingEdits.attendees
.split(",")
.map((e) => e.trim())
.filter(Boolean)
: null;
return {
event_id: event?.event_id,
@ -282,7 +298,16 @@ function ApprovalCard({
args: buildFinalArgs(),
},
});
}, [phase, isPanelOpen, allowedDecisions, setProcessing, onDecision, interruptData, buildFinalArgs, pendingEdits]);
}, [
phase,
isPanelOpen,
allowedDecisions,
setProcessing,
onDecision,
interruptData,
buildFinalArgs,
pendingEdits,
]);
useEffect(() => {
const handler = (e: KeyboardEvent) => {
@ -308,15 +333,16 @@ function ApprovalCard({
: "Update Calendar Event"}
</p>
{phase === "processing" ? (
<TextShimmerLoader text={wasEdited ? "Updating event with your changes" : "Updating event"} size="sm" />
<TextShimmerLoader
text={wasEdited ? "Updating event with your changes" : "Updating event"}
size="sm"
/>
) : phase === "complete" ? (
<p className="text-xs text-muted-foreground mt-0.5">
{wasEdited ? "Event updated with your changes" : "Event updated"}
</p>
) : phase === "rejected" ? (
<p className="text-xs text-muted-foreground mt-0.5">
Event update was cancelled
</p>
<p className="text-xs text-muted-foreground mt-0.5">Event update was cancelled</p>
) : (
<p className="text-xs text-muted-foreground mt-0.5">
Requires your approval to proceed
@ -331,24 +357,48 @@ function ApprovalCard({
className="rounded-lg text-muted-foreground -mt-1 -mr-2"
onClick={() => {
setIsPanelOpen(true);
const proposedSummary = pendingEdits?.summary
?? (actionArgs.new_summary ? String(actionArgs.new_summary) : (event?.summary ?? ""));
const proposedDescription = pendingEdits?.description
?? (actionArgs.new_description ? String(actionArgs.new_description) : (event?.description ?? ""));
const proposedStart = pendingEdits?.start_datetime
?? (actionArgs.new_start_datetime ? String(actionArgs.new_start_datetime) : (event?.start ?? ""));
const proposedEnd = pendingEdits?.end_datetime
?? (actionArgs.new_end_datetime ? String(actionArgs.new_end_datetime) : (event?.end ?? ""));
const proposedLocation = pendingEdits?.location
?? (actionArgs.new_location !== undefined ? String(actionArgs.new_location ?? "") : (event?.location ?? ""));
const proposedAttendeesStr = pendingEdits?.attendees
?? (proposedAttendees ? proposedAttendees.join(", ") : currentAttendees.join(", "));
const proposedSummary =
pendingEdits?.summary ??
(actionArgs.new_summary ? String(actionArgs.new_summary) : (event?.summary ?? ""));
const proposedDescription =
pendingEdits?.description ??
(actionArgs.new_description
? String(actionArgs.new_description)
: (event?.description ?? ""));
const proposedStart =
pendingEdits?.start_datetime ??
(actionArgs.new_start_datetime
? String(actionArgs.new_start_datetime)
: (event?.start ?? ""));
const proposedEnd =
pendingEdits?.end_datetime ??
(actionArgs.new_end_datetime
? String(actionArgs.new_end_datetime)
: (event?.end ?? ""));
const proposedLocation =
pendingEdits?.location ??
(actionArgs.new_location !== undefined
? String(actionArgs.new_location ?? "")
: (event?.location ?? ""));
const proposedAttendeesStr =
pendingEdits?.attendees ??
(proposedAttendees ? proposedAttendees.join(", ") : currentAttendees.join(", "));
const extraFields: ExtraField[] = [
{ key: "start_datetime", label: "Start", type: "datetime-local", value: proposedStart },
{
key: "start_datetime",
label: "Start",
type: "datetime-local",
value: proposedStart,
},
{ key: "end_datetime", label: "End", type: "datetime-local", value: proposedEnd },
{ key: "location", label: "Location", type: "text", value: proposedLocation },
{ key: "attendees", label: "Attendees", type: "emails", value: proposedAttendeesStr },
{
key: "attendees",
label: "Attendees",
type: "emails",
value: proposedAttendeesStr,
},
];
openHitlEditPanel({
title: proposedSummary,
@ -377,7 +427,7 @@ function ApprovalCard({
)}
</div>
{/* Content section */}
{/* Content section */}
<div className="mx-5 h-px bg-border/50" />
<div className="px-5 py-4 space-y-4 select-none">
{context?.error ? (
@ -433,9 +483,13 @@ function ApprovalCard({
<div key={change.label} className="text-xs space-y-0.5">
<span className="text-muted-foreground">{change.label}</span>
<div className="flex items-center gap-1.5 flex-wrap">
<span className="text-muted-foreground line-through">{change.oldVal || "(empty)"}</span>
<span className="text-muted-foreground line-through">
{change.oldVal || "(empty)"}
</span>
<ArrowRightIcon className="size-3 text-muted-foreground shrink-0" />
<span className="font-medium text-foreground">{change.newVal || "(empty)"}</span>
<span className="font-medium text-foreground">
{change.newVal || "(empty)"}
</span>
</div>
</div>
))}
@ -446,7 +500,8 @@ function ApprovalCard({
className="mt-1 max-h-[5rem] overflow-hidden"
style={{
maskImage: "linear-gradient(to bottom, black 50%, transparent 100%)",
WebkitMaskImage: "linear-gradient(to bottom, black 50%, transparent 100%)",
WebkitMaskImage:
"linear-gradient(to bottom, black 50%, transparent 100%)",
}}
>
<PlateEditor

View file

@ -1,12 +1,12 @@
"use client";
import { makeAssistantToolUI } from "@assistant-ui/react";
import {
CornerDownLeftIcon,
FileIcon,
Pen,
} from "lucide-react";
import { useSetAtom } from "jotai";
import { CornerDownLeftIcon, FileIcon, Pen } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { openHitlEditPanelAtom } from "@/atoms/chat/hitl-edit-panel.atom";
import { PlateEditor } from "@/components/editor/plate-editor";
import { TextShimmerLoader } from "@/components/prompt-kit/loader";
import { Button } from "@/components/ui/button";
import {
Select,
@ -15,11 +15,7 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { PlateEditor } from "@/components/editor/plate-editor";
import { TextShimmerLoader } from "@/components/prompt-kit/loader";
import { useHitlPhase } from "@/hooks/use-hitl-phase";
import { useSetAtom } from "jotai";
import { openHitlEditPanelAtom } from "@/atoms/chat/hitl-edit-panel.atom";
interface GoogleDriveAccount {
id: number;
@ -139,8 +135,8 @@ function ApprovalCard({
const [pendingEdits, setPendingEdits] = useState<{ name: string; content: string } | null>(null);
const accounts = interruptData.context?.accounts ?? [];
const validAccounts = accounts.filter(a => !a.auth_expired);
const expiredAccounts = accounts.filter(a => a.auth_expired);
const validAccounts = accounts.filter((a) => !a.auth_expired);
const expiredAccounts = accounts.filter((a) => a.auth_expired);
const defaultAccountId = useMemo(() => {
if (validAccounts.length === 1) return String(validAccounts[0].id);
@ -162,7 +158,8 @@ function ApprovalCard({
setParentFolderId("__root__");
}, []);
const fileTypeLabel = FILE_TYPE_LABELS[selectedFileType] ?? FILE_TYPE_LABELS[args.file_type] ?? "Google Drive File";
const fileTypeLabel =
FILE_TYPE_LABELS[selectedFileType] ?? FILE_TYPE_LABELS[args.file_type] ?? "Google Drive File";
const isNameValid = useMemo(() => {
const name = pendingEdits?.name ?? args.name;
@ -194,7 +191,20 @@ function ApprovalCard({
},
},
});
}, [phase, setProcessing, isPanelOpen, canApprove, allowedDecisions, onDecision, interruptData, args, selectedFileType, selectedAccountId, parentFolderId, pendingEdits]);
}, [
phase,
setProcessing,
isPanelOpen,
canApprove,
allowedDecisions,
onDecision,
interruptData,
args,
selectedFileType,
selectedAccountId,
parentFolderId,
pendingEdits,
]);
useEffect(() => {
const handler = (e: KeyboardEvent) => {
@ -218,16 +228,17 @@ function ApprovalCard({
? `${fileTypeLabel} Approved`
: `Create ${fileTypeLabel}`}
</p>
{phase === "processing" ? (
<TextShimmerLoader text={pendingEdits ? "Creating file with your changes" : "Creating file"} size="sm" />
{phase === "processing" ? (
<TextShimmerLoader
text={pendingEdits ? "Creating file with your changes" : "Creating file"}
size="sm"
/>
) : phase === "complete" ? (
<p className="text-xs text-muted-foreground mt-0.5">
{pendingEdits ? "File created with your changes" : "File created"}
</p>
) : phase === "rejected" ? (
<p className="text-xs text-muted-foreground mt-0.5">
File creation was cancelled
</p>
<p className="text-xs text-muted-foreground mt-0.5">File creation was cancelled</p>
) : (
<p className="text-xs text-muted-foreground mt-0.5">
Requires your approval to proceed
@ -242,8 +253,8 @@ function ApprovalCard({
onClick={() => {
setIsPanelOpen(true);
openHitlEditPanel({
title: pendingEdits?.name ?? (args.name ?? ""),
content: pendingEdits?.content ?? (args.content ?? ""),
title: pendingEdits?.name ?? args.name ?? "",
content: pendingEdits?.content ?? args.content ?? "",
toolName: fileTypeLabel,
onSave: (newName, newContent) => {
setIsPanelOpen(false);
@ -268,33 +279,33 @@ function ApprovalCard({
<p className="text-sm text-destructive">{interruptData.context.error}</p>
) : (
<>
{accounts.length > 0 && (
<div className="space-y-2">
<p className="text-xs font-medium text-muted-foreground">
Google Drive Account <span className="text-destructive">*</span>
</p>
<Select value={selectedAccountId} onValueChange={handleAccountChange}>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select an account" />
</SelectTrigger>
<SelectContent>
{validAccounts.map((account) => (
<SelectItem key={account.id} value={String(account.id)}>
{account.name}
</SelectItem>
))}
{expiredAccounts.map((a) => (
<div
key={a.id}
className="relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 px-2 text-sm select-none opacity-50 pointer-events-none"
>
{a.name} (expired, retry after re-auth)
</div>
))}
</SelectContent>
</Select>
</div>
)}
{accounts.length > 0 && (
<div className="space-y-2">
<p className="text-xs font-medium text-muted-foreground">
Google Drive Account <span className="text-destructive">*</span>
</p>
<Select value={selectedAccountId} onValueChange={handleAccountChange}>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select an account" />
</SelectTrigger>
<SelectContent>
{validAccounts.map((account) => (
<SelectItem key={account.id} value={String(account.id)}>
{account.name}
</SelectItem>
))}
{expiredAccounts.map((a) => (
<div
key={a.id}
className="relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 px-2 text-sm select-none opacity-50 pointer-events-none"
>
{a.name} (expired, retry after re-auth)
</div>
))}
</SelectContent>
</Select>
</div>
)}
<div className="space-y-2">
<p className="text-xs font-medium text-muted-foreground">
@ -311,31 +322,29 @@ function ApprovalCard({
</Select>
</div>
{selectedAccountId && (
<div className="space-y-2">
<p className="text-xs font-medium text-muted-foreground">
Parent Folder
</p>
<Select value={parentFolderId} onValueChange={setParentFolderId}>
<SelectTrigger className="w-full">
<SelectValue placeholder="Drive Root" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__root__">Drive Root</SelectItem>
{availableParentFolders.map((folder) => (
<SelectItem key={folder.folder_id} value={folder.folder_id}>
{folder.name}
</SelectItem>
))}
</SelectContent>
</Select>
{availableParentFolders.length === 0 && (
<p className="text-xs text-muted-foreground">
No folders found. File will be created at Drive root.
</p>
)}
</div>
)}
{selectedAccountId && (
<div className="space-y-2">
<p className="text-xs font-medium text-muted-foreground">Parent Folder</p>
<Select value={parentFolderId} onValueChange={setParentFolderId}>
<SelectTrigger className="w-full">
<SelectValue placeholder="Drive Root" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__root__">Drive Root</SelectItem>
{availableParentFolders.map((folder) => (
<SelectItem key={folder.folder_id} value={folder.folder_id}>
{folder.name}
</SelectItem>
))}
</SelectContent>
</Select>
{availableParentFolders.length === 0 && (
<p className="text-xs text-muted-foreground">
No folders found. File will be created at Drive root.
</p>
)}
</div>
)}
</>
)}
</div>
@ -345,9 +354,11 @@ function ApprovalCard({
{/* Content preview */}
<div className="mx-5 h-px bg-border/50" />
<div className="px-5 pt-3">
{(pendingEdits?.name ?? args.name) != null && (
<p className="text-sm font-medium text-foreground">{String(pendingEdits?.name ?? args.name)}</p>
)}
{(pendingEdits?.name ?? args.name) != null && (
<p className="text-sm font-medium text-foreground">
{String(pendingEdits?.name ?? args.name)}
</p>
)}
{(pendingEdits?.content ?? args.content) != null && (
<div
className="mt-2 max-h-[7rem] overflow-hidden text-sm"

View file

@ -1,14 +1,11 @@
"use client";
import { makeAssistantToolUI } from "@assistant-ui/react";
import {
CornerDownLeftIcon,
InfoIcon,
} from "lucide-react";
import { CornerDownLeftIcon, InfoIcon } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { TextShimmerLoader } from "@/components/prompt-kit/loader";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { TextShimmerLoader } from "@/components/prompt-kit/loader";
import { useHitlPhase } from "@/hooks/use-hitl-phase";
interface GoogleDriveAccount {
@ -207,14 +204,12 @@ function ApprovalCard({
? "Google Drive File Deletion Approved"
: "Delete Google Drive File"}
</p>
{phase === "processing" ? (
{phase === "processing" ? (
<TextShimmerLoader text="Trashing file" size="sm" />
) : phase === "complete" ? (
<p className="text-xs text-muted-foreground mt-0.5">File trashed</p>
) : phase === "rejected" ? (
<p className="text-xs text-muted-foreground mt-0.5">
File deletion was cancelled
</p>
<p className="text-xs text-muted-foreground mt-0.5">File deletion was cancelled</p>
) : (
<p className="text-xs text-muted-foreground mt-0.5">
Requires your approval to proceed
@ -274,22 +269,23 @@ function ApprovalCard({
<div className="mx-5 h-px bg-border/50" />
<div className="px-5 py-4 space-y-3 select-none">
<p className="text-xs text-muted-foreground">
The file will be moved to Google Drive trash. You can restore it from trash within 30 days.
The file will be moved to Google Drive trash. You can restore it from trash within 30
days.
</p>
<div className="flex items-center gap-2.5">
<Checkbox
id="delete-from-kb"
checked={deleteFromKb}
onCheckedChange={(v) => setDeleteFromKb(v === true)}
className="shrink-0"
/>
<label htmlFor="delete-from-kb" className="flex-1 cursor-pointer">
<span className="text-sm text-foreground">Also remove from knowledge base</span>
<p className="text-xs text-muted-foreground mt-0.5">
This will permanently delete the file from your knowledge base (cannot be undone)
</p>
</label>
</div>
<div className="flex items-center gap-2.5">
<Checkbox
id="delete-from-kb"
checked={deleteFromKb}
onCheckedChange={(v) => setDeleteFromKb(v === true)}
className="shrink-0"
/>
<label htmlFor="delete-from-kb" className="flex-1 cursor-pointer">
<span className="text-sm text-foreground">Also remove from knowledge base</span>
<p className="text-xs text-muted-foreground mt-0.5">
This will permanently delete the file from your knowledge base (cannot be undone)
</p>
</label>
</div>
</div>
</>
)}
@ -298,27 +294,23 @@ function ApprovalCard({
{phase === "pending" && (
<>
<div className="mx-5 h-px bg-border/50" />
<div className="px-5 py-4 flex items-center gap-2 select-none">
<Button
size="sm"
className="rounded-lg gap-1.5"
onClick={handleApprove}
>
Approve
<CornerDownLeftIcon className="size-3 opacity-60" />
</Button>
<Button
size="sm"
variant="ghost"
className="rounded-lg text-muted-foreground"
onClick={() => {
setRejected();
onDecision({ type: "reject", message: "User rejected the action." });
}}
>
Reject
</Button>
</div>
<div className="px-5 py-4 flex items-center gap-2 select-none">
<Button size="sm" className="rounded-lg gap-1.5" onClick={handleApprove}>
Approve
<CornerDownLeftIcon className="size-3 opacity-60" />
</Button>
<Button
size="sm"
variant="ghost"
className="rounded-lg text-muted-foreground"
onClick={() => {
setRejected();
onDecision({ type: "reject", message: "User rejected the action." });
}}
>
Reject
</Button>
</div>
</>
)}
</div>

View file

@ -1,8 +1,12 @@
"use client";
import { makeAssistantToolUI } from "@assistant-ui/react";
import { useSetAtom } from "jotai";
import { CornerDownLeftIcon, Pen } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { openHitlEditPanelAtom } from "@/atoms/chat/hitl-edit-panel.atom";
import { PlateEditor } from "@/components/editor/plate-editor";
import { TextShimmerLoader } from "@/components/prompt-kit/loader";
import { Button } from "@/components/ui/button";
import {
Select,
@ -11,11 +15,7 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { PlateEditor } from "@/components/editor/plate-editor";
import { TextShimmerLoader } from "@/components/prompt-kit/loader";
import { useHitlPhase } from "@/hooks/use-hitl-phase";
import { useSetAtom } from "jotai";
import { openHitlEditPanelAtom } from "@/atoms/chat/hitl-edit-panel.atom";
interface JiraAccount {
id: number;
@ -151,7 +151,9 @@ function ApprovalCard({
const { phase, setProcessing, setRejected } = useHitlPhase(interruptData);
const [isPanelOpen, setIsPanelOpen] = useState(false);
const openHitlEditPanel = useSetAtom(openHitlEditPanelAtom);
const [pendingEdits, setPendingEdits] = useState<{ title: string; description: string } | null>(null);
const [pendingEdits, setPendingEdits] = useState<{ title: string; description: string } | null>(
null
);
const [selectedAccountId, setSelectedAccountId] = useState("");
const [selectedProjectKey, setSelectedProjectKey] = useState(args.project_key ?? "");
@ -177,14 +179,23 @@ function ApprovalCard({
(overrides?: { title?: string; description?: string }) => {
return {
summary: overrides?.title ?? pendingEdits?.title ?? args.summary,
description: overrides?.description ?? pendingEdits?.description ?? args.description ?? null,
description:
overrides?.description ?? pendingEdits?.description ?? args.description ?? null,
connector_id: selectedAccountId ? Number(selectedAccountId) : null,
project_key: selectedProjectKey || null,
issue_type: selectedIssueType === "__none__" ? null : selectedIssueType,
priority: selectedPriority === "__none__" ? null : selectedPriority,
};
},
[args.summary, args.description, selectedAccountId, selectedProjectKey, selectedIssueType, selectedPriority, pendingEdits]
[
args.summary,
args.description,
selectedAccountId,
selectedProjectKey,
selectedIssueType,
selectedPriority,
pendingEdits,
]
);
const handleApprove = useCallback(() => {
@ -200,7 +211,17 @@ function ApprovalCard({
args: buildFinalArgs(),
},
});
}, [phase, setProcessing, isPanelOpen, canApprove, allowedDecisions, onDecision, interruptData, buildFinalArgs, pendingEdits]);
}, [
phase,
setProcessing,
isPanelOpen,
canApprove,
allowedDecisions,
onDecision,
interruptData,
buildFinalArgs,
pendingEdits,
]);
useEffect(() => {
const handler = (e: KeyboardEvent) => {
@ -225,15 +246,16 @@ function ApprovalCard({
: "Create Jira Issue"}
</p>
{phase === "processing" ? (
<TextShimmerLoader text={pendingEdits ? "Creating issue with your changes" : "Creating issue"} size="sm" />
<TextShimmerLoader
text={pendingEdits ? "Creating issue with your changes" : "Creating issue"}
size="sm"
/>
) : phase === "complete" ? (
<p className="text-xs text-muted-foreground mt-0.5">
{pendingEdits ? "Issue created with your changes" : "Issue created"}
</p>
) : phase === "rejected" ? (
<p className="text-xs text-muted-foreground mt-0.5">
Issue creation was cancelled
</p>
<p className="text-xs text-muted-foreground mt-0.5">Issue creation was cancelled</p>
) : (
<p className="text-xs text-muted-foreground mt-0.5">
Requires your approval to proceed
@ -248,8 +270,8 @@ function ApprovalCard({
onClick={() => {
setIsPanelOpen(true);
openHitlEditPanel({
title: pendingEdits?.title ?? (args.summary ?? ""),
content: pendingEdits?.description ?? (args.description ?? ""),
title: pendingEdits?.title ?? args.summary ?? "",
content: pendingEdits?.description ?? args.description ?? "",
toolName: "Jira Issue",
onSave: (newTitle, newDescription) => {
setIsPanelOpen(false);
@ -316,10 +338,7 @@ function ApprovalCard({
<p className="text-xs font-medium text-muted-foreground">
Project <span className="text-destructive">*</span>
</p>
<Select
value={selectedProjectKey}
onValueChange={setSelectedProjectKey}
>
<Select value={selectedProjectKey} onValueChange={setSelectedProjectKey}>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select a project" />
</SelectTrigger>
@ -336,32 +355,26 @@ function ApprovalCard({
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<p className="text-xs font-medium text-muted-foreground">Issue Type</p>
<Select
value={selectedIssueType}
onValueChange={setSelectedIssueType}
>
<Select value={selectedIssueType} onValueChange={setSelectedIssueType}>
<SelectTrigger className="w-full">
<SelectValue placeholder="Task" />
</SelectTrigger>
<SelectContent>
{issueTypes.length > 0
? issueTypes.map((t) => (
{issueTypes.length > 0 ? (
issueTypes.map((t) => (
<SelectItem key={t.id} value={t.name}>
{t.name}
</SelectItem>
))
: (
<SelectItem value="Task">Task</SelectItem>
)}
) : (
<SelectItem value="Task">Task</SelectItem>
)}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<p className="text-xs font-medium text-muted-foreground">Priority</p>
<Select
value={selectedPriority}
onValueChange={setSelectedPriority}
>
<Select value={selectedPriority} onValueChange={setSelectedPriority}>
<SelectTrigger className="w-full">
<SelectValue placeholder="Default" />
</SelectTrigger>
@ -388,7 +401,9 @@ function ApprovalCard({
<div className="mx-5 h-px bg-border/50" />
<div className="px-5 pt-3">
{(pendingEdits?.title ?? args.summary) != null && (
<p className="text-sm font-medium text-foreground">{pendingEdits?.title ?? args.summary}</p>
<p className="text-sm font-medium text-foreground">
{pendingEdits?.title ?? args.summary}
</p>
)}
{(pendingEdits?.description ?? args.description) != null && (
<div
@ -450,9 +465,7 @@ function AuthErrorCard({ result }: { result: AuthErrorResult }) {
return (
<div className="my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30 select-none">
<div className="px-5 pt-5 pb-4">
<p className="text-sm font-semibold text-destructive">
All Jira accounts expired
</p>
<p className="text-sm font-semibold text-destructive">All Jira accounts expired</p>
</div>
<div className="mx-5 h-px bg-border/50" />
<div className="px-5 py-4">

View file

@ -3,9 +3,9 @@
import { makeAssistantToolUI } from "@assistant-ui/react";
import { CornerDownLeftIcon } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { TextShimmerLoader } from "@/components/prompt-kit/loader";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { TextShimmerLoader } from "@/components/prompt-kit/loader";
import { useHitlPhase } from "@/hooks/use-hitl-phase";
interface JiraAccount {
@ -204,9 +204,7 @@ function ApprovalCard({
) : phase === "complete" ? (
<p className="text-xs text-muted-foreground mt-0.5">Issue deleted</p>
) : phase === "rejected" ? (
<p className="text-xs text-muted-foreground mt-0.5">
Issue deletion was cancelled
</p>
<p className="text-xs text-muted-foreground mt-0.5">Issue deletion was cancelled</p>
) : (
<p className="text-xs text-muted-foreground mt-0.5">
Requires your approval to proceed
@ -280,11 +278,7 @@ function ApprovalCard({
<>
<div className="mx-5 h-px bg-border/50" />
<div className="px-5 py-4 flex items-center gap-2 select-none">
<Button
size="sm"
className="rounded-lg gap-1.5"
onClick={handleApprove}
>
<Button size="sm" className="rounded-lg gap-1.5" onClick={handleApprove}>
Approve
<CornerDownLeftIcon className="size-3 opacity-60" />
</Button>
@ -310,9 +304,7 @@ function AuthErrorCard({ result }: { result: AuthErrorResult }) {
return (
<div className="my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30 select-none">
<div className="px-5 pt-5 pb-4">
<p className="text-sm font-semibold text-destructive">
Jira authentication expired
</p>
<p className="text-sm font-semibold text-destructive">Jira authentication expired</p>
</div>
<div className="mx-5 h-px bg-border/50" />
<div className="px-5 py-4">
@ -356,9 +348,7 @@ function NotFoundCard({ result }: { result: NotFoundResult }) {
return (
<div className="my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30 select-none">
<div className="px-5 pt-5 pb-4">
<p className="text-sm font-semibold text-amber-600 dark:text-amber-400">
Issue not found
</p>
<p className="text-sm font-semibold text-amber-600 dark:text-amber-400">Issue not found</p>
</div>
<div className="mx-5 h-px bg-border/50" />
<div className="px-5 py-4">

View file

@ -4,6 +4,9 @@ import { makeAssistantToolUI } from "@assistant-ui/react";
import { useSetAtom } from "jotai";
import { CornerDownLeftIcon, Pen } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { openHitlEditPanelAtom } from "@/atoms/chat/hitl-edit-panel.atom";
import { PlateEditor } from "@/components/editor/plate-editor";
import { TextShimmerLoader } from "@/components/prompt-kit/loader";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
@ -13,10 +16,7 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { PlateEditor } from "@/components/editor/plate-editor";
import { TextShimmerLoader } from "@/components/prompt-kit/loader";
import { useHitlPhase } from "@/hooks/use-hitl-phase";
import { openHitlEditPanelAtom } from "@/atoms/chat/hitl-edit-panel.atom";
interface JiraIssue {
issue_id: string;
@ -194,9 +194,12 @@ function ApprovalCard({
const canEdit = allowedDecisions.includes("edit");
const hasProposedChanges =
actionArgs.new_summary || args.new_summary ||
actionArgs.new_description || args.new_description ||
actionArgs.new_priority || args.new_priority;
actionArgs.new_summary ||
args.new_summary ||
actionArgs.new_description ||
args.new_description ||
actionArgs.new_priority ||
args.new_priority;
const buildFinalArgs = useCallback(() => {
return {
@ -222,7 +225,16 @@ function ApprovalCard({
args: buildFinalArgs(),
},
});
}, [phase, setProcessing, isPanelOpen, allowedDecisions, onDecision, interruptData, buildFinalArgs, hasPanelEdits]);
}, [
phase,
setProcessing,
isPanelOpen,
allowedDecisions,
onDecision,
interruptData,
buildFinalArgs,
hasPanelEdits,
]);
useEffect(() => {
const handler = (e: KeyboardEvent) => {
@ -247,15 +259,16 @@ function ApprovalCard({
: "Update Jira Issue"}
</p>
{phase === "processing" ? (
<TextShimmerLoader text={hasPanelEdits ? "Updating issue with your changes" : "Updating issue"} size="sm" />
<TextShimmerLoader
text={hasPanelEdits ? "Updating issue with your changes" : "Updating issue"}
size="sm"
/>
) : phase === "complete" ? (
<p className="text-xs text-muted-foreground mt-0.5">
{hasPanelEdits ? "Issue updated with your changes" : "Issue updated"}
</p>
) : phase === "rejected" ? (
<p className="text-xs text-muted-foreground mt-0.5">
Issue update was cancelled
</p>
<p className="text-xs text-muted-foreground mt-0.5">Issue update was cancelled</p>
) : (
<p className="text-xs text-muted-foreground mt-0.5">
Requires your approval to proceed
@ -366,14 +379,20 @@ function ApprovalCard({
{/* Content preview — proposed changes */}
<div className="mx-5 h-px bg-border/50" />
<div className="px-5 pt-3">
{(hasProposedChanges || hasPanelEdits) ? (
{hasProposedChanges || hasPanelEdits ? (
<>
{(hasPanelEdits ? editedArgs.summary : (actionArgs.new_summary ?? args.new_summary)) && (
{(hasPanelEdits
? editedArgs.summary
: (actionArgs.new_summary ?? args.new_summary)) && (
<p className="text-sm font-medium text-foreground">
{String(hasPanelEdits ? editedArgs.summary : (actionArgs.new_summary ?? args.new_summary))}
{String(
hasPanelEdits ? editedArgs.summary : (actionArgs.new_summary ?? args.new_summary)
)}
</p>
)}
{(hasPanelEdits ? editedArgs.description : (actionArgs.new_description ?? args.new_description)) && (
{(hasPanelEdits
? editedArgs.description
: (actionArgs.new_description ?? args.new_description)) && (
<div
className="max-h-[7rem] overflow-hidden text-sm"
style={{
@ -382,7 +401,11 @@ function ApprovalCard({
}}
>
<PlateEditor
markdown={String(hasPanelEdits ? editedArgs.description : (actionArgs.new_description ?? args.new_description))}
markdown={String(
hasPanelEdits
? editedArgs.description
: (actionArgs.new_description ?? args.new_description)
)}
readOnly
preset="readonly"
editorVariant="none"
@ -445,9 +468,7 @@ function AuthErrorCard({ result }: { result: AuthErrorResult }) {
return (
<div className="my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30 select-none">
<div className="px-5 pt-5 pb-4">
<p className="text-sm font-semibold text-destructive">
Jira authentication expired
</p>
<p className="text-sm font-semibold text-destructive">Jira authentication expired</p>
</div>
<div className="mx-5 h-px bg-border/50" />
<div className="px-5 py-4">
@ -491,9 +512,7 @@ function NotFoundCard({ result }: { result: NotFoundResult }) {
return (
<div className="my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30 select-none">
<div className="px-5 pt-5 pb-4">
<p className="text-sm font-semibold text-amber-600 dark:text-amber-400">
Issue not found
</p>
<p className="text-sm font-semibold text-amber-600 dark:text-amber-400">Issue not found</p>
</div>
<div className="mx-5 h-px bg-border/50" />
<div className="px-5 py-4">

View file

@ -1,8 +1,12 @@
"use client";
import { makeAssistantToolUI } from "@assistant-ui/react";
import { useSetAtom } from "jotai";
import { CornerDownLeftIcon, Pen } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { openHitlEditPanelAtom } from "@/atoms/chat/hitl-edit-panel.atom";
import { PlateEditor } from "@/components/editor/plate-editor";
import { TextShimmerLoader } from "@/components/prompt-kit/loader";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
@ -13,11 +17,7 @@ import {
SelectValue,
} from "@/components/ui/select";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import { PlateEditor } from "@/components/editor/plate-editor";
import { TextShimmerLoader } from "@/components/prompt-kit/loader";
import { useHitlPhase } from "@/hooks/use-hitl-phase";
import { useSetAtom } from "jotai";
import { openHitlEditPanelAtom } from "@/atoms/chat/hitl-edit-panel.atom";
interface LinearLabel {
id: string;
@ -148,7 +148,9 @@ function ApprovalCard({
const { phase, setProcessing, setRejected } = useHitlPhase(interruptData);
const [isPanelOpen, setIsPanelOpen] = useState(false);
const openHitlEditPanel = useSetAtom(openHitlEditPanelAtom);
const [pendingEdits, setPendingEdits] = useState<{ title: string; description: string } | null>(null);
const [pendingEdits, setPendingEdits] = useState<{ title: string; description: string } | null>(
null
);
const [selectedWorkspaceId, setSelectedWorkspaceId] = useState("");
const [selectedTeamId, setSelectedTeamId] = useState("");
@ -178,18 +180,32 @@ function ApprovalCard({
const allowedDecisions = reviewConfig?.allowed_decisions ?? ["approve", "reject"];
const canEdit = allowedDecisions.includes("edit");
const buildFinalArgs = useCallback((overrides?: { title?: string; description?: string }) => {
return {
title: overrides?.title ?? pendingEdits?.title ?? args.title,
description: overrides?.description ?? pendingEdits?.description ?? args.description ?? null,
connector_id: selectedWorkspaceId ? Number(selectedWorkspaceId) : null,
team_id: selectedTeamId || null,
state_id: selectedStateId === "__none__" ? null : selectedStateId,
assignee_id: selectedAssigneeId === "__none__" ? null : selectedAssigneeId,
priority: Number(selectedPriority),
label_ids: selectedLabelIds,
};
}, [args.title, args.description, selectedWorkspaceId, selectedTeamId, selectedStateId, selectedAssigneeId, selectedPriority, selectedLabelIds, pendingEdits]);
const buildFinalArgs = useCallback(
(overrides?: { title?: string; description?: string }) => {
return {
title: overrides?.title ?? pendingEdits?.title ?? args.title,
description:
overrides?.description ?? pendingEdits?.description ?? args.description ?? null,
connector_id: selectedWorkspaceId ? Number(selectedWorkspaceId) : null,
team_id: selectedTeamId || null,
state_id: selectedStateId === "__none__" ? null : selectedStateId,
assignee_id: selectedAssigneeId === "__none__" ? null : selectedAssigneeId,
priority: Number(selectedPriority),
label_ids: selectedLabelIds,
};
},
[
args.title,
args.description,
selectedWorkspaceId,
selectedTeamId,
selectedStateId,
selectedAssigneeId,
selectedPriority,
selectedLabelIds,
pendingEdits,
]
);
const handleApprove = useCallback(() => {
if (phase !== "pending") return;
@ -204,7 +220,17 @@ function ApprovalCard({
args: buildFinalArgs(),
},
});
}, [phase, setProcessing, isPanelOpen, canApprove, allowedDecisions, onDecision, interruptData, buildFinalArgs, pendingEdits]);
}, [
phase,
setProcessing,
isPanelOpen,
canApprove,
allowedDecisions,
onDecision,
interruptData,
buildFinalArgs,
pendingEdits,
]);
useEffect(() => {
const handler = (e: KeyboardEvent) => {
@ -229,15 +255,16 @@ function ApprovalCard({
: "Create Linear Issue"}
</p>
{phase === "processing" ? (
<TextShimmerLoader text={pendingEdits ? "Creating issue with your changes" : "Creating issue"} size="sm" />
<TextShimmerLoader
text={pendingEdits ? "Creating issue with your changes" : "Creating issue"}
size="sm"
/>
) : phase === "complete" ? (
<p className="text-xs text-muted-foreground mt-0.5">
{pendingEdits ? "Issue created with your changes" : "Issue created"}
</p>
) : phase === "rejected" ? (
<p className="text-xs text-muted-foreground mt-0.5">
Issue creation was cancelled
</p>
<p className="text-xs text-muted-foreground mt-0.5">Issue creation was cancelled</p>
) : (
<p className="text-xs text-muted-foreground mt-0.5">
Requires your approval to proceed
@ -252,8 +279,8 @@ function ApprovalCard({
onClick={() => {
setIsPanelOpen(true);
openHitlEditPanel({
title: pendingEdits?.title ?? (args.title ?? ""),
content: pendingEdits?.description ?? (args.description ?? ""),
title: pendingEdits?.title ?? args.title ?? "",
content: pendingEdits?.description ?? args.description ?? "",
toolName: "Linear Issue",
onSave: (newTitle, newDescription) => {
setIsPanelOpen(false);
@ -269,7 +296,7 @@ function ApprovalCard({
)}
</div>
{/* Context section — real pickers in pending */}
{/* Context section — real pickers in pending */}
{phase === "pending" && (
<>
<div className="mx-5 h-px bg-border/50" />
@ -278,43 +305,43 @@ function ApprovalCard({
<p className="text-sm text-destructive">{interruptData.context.error}</p>
) : (
<>
{workspaces.length > 0 && (
<div className="space-y-1.5">
<p className="text-xs font-medium text-muted-foreground">
Linear Account <span className="text-destructive">*</span>
</p>
<Select
value={selectedWorkspaceId}
onValueChange={(v) => {
setSelectedWorkspaceId(v);
setSelectedTeamId("");
setSelectedStateId("__none__");
setSelectedAssigneeId("__none__");
setSelectedPriority("0");
setSelectedLabelIds([]);
}}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select an account" />
</SelectTrigger>
<SelectContent>
{validWorkspaces.map((w) => (
<SelectItem key={w.id} value={String(w.id)}>
{w.name}
</SelectItem>
))}
{expiredWorkspaces.map((w) => (
<div
key={w.id}
className="relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 px-2 text-sm select-none opacity-50 pointer-events-none"
>
{w.name} (expired, retry after re-auth)
</div>
))}
</SelectContent>
</Select>
</div>
)}
{workspaces.length > 0 && (
<div className="space-y-1.5">
<p className="text-xs font-medium text-muted-foreground">
Linear Account <span className="text-destructive">*</span>
</p>
<Select
value={selectedWorkspaceId}
onValueChange={(v) => {
setSelectedWorkspaceId(v);
setSelectedTeamId("");
setSelectedStateId("__none__");
setSelectedAssigneeId("__none__");
setSelectedPriority("0");
setSelectedLabelIds([]);
}}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select an account" />
</SelectTrigger>
<SelectContent>
{validWorkspaces.map((w) => (
<SelectItem key={w.id} value={String(w.id)}>
{w.name}
</SelectItem>
))}
{expiredWorkspaces.map((w) => (
<div
key={w.id}
className="relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 px-2 text-sm select-none opacity-50 pointer-events-none"
>
{w.name} (expired, retry after re-auth)
</div>
))}
</SelectContent>
</Select>
</div>
)}
{selectedWorkspace && (
<>
@ -366,7 +393,10 @@ function ApprovalCard({
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<p className="text-xs font-medium text-muted-foreground">Assignee</p>
<Select value={selectedAssigneeId} onValueChange={setSelectedAssigneeId}>
<Select
value={selectedAssigneeId}
onValueChange={setSelectedAssigneeId}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Unassigned" />
</SelectTrigger>
@ -520,9 +550,7 @@ function AuthErrorCard({ result }: { result: AuthErrorResult }) {
return (
<div className="my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30 select-none">
<div className="px-5 pt-5 pb-4">
<p className="text-sm font-semibold text-destructive">
All Linear accounts expired
</p>
<p className="text-sm font-semibold text-destructive">All Linear accounts expired</p>
</div>
<div className="mx-5 h-px bg-border/50" />
<div className="px-5 py-4">

View file

@ -3,9 +3,9 @@
import { makeAssistantToolUI } from "@assistant-ui/react";
import { CornerDownLeftIcon } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { TextShimmerLoader } from "@/components/prompt-kit/loader";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { TextShimmerLoader } from "@/components/prompt-kit/loader";
import { useHitlPhase } from "@/hooks/use-hitl-phase";
interface InterruptResult {
@ -150,7 +150,15 @@ function ApprovalCard({
},
},
});
}, [phase, setProcessing, onDecision, interruptData, issue?.id, context?.workspace?.id, deleteFromKb]);
}, [
phase,
setProcessing,
onDecision,
interruptData,
issue?.id,
context?.workspace?.id,
deleteFromKb,
]);
useEffect(() => {
const handler = (e: KeyboardEvent) => {
@ -174,14 +182,12 @@ function ApprovalCard({
? "Linear Issue Deletion Approved"
: "Delete Linear Issue"}
</p>
{phase === "processing" ? (
{phase === "processing" ? (
<TextShimmerLoader text="Deleting issue" size="sm" />
) : phase === "complete" ? (
<p className="text-xs text-muted-foreground mt-0.5">Issue deleted</p>
) : phase === "rejected" ? (
<p className="text-xs text-muted-foreground mt-0.5">
Issue deletion was cancelled
</p>
<p className="text-xs text-muted-foreground mt-0.5">Issue deletion was cancelled</p>
) : (
<p className="text-xs text-muted-foreground mt-0.5">
Requires your approval to proceed
@ -232,20 +238,20 @@ function ApprovalCard({
<>
<div className="mx-5 h-px bg-border/50" />
<div className="px-5 py-4 select-none">
<div className="flex items-center gap-2.5">
<Checkbox
id="linear-delete-from-kb"
checked={deleteFromKb}
onCheckedChange={(v) => setDeleteFromKb(v === true)}
className="shrink-0"
/>
<label htmlFor="linear-delete-from-kb" className="flex-1 cursor-pointer">
<span className="text-sm text-foreground">Also remove from knowledge base</span>
<p className="text-xs text-muted-foreground mt-0.5">
This will permanently delete the issue from your knowledge base (cannot be undone)
</p>
</label>
</div>
<div className="flex items-center gap-2.5">
<Checkbox
id="linear-delete-from-kb"
checked={deleteFromKb}
onCheckedChange={(v) => setDeleteFromKb(v === true)}
className="shrink-0"
/>
<label htmlFor="linear-delete-from-kb" className="flex-1 cursor-pointer">
<span className="text-sm text-foreground">Also remove from knowledge base</span>
<p className="text-xs text-muted-foreground mt-0.5">
This will permanently delete the issue from your knowledge base (cannot be undone)
</p>
</label>
</div>
</div>
</>
)}
@ -254,12 +260,8 @@ function ApprovalCard({
{phase === "pending" && (
<>
<div className="mx-5 h-px bg-border/50" />
<div className="px-5 py-4 flex items-center gap-2 select-none">
<Button
size="sm"
className="rounded-lg gap-1.5"
onClick={handleApprove}
>
<div className="px-5 py-4 flex items-center gap-2 select-none">
<Button size="sm" className="rounded-lg gap-1.5" onClick={handleApprove}>
Approve
<CornerDownLeftIcon className="size-3 opacity-60" />
</Button>
@ -285,9 +287,7 @@ function AuthErrorCard({ result }: { result: AuthErrorResult }) {
return (
<div className="my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30 select-none">
<div className="px-5 pt-5 pb-4">
<p className="text-sm font-semibold text-destructive">
Linear authentication expired
</p>
<p className="text-sm font-semibold text-destructive">Linear authentication expired</p>
</div>
<div className="mx-5 h-px bg-border/50" />
<div className="px-5 py-4">
@ -315,9 +315,7 @@ function NotFoundCard({ result }: { result: NotFoundResult }) {
return (
<div className="my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30 select-none">
<div className="px-5 pt-5 pb-4">
<p className="text-sm font-semibold text-amber-600 dark:text-amber-400">
Issue not found
</p>
<p className="text-sm font-semibold text-amber-600 dark:text-amber-400">Issue not found</p>
</div>
<div className="mx-5 h-px bg-border/50" />
<div className="px-5 py-4">

View file

@ -4,6 +4,9 @@ import { makeAssistantToolUI } from "@assistant-ui/react";
import { useSetAtom } from "jotai";
import { CornerDownLeftIcon, Pen } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { openHitlEditPanelAtom } from "@/atoms/chat/hitl-edit-panel.atom";
import { PlateEditor } from "@/components/editor/plate-editor";
import { TextShimmerLoader } from "@/components/prompt-kit/loader";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
@ -14,10 +17,7 @@ import {
SelectValue,
} from "@/components/ui/select";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import { PlateEditor } from "@/components/editor/plate-editor";
import { TextShimmerLoader } from "@/components/prompt-kit/loader";
import { useHitlPhase } from "@/hooks/use-hitl-phase";
import { openHitlEditPanelAtom } from "@/atoms/chat/hitl-edit-panel.atom";
interface LinearLabel {
id: string;
@ -110,7 +110,12 @@ interface AuthErrorResult {
connector_type: string;
}
type UpdateLinearIssueResult = InterruptResult | SuccessResult | ErrorResult | NotFoundResult | AuthErrorResult;
type UpdateLinearIssueResult =
| InterruptResult
| SuccessResult
| ErrorResult
| NotFoundResult
| AuthErrorResult;
function isInterruptResult(result: unknown): result is InterruptResult {
return (
@ -178,7 +183,9 @@ function ApprovalCard({
const issue = context?.issue;
const initialEditState = {
title: actionArgs.new_title ? String(actionArgs.new_title) : (issue?.title ?? args.new_title ?? ""),
title: actionArgs.new_title
? String(actionArgs.new_title)
: (issue?.title ?? args.new_title ?? ""),
description: actionArgs.new_description
? String(actionArgs.new_description)
: (issue?.description ?? args.new_description ?? ""),
@ -256,8 +263,10 @@ function ApprovalCard({
);
const hasProposedChanges =
actionArgs.new_title || args.new_title ||
actionArgs.new_description || args.new_description ||
actionArgs.new_title ||
args.new_title ||
actionArgs.new_description ||
args.new_description ||
proposedStateName ||
proposedAssigneeName ||
proposedPriorityLabel ||
@ -276,7 +285,16 @@ function ApprovalCard({
args: buildFinalArgs(),
},
});
}, [phase, setProcessing, isPanelOpen, allowedDecisions, onDecision, interruptData, buildFinalArgs, hasPanelEdits]);
}, [
phase,
setProcessing,
isPanelOpen,
allowedDecisions,
onDecision,
interruptData,
buildFinalArgs,
hasPanelEdits,
]);
useEffect(() => {
const handler = (e: KeyboardEvent) => {
@ -301,15 +319,16 @@ function ApprovalCard({
: "Update Linear Issue"}
</p>
{phase === "processing" ? (
<TextShimmerLoader text={hasPanelEdits ? "Updating issue with your changes" : "Updating issue"} size="sm" />
<TextShimmerLoader
text={hasPanelEdits ? "Updating issue with your changes" : "Updating issue"}
size="sm"
/>
) : phase === "complete" ? (
<p className="text-xs text-muted-foreground mt-0.5">
{hasPanelEdits ? "Issue updated with your changes" : "Issue updated"}
</p>
) : phase === "rejected" ? (
<p className="text-xs text-muted-foreground mt-0.5">
Issue update was cancelled
</p>
<p className="text-xs text-muted-foreground mt-0.5">Issue update was cancelled</p>
) : (
<p className="text-xs text-muted-foreground mt-0.5">
Requires your approval to proceed
@ -346,7 +365,7 @@ function ApprovalCard({
)}
</div>
{/* Context section — workspace + current issue + pickers in pending */}
{/* Context section — workspace + current issue + pickers in pending */}
{phase === "pending" && (
<>
<div className="mx-5 h-px bg-border/50" />
@ -385,7 +404,9 @@ function ApprovalCard({
)}
{issue.current_assignee && <span>{issue.current_assignee.name}</span>}
{priorities.find((p) => p.priority === issue.priority) && (
<span>{priorities.find((p) => p.priority === issue.priority)?.label}</span>
<span>
{priorities.find((p) => p.priority === issue.priority)?.label}
</span>
)}
</div>
{issue.current_labels && issue.current_labels.length > 0 && (
@ -510,9 +531,7 @@ function ApprovalCard({
? `${label.color}70`
: `${label.color}28`,
color: label.color,
borderColor: isSelected
? `${label.color}cc`
: "transparent",
borderColor: isSelected ? `${label.color}cc` : "transparent",
}}
>
<span
@ -538,12 +557,18 @@ function ApprovalCard({
{/* Content preview — proposed changes */}
<div className="mx-5 h-px bg-border/50" />
<div className="px-5 pt-3">
{(hasProposedChanges || hasPanelEdits) ? (
{hasProposedChanges || hasPanelEdits ? (
<>
{(hasPanelEdits ? editedArgs.title : (actionArgs.new_title ?? args.new_title)) && (
<p className="text-sm font-medium text-foreground">{String(hasPanelEdits ? editedArgs.title : (actionArgs.new_title ?? args.new_title))}</p>
<p className="text-sm font-medium text-foreground">
{String(
hasPanelEdits ? editedArgs.title : (actionArgs.new_title ?? args.new_title)
)}
</p>
)}
{(hasPanelEdits ? editedArgs.description : (actionArgs.new_description ?? args.new_description)) && (
{(hasPanelEdits
? editedArgs.description
: (actionArgs.new_description ?? args.new_description)) && (
<div
className="max-h-[7rem] overflow-hidden text-sm"
style={{
@ -552,7 +577,11 @@ function ApprovalCard({
}}
>
<PlateEditor
markdown={String(hasPanelEdits ? editedArgs.description : (actionArgs.new_description ?? args.new_description))}
markdown={String(
hasPanelEdits
? editedArgs.description
: (actionArgs.new_description ?? args.new_description)
)}
readOnly
preset="readonly"
editorVariant="none"
@ -641,9 +670,7 @@ function AuthErrorCard({ result }: { result: AuthErrorResult }) {
return (
<div className="my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30 select-none">
<div className="px-5 pt-5 pb-4">
<p className="text-sm font-semibold text-destructive">
Linear authentication expired
</p>
<p className="text-sm font-semibold text-destructive">Linear authentication expired</p>
</div>
<div className="mx-5 h-px bg-border/50" />
<div className="px-5 py-4">
@ -671,9 +698,7 @@ function NotFoundCard({ result }: { result: NotFoundResult }) {
return (
<div className="my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30 select-none">
<div className="px-5 pt-5 pb-4">
<p className="text-sm font-semibold text-amber-600 dark:text-amber-400">
Issue not found
</p>
<p className="text-sm font-semibold text-amber-600 dark:text-amber-400">Issue not found</p>
</div>
<div className="mx-5 h-px bg-border/50" />
<div className="px-5 py-4">

Some files were not shown because too many files have changed in this diff Show more