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

View file

@ -43,11 +43,16 @@ def create_create_confluence_page_tool(
logger.info(f"create_confluence_page called: title='{title}'") 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: 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: try:
metadata_service = ConfluenceToolMetadataService(db_session) 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: if "error" in context:
return {"status": "error", "message": context["error"]} return {"status": "error", "message": context["error"]}
@ -60,7 +65,8 @@ def create_create_confluence_page_tool(
"connector_type": "confluence", "connector_type": "confluence",
} }
approval = interrupt({ approval = interrupt(
{
"type": "confluence_page_creation", "type": "confluence_page_creation",
"action": { "action": {
"tool": "create_confluence_page", "tool": "create_confluence_page",
@ -72,10 +78,15 @@ def create_create_confluence_page_tool(
}, },
}, },
"context": context, "context": context,
}) }
)
decisions_raw = approval.get("decisions", []) if isinstance(approval, dict) else [] decisions_raw = (
decisions = decisions_raw if isinstance(decisions_raw, list) else [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)] decisions = [d for d in decisions if isinstance(d, dict)]
if not decisions: if not decisions:
return {"status": "error", "message": "No approval decision received"} 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") decision_type = decision.get("type") or decision.get("decision_type")
if decision_type == "reject": 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] = {} final_params: dict[str, Any] = {}
edited_action = decision.get("edited_action") edited_action = decision.get("edited_action")
@ -114,12 +128,16 @@ def create_create_confluence_page_tool(
select(SearchSourceConnector).filter( select(SearchSourceConnector).filter(
SearchSourceConnector.search_space_id == search_space_id, SearchSourceConnector.search_space_id == search_space_id,
SearchSourceConnector.user_id == user_id, SearchSourceConnector.user_id == user_id,
SearchSourceConnector.connector_type == SearchSourceConnectorType.CONFLUENCE_CONNECTOR, SearchSourceConnector.connector_type
== SearchSourceConnectorType.CONFLUENCE_CONNECTOR,
) )
) )
connector = result.scalars().first() connector = result.scalars().first()
if not connector: if not connector:
return {"status": "error", "message": "No Confluence connector found."} return {
"status": "error",
"message": "No Confluence connector found.",
}
actual_connector_id = connector.id actual_connector_id = connector.id
else: else:
result = await db_session.execute( result = await db_session.execute(
@ -127,15 +145,21 @@ def create_create_confluence_page_tool(
SearchSourceConnector.id == actual_connector_id, SearchSourceConnector.id == actual_connector_id,
SearchSourceConnector.search_space_id == search_space_id, SearchSourceConnector.search_space_id == search_space_id,
SearchSourceConnector.user_id == user_id, SearchSourceConnector.user_id == user_id,
SearchSourceConnector.connector_type == SearchSourceConnectorType.CONFLUENCE_CONNECTOR, SearchSourceConnector.connector_type
== SearchSourceConnectorType.CONFLUENCE_CONNECTOR,
) )
) )
connector = result.scalars().first() connector = result.scalars().first()
if not connector: if not connector:
return {"status": "error", "message": "Selected Confluence connector is invalid."} return {
"status": "error",
"message": "Selected Confluence connector is invalid.",
}
try: 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( api_result = await client.create_page(
space_id=final_space_id, space_id=final_space_id,
title=final_title, title=final_title,
@ -143,7 +167,10 @@ def create_create_confluence_page_tool(
) )
await client.close() await client.close()
except Exception as api_err: 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: try:
_conn = connector _conn = connector
_conn.config = {**_conn.config, "auth_expired": True} _conn.config = {**_conn.config, "auth_expired": True}
@ -163,6 +190,7 @@ def create_create_confluence_page_tool(
kb_message_suffix = "" kb_message_suffix = ""
try: try:
from app.services.confluence import ConfluenceKBSyncService from app.services.confluence import ConfluenceKBSyncService
kb_service = ConfluenceKBSyncService(db_session) kb_service = ConfluenceKBSyncService(db_session)
kb_result = await kb_service.sync_after_create( kb_result = await kb_service.sync_after_create(
page_id=page_id, page_id=page_id,
@ -189,9 +217,13 @@ def create_create_confluence_page_tool(
except Exception as e: except Exception as e:
from langgraph.errors import GraphInterrupt from langgraph.errors import GraphInterrupt
if isinstance(e, GraphInterrupt): if isinstance(e, GraphInterrupt):
raise raise
logger.error(f"Error creating Confluence page: {e}", exc_info=True) 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 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 "not_found", relay the message to the user.
- If status is "insufficient_permissions", inform user to re-authenticate. - 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: 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: try:
metadata_service = ConfluenceToolMetadataService(db_session) 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: if "error" in context:
error_msg = context["error"] error_msg = context["error"]
@ -67,7 +74,8 @@ def create_delete_confluence_page_tool(
document_id = page_data["document_id"] document_id = page_data["document_id"]
connector_id_from_context = context.get("account", {}).get("id") connector_id_from_context = context.get("account", {}).get("id")
approval = interrupt({ approval = interrupt(
{
"type": "confluence_page_deletion", "type": "confluence_page_deletion",
"action": { "action": {
"tool": "delete_confluence_page", "tool": "delete_confluence_page",
@ -78,10 +86,15 @@ def create_delete_confluence_page_tool(
}, },
}, },
"context": context, "context": context,
}) }
)
decisions_raw = approval.get("decisions", []) if isinstance(approval, dict) else [] decisions_raw = (
decisions = decisions_raw if isinstance(decisions_raw, list) else [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)] decisions = [d for d in decisions if isinstance(d, dict)]
if not decisions: if not decisions:
return {"status": "error", "message": "No approval decision received"} 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") decision_type = decision.get("type") or decision.get("decision_type")
if decision_type == "reject": 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] = {} final_params: dict[str, Any] = {}
edited_action = decision.get("edited_action") edited_action = decision.get("edited_action")
@ -102,33 +118,47 @@ def create_delete_confluence_page_tool(
final_params = decision["args"] final_params = decision["args"]
final_page_id = final_params.get("page_id", page_id) 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) final_delete_from_kb = final_params.get("delete_from_kb", delete_from_kb)
from sqlalchemy.future import select from sqlalchemy.future import select
from app.db import SearchSourceConnector, SearchSourceConnectorType from app.db import SearchSourceConnector, SearchSourceConnectorType
if not final_connector_id: 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( result = await db_session.execute(
select(SearchSourceConnector).filter( select(SearchSourceConnector).filter(
SearchSourceConnector.id == final_connector_id, SearchSourceConnector.id == final_connector_id,
SearchSourceConnector.search_space_id == search_space_id, SearchSourceConnector.search_space_id == search_space_id,
SearchSourceConnector.user_id == user_id, SearchSourceConnector.user_id == user_id,
SearchSourceConnector.connector_type == SearchSourceConnectorType.CONFLUENCE_CONNECTOR, SearchSourceConnector.connector_type
== SearchSourceConnectorType.CONFLUENCE_CONNECTOR,
) )
) )
connector = result.scalars().first() connector = result.scalars().first()
if not connector: if not connector:
return {"status": "error", "message": "Selected Confluence connector is invalid."} return {
"status": "error",
"message": "Selected Confluence connector is invalid.",
}
try: 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.delete_page(final_page_id)
await client.close() await client.close()
except Exception as api_err: 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: try:
connector.config = {**connector.config, "auth_expired": True} connector.config = {**connector.config, "auth_expired": True}
flag_modified(connector, "config") flag_modified(connector, "config")
@ -146,6 +176,7 @@ def create_delete_confluence_page_tool(
if final_delete_from_kb and document_id: if final_delete_from_kb and document_id:
try: try:
from app.db import Document from app.db import Document
doc_result = await db_session.execute( doc_result = await db_session.execute(
select(Document).filter(Document.id == document_id) select(Document).filter(Document.id == document_id)
) )
@ -171,9 +202,13 @@ def create_delete_confluence_page_tool(
except Exception as e: except Exception as e:
from langgraph.errors import GraphInterrupt from langgraph.errors import GraphInterrupt
if isinstance(e, GraphInterrupt): if isinstance(e, GraphInterrupt):
raise raise
logger.error(f"Error deleting Confluence page: {e}", exc_info=True) 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 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 "not_found", relay the message to the user.
- If status is "insufficient_permissions", inform user to re-authenticate. - 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: 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: try:
metadata_service = ConfluenceToolMetadataService(db_session) 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: if "error" in context:
error_msg = context["error"] error_msg = context["error"]
@ -71,7 +78,8 @@ def create_update_confluence_page_tool(
document_id = page_data.get("document_id") document_id = page_data.get("document_id")
connector_id_from_context = context.get("account", {}).get("id") connector_id_from_context = context.get("account", {}).get("id")
approval = interrupt({ approval = interrupt(
{
"type": "confluence_page_update", "type": "confluence_page_update",
"action": { "action": {
"tool": "update_confluence_page", "tool": "update_confluence_page",
@ -85,10 +93,15 @@ def create_update_confluence_page_tool(
}, },
}, },
"context": context, "context": context,
}) }
)
decisions_raw = approval.get("decisions", []) if isinstance(approval, dict) else [] decisions_raw = (
decisions = decisions_raw if isinstance(decisions_raw, list) else [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)] decisions = [d for d in decisions if isinstance(d, dict)]
if not decisions: if not decisions:
return {"status": "error", "message": "No approval decision received"} 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") decision_type = decision.get("type") or decision.get("decision_type")
if decision_type == "reject": 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] = {} final_params: dict[str, Any] = {}
edited_action = decision.get("edited_action") edited_action = decision.get("edited_action")
@ -114,29 +130,40 @@ def create_update_confluence_page_tool(
if final_content is None: if final_content is None:
final_content = current_body final_content = current_body
final_version = final_params.get("version", current_version) 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) final_document_id = final_params.get("document_id", document_id)
from sqlalchemy.future import select from sqlalchemy.future import select
from app.db import SearchSourceConnector, SearchSourceConnectorType from app.db import SearchSourceConnector, SearchSourceConnectorType
if not final_connector_id: 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( result = await db_session.execute(
select(SearchSourceConnector).filter( select(SearchSourceConnector).filter(
SearchSourceConnector.id == final_connector_id, SearchSourceConnector.id == final_connector_id,
SearchSourceConnector.search_space_id == search_space_id, SearchSourceConnector.search_space_id == search_space_id,
SearchSourceConnector.user_id == user_id, SearchSourceConnector.user_id == user_id,
SearchSourceConnector.connector_type == SearchSourceConnectorType.CONFLUENCE_CONNECTOR, SearchSourceConnector.connector_type
== SearchSourceConnectorType.CONFLUENCE_CONNECTOR,
) )
) )
connector = result.scalars().first() connector = result.scalars().first()
if not connector: if not connector:
return {"status": "error", "message": "Selected Confluence connector is invalid."} return {
"status": "error",
"message": "Selected Confluence connector is invalid.",
}
try: 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( await client.update_page(
page_id=final_page_id, page_id=final_page_id,
title=final_title, title=final_title,
@ -145,7 +172,10 @@ def create_update_confluence_page_tool(
) )
await client.close() await client.close()
except Exception as api_err: 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: try:
connector.config = {**connector.config, "auth_expired": True} connector.config = {**connector.config, "auth_expired": True}
flag_modified(connector, "config") flag_modified(connector, "config")
@ -163,6 +193,7 @@ def create_update_confluence_page_tool(
if final_document_id: if final_document_id:
try: try:
from app.services.confluence import ConfluenceKBSyncService from app.services.confluence import ConfluenceKBSyncService
kb_service = ConfluenceKBSyncService(db_session) kb_service = ConfluenceKBSyncService(db_session)
kb_result = await kb_service.sync_after_update( kb_result = await kb_service.sync_after_update(
document_id=final_document_id, document_id=final_document_id,
@ -171,12 +202,18 @@ def create_update_confluence_page_tool(
search_space_id=search_space_id, search_space_id=search_space_id,
) )
if kb_result["status"] == "success": 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: 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: except Exception as kb_err:
logger.warning(f"KB sync after update failed: {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 { return {
"status": "success", "status": "success",
@ -186,9 +223,13 @@ def create_update_confluence_page_tool(
except Exception as e: except Exception as e:
from langgraph.errors import GraphInterrupt from langgraph.errors import GraphInterrupt
if isinstance(e, GraphInterrupt): if isinstance(e, GraphInterrupt):
raise raise
logger.error(f"Error updating Confluence page: {e}", exc_info=True) 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 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" - "Draft an email to alice@example.com about the meeting"
- "Compose a reply to Bob about the project update" - "Compose a reply to Bob about the project update"
""" """
logger.info( logger.info(f"create_gmail_draft called: to='{to}', subject='{subject}'")
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: if db_session is None or search_space_id is None or user_id is None:
return { 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}" 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 from app.utils.google_credentials import build_composio_credentials
cca_id = connector.config.get("composio_connected_account_id") cca_id = connector.config.get("composio_connected_account_id")
@ -251,10 +252,12 @@ def create_create_gmail_draft_tool(
try: try:
created = await asyncio.get_event_loop().run_in_executor( created = await asyncio.get_event_loop().run_in_executor(
None, None,
lambda: gmail_service.users() lambda: (
gmail_service.users()
.drafts() .drafts()
.create(userId="me", body={"message": {"raw": raw}}) .create(userId="me", body={"message": {"raw": raw}})
.execute(), .execute()
),
) )
except Exception as api_err: except Exception as api_err:
from googleapiclient.errors import HttpError from googleapiclient.errors import HttpError
@ -289,9 +292,7 @@ def create_create_gmail_draft_tool(
} }
raise raise
logger.info( logger.info(f"Gmail draft created: id={created.get('id')}")
f"Gmail draft created: id={created.get('id')}"
)
kb_message_suffix = "" kb_message_suffix = ""
try: try:

View file

@ -56,9 +56,7 @@ def create_send_gmail_email_tool(
- "Send an email to alice@example.com about the meeting" - "Send an email to alice@example.com about the meeting"
- "Email Bob the project update" - "Email Bob the project update"
""" """
logger.info( logger.info(f"send_gmail_email called: to='{to}', subject='{subject}'")
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: if db_session is None or search_space_id is None or user_id is None:
return { 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}" 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 from app.utils.google_credentials import build_composio_credentials
cca_id = connector.config.get("composio_connected_account_id") cca_id = connector.config.get("composio_connected_account_id")
@ -252,10 +253,12 @@ def create_send_gmail_email_tool(
try: try:
sent = await asyncio.get_event_loop().run_in_executor( sent = await asyncio.get_event_loop().run_in_executor(
None, None,
lambda: gmail_service.users() lambda: (
gmail_service.users()
.messages() .messages()
.send(userId="me", body={"raw": raw}) .send(userId="me", body={"raw": raw})
.execute(), .execute()
),
) )
except Exception as api_err: except Exception as api_err:
from googleapiclient.errors import HttpError 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}" 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 from app.utils.google_credentials import build_composio_credentials
cca_id = connector.config.get("composio_connected_account_id") cca_id = connector.config.get("composio_connected_account_id")
@ -241,10 +244,12 @@ def create_trash_gmail_email_tool(
try: try:
await asyncio.get_event_loop().run_in_executor( await asyncio.get_event_loop().run_in_executor(
None, None,
lambda: gmail_service.users() lambda: (
gmail_service.users()
.messages() .messages()
.trash(userId="me", id=final_message_id) .trash(userId="me", id=final_message_id)
.execute(), .execute()
),
) )
except Exception as api_err: except Exception as api_err:
from googleapiclient.errors import HttpError from googleapiclient.errors import HttpError
@ -257,7 +262,10 @@ def create_trash_gmail_email_tool(
from sqlalchemy.orm.attributes import flag_modified from sqlalchemy.orm.attributes import flag_modified
if not connector.config.get("auth_expired"): 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") flag_modified(connector, "config")
await db_session.commit() await db_session.commit()
except Exception: except Exception:
@ -273,9 +281,7 @@ def create_trash_gmail_email_tool(
} }
raise raise
logger.info( logger.info(f"Gmail email trashed: message_id={final_message_id}")
f"Gmail email trashed: message_id={final_message_id}"
)
trash_result: dict[str, Any] = { trash_result: dict[str, Any] = {
"status": "success", "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}" 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 from app.utils.google_credentials import build_composio_credentials
cca_id = connector.config.get("composio_connected_account_id") cca_id = connector.config.get("composio_connected_account_id")
@ -299,14 +302,16 @@ def create_update_gmail_draft_tool(
try: try:
updated = await asyncio.get_event_loop().run_in_executor( updated = await asyncio.get_event_loop().run_in_executor(
None, None,
lambda: gmail_service.users() lambda: (
gmail_service.users()
.drafts() .drafts()
.update( .update(
userId="me", userId="me",
id=final_draft_id, id=final_draft_id,
body={"message": {"raw": raw}}, body={"message": {"raw": raw}},
) )
.execute(), .execute()
),
) )
except Exception as api_err: except Exception as api_err:
from googleapiclient.errors import HttpError from googleapiclient.errors import HttpError
@ -369,7 +374,9 @@ def create_update_gmail_draft_tool(
document.document_metadata = meta document.document_metadata = meta
flag_modified(document, "document_metadata") flag_modified(document, "document_metadata")
await db_session.commit() 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( logger.info(
f"KB document {document_id} updated for draft {final_draft_id}" 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", []) accounts = context.get("accounts", [])
if accounts and all(a.get("auth_expired") for a in 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 { return {
"status": "auth_error", "status": "auth_error",
"message": "All connected Google Calendar accounts need re-authentication. Please re-authenticate in your connector settings.", "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}" 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 from app.utils.google_credentials import build_composio_credentials
cca_id = connector.config.get("composio_connected_account_id") 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) token_encryption = TokenEncryption(app_config.SECRET_KEY)
for key in ("token", "refresh_token", "client_secret"): for key in ("token", "refresh_token", "client_secret"):
if config_data.get(key): 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", "") exp = config_data.get("expiry", "")
if exp: if exp:
@ -254,9 +261,11 @@ def create_create_calendar_event_tool(
try: try:
created = await asyncio.get_event_loop().run_in_executor( created = await asyncio.get_event_loop().run_in_executor(
None, None,
lambda: service.events() lambda: (
service.events()
.insert(calendarId="primary", body=event_body) .insert(calendarId="primary", body=event_body)
.execute(), .execute()
),
) )
except Exception as api_err: except Exception as api_err:
from googleapiclient.errors import HttpError 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}" 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 from app.utils.google_credentials import build_composio_credentials
cca_id = connector.config.get("composio_connected_account_id") 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) token_encryption = TokenEncryption(app_config.SECRET_KEY)
for key in ("token", "refresh_token", "client_secret"): for key in ("token", "refresh_token", "client_secret"):
if config_data.get(key): 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", "") exp = config_data.get("expiry", "")
if exp: if exp:
@ -232,9 +237,11 @@ def create_delete_calendar_event_tool(
try: try:
await asyncio.get_event_loop().run_in_executor( await asyncio.get_event_loop().run_in_executor(
None, None,
lambda: service.events() lambda: (
service.events()
.delete(calendarId="primary", eventId=final_event_id) .delete(calendarId="primary", eventId=final_event_id)
.execute(), .execute()
),
) )
except Exception as api_err: except Exception as api_err:
from googleapiclient.errors import HttpError from googleapiclient.errors import HttpError
@ -269,9 +276,7 @@ def create_delete_calendar_event_tool(
} }
raise raise
logger.info( logger.info(f"Calendar event deleted: event_id={final_event_id}")
f"Calendar event deleted: event_id={final_event_id}"
)
delete_result: dict[str, Any] = { delete_result: dict[str, Any] = {
"status": "success", "status": "success",

View file

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

View file

@ -208,7 +208,10 @@ def create_create_google_drive_file_tool(
) )
pre_built_creds = None 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 from app.utils.google_credentials import build_composio_credentials
cca_id = connector.config.get("composio_connected_account_id") 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 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 from app.utils.google_credentials import build_composio_credentials
cca_id = connector.config.get("composio_connected_account_id") 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 from sqlalchemy.orm.attributes import flag_modified
if not connector.config.get("auth_expired"): 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") flag_modified(connector, "config")
await db_session.commit() await db_session.commit()
except Exception: 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 "rejected", the user declined. Do NOT retry.
- If status is "insufficient_permissions", inform user to re-authenticate. - 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: 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."} return {"status": "error", "message": "Jira tool not properly configured."}
try: try:
metadata_service = JiraToolMetadataService(db_session) 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: if "error" in context:
return {"status": "error", "message": context["error"]} return {"status": "error", "message": context["error"]}
@ -65,7 +69,8 @@ def create_create_jira_issue_tool(
"connector_type": "jira", "connector_type": "jira",
} }
approval = interrupt({ approval = interrupt(
{
"type": "jira_issue_creation", "type": "jira_issue_creation",
"action": { "action": {
"tool": "create_jira_issue", "tool": "create_jira_issue",
@ -79,10 +84,15 @@ def create_create_jira_issue_tool(
}, },
}, },
"context": context, "context": context,
}) }
)
decisions_raw = approval.get("decisions", []) if isinstance(approval, dict) else [] decisions_raw = (
decisions = decisions_raw if isinstance(decisions_raw, list) else [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)] decisions = [d for d in decisions if isinstance(d, dict)]
if not decisions: if not decisions:
return {"status": "error", "message": "No approval decision received"} 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") decision_type = decision.get("type") or decision.get("decision_type")
if decision_type == "reject": 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] = {} final_params: dict[str, Any] = {}
edited_action = decision.get("edited_action") edited_action = decision.get("edited_action")
@ -123,7 +136,8 @@ def create_create_jira_issue_tool(
select(SearchSourceConnector).filter( select(SearchSourceConnector).filter(
SearchSourceConnector.search_space_id == search_space_id, SearchSourceConnector.search_space_id == search_space_id,
SearchSourceConnector.user_id == user_id, SearchSourceConnector.user_id == user_id,
SearchSourceConnector.connector_type == SearchSourceConnectorType.JIRA_CONNECTOR, SearchSourceConnector.connector_type
== SearchSourceConnectorType.JIRA_CONNECTOR,
) )
) )
connector = result.scalars().first() connector = result.scalars().first()
@ -136,15 +150,21 @@ def create_create_jira_issue_tool(
SearchSourceConnector.id == actual_connector_id, SearchSourceConnector.id == actual_connector_id,
SearchSourceConnector.search_space_id == search_space_id, SearchSourceConnector.search_space_id == search_space_id,
SearchSourceConnector.user_id == user_id, SearchSourceConnector.user_id == user_id,
SearchSourceConnector.connector_type == SearchSourceConnectorType.JIRA_CONNECTOR, SearchSourceConnector.connector_type
== SearchSourceConnectorType.JIRA_CONNECTOR,
) )
) )
connector = result.scalars().first() connector = result.scalars().first()
if not connector: if not connector:
return {"status": "error", "message": "Selected Jira connector is invalid."} return {
"status": "error",
"message": "Selected Jira connector is invalid.",
}
try: 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() jira_client = await jira_history._get_jira_client()
api_result = await asyncio.to_thread( api_result = await asyncio.to_thread(
jira_client.create_issue, jira_client.create_issue,
@ -175,6 +195,7 @@ def create_create_jira_issue_tool(
kb_message_suffix = "" kb_message_suffix = ""
try: try:
from app.services.jira import JiraKBSyncService from app.services.jira import JiraKBSyncService
kb_service = JiraKBSyncService(db_session) kb_service = JiraKBSyncService(db_session)
kb_result = await kb_service.sync_after_create( kb_result = await kb_service.sync_after_create(
issue_id=issue_key, issue_id=issue_key,
@ -202,9 +223,13 @@ def create_create_jira_issue_tool(
except Exception as e: except Exception as e:
from langgraph.errors import GraphInterrupt from langgraph.errors import GraphInterrupt
if isinstance(e, GraphInterrupt): if isinstance(e, GraphInterrupt):
raise raise
logger.error(f"Error creating Jira issue: {e}", exc_info=True) 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 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 "not_found", relay the message to the user.
- If status is "insufficient_permissions", inform user to re-authenticate. - 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: 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."} return {"status": "error", "message": "Jira tool not properly configured."}
try: try:
metadata_service = JiraToolMetadataService(db_session) 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: if "error" in context:
error_msg = context["error"] error_msg = context["error"]
@ -67,7 +71,8 @@ def create_delete_jira_issue_tool(
document_id = issue_data["document_id"] document_id = issue_data["document_id"]
connector_id_from_context = context.get("account", {}).get("id") connector_id_from_context = context.get("account", {}).get("id")
approval = interrupt({ approval = interrupt(
{
"type": "jira_issue_deletion", "type": "jira_issue_deletion",
"action": { "action": {
"tool": "delete_jira_issue", "tool": "delete_jira_issue",
@ -78,10 +83,15 @@ def create_delete_jira_issue_tool(
}, },
}, },
"context": context, "context": context,
}) }
)
decisions_raw = approval.get("decisions", []) if isinstance(approval, dict) else [] decisions_raw = (
decisions = decisions_raw if isinstance(decisions_raw, list) else [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)] decisions = [d for d in decisions if isinstance(d, dict)]
if not decisions: if not decisions:
return {"status": "error", "message": "No approval decision received"} 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") decision_type = decision.get("type") or decision.get("decision_type")
if decision_type == "reject": 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] = {} final_params: dict[str, Any] = {}
edited_action = decision.get("edited_action") edited_action = decision.get("edited_action")
@ -102,29 +115,40 @@ def create_delete_jira_issue_tool(
final_params = decision["args"] final_params = decision["args"]
final_issue_key = final_params.get("issue_key", issue_key) 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) final_delete_from_kb = final_params.get("delete_from_kb", delete_from_kb)
from sqlalchemy.future import select from sqlalchemy.future import select
from app.db import SearchSourceConnector, SearchSourceConnectorType from app.db import SearchSourceConnector, SearchSourceConnectorType
if not final_connector_id: 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( result = await db_session.execute(
select(SearchSourceConnector).filter( select(SearchSourceConnector).filter(
SearchSourceConnector.id == final_connector_id, SearchSourceConnector.id == final_connector_id,
SearchSourceConnector.search_space_id == search_space_id, SearchSourceConnector.search_space_id == search_space_id,
SearchSourceConnector.user_id == user_id, SearchSourceConnector.user_id == user_id,
SearchSourceConnector.connector_type == SearchSourceConnectorType.JIRA_CONNECTOR, SearchSourceConnector.connector_type
== SearchSourceConnectorType.JIRA_CONNECTOR,
) )
) )
connector = result.scalars().first() connector = result.scalars().first()
if not connector: if not connector:
return {"status": "error", "message": "Selected Jira connector is invalid."} return {
"status": "error",
"message": "Selected Jira connector is invalid.",
}
try: 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() jira_client = await jira_history._get_jira_client()
await asyncio.to_thread(jira_client.delete_issue, final_issue_key) await asyncio.to_thread(jira_client.delete_issue, final_issue_key)
except Exception as api_err: except Exception as api_err:
@ -146,6 +170,7 @@ def create_delete_jira_issue_tool(
if final_delete_from_kb and document_id: if final_delete_from_kb and document_id:
try: try:
from app.db import Document from app.db import Document
doc_result = await db_session.execute( doc_result = await db_session.execute(
select(Document).filter(Document.id == document_id) select(Document).filter(Document.id == document_id)
) )
@ -171,9 +196,13 @@ def create_delete_jira_issue_tool(
except Exception as e: except Exception as e:
from langgraph.errors import GraphInterrupt from langgraph.errors import GraphInterrupt
if isinstance(e, GraphInterrupt): if isinstance(e, GraphInterrupt):
raise raise
logger.error(f"Error deleting Jira issue: {e}", exc_info=True) 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 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 "not_found", relay the message and ask user to verify.
- If status is "insufficient_permissions", inform user to re-authenticate. - 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: 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."} return {"status": "error", "message": "Jira tool not properly configured."}
try: try:
metadata_service = JiraToolMetadataService(db_session) 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: if "error" in context:
error_msg = context["error"] error_msg = context["error"]
@ -71,7 +75,8 @@ def create_update_jira_issue_tool(
document_id = issue_data.get("document_id") document_id = issue_data.get("document_id")
connector_id_from_context = context.get("account", {}).get("id") connector_id_from_context = context.get("account", {}).get("id")
approval = interrupt({ approval = interrupt(
{
"type": "jira_issue_update", "type": "jira_issue_update",
"action": { "action": {
"tool": "update_jira_issue", "tool": "update_jira_issue",
@ -85,10 +90,15 @@ def create_update_jira_issue_tool(
}, },
}, },
"context": context, "context": context,
}) }
)
decisions_raw = approval.get("decisions", []) if isinstance(approval, dict) else [] decisions_raw = (
decisions = decisions_raw if isinstance(decisions_raw, list) else [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)] decisions = [d for d in decisions if isinstance(d, dict)]
if not decisions: if not decisions:
return {"status": "error", "message": "No approval decision received"} 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") decision_type = decision.get("type") or decision.get("decision_type")
if decision_type == "reject": 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] = {} final_params: dict[str, Any] = {}
edited_action = decision.get("edited_action") 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_summary = final_params.get("new_summary", new_summary)
final_description = final_params.get("new_description", new_description) final_description = final_params.get("new_description", new_description)
final_priority = final_params.get("new_priority", new_priority) 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) final_document_id = final_params.get("document_id", document_id)
from sqlalchemy.future import select from sqlalchemy.future import select
from app.db import SearchSourceConnector, SearchSourceConnectorType from app.db import SearchSourceConnector, SearchSourceConnectorType
if not final_connector_id: 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( result = await db_session.execute(
select(SearchSourceConnector).filter( select(SearchSourceConnector).filter(
SearchSourceConnector.id == final_connector_id, SearchSourceConnector.id == final_connector_id,
SearchSourceConnector.search_space_id == search_space_id, SearchSourceConnector.search_space_id == search_space_id,
SearchSourceConnector.user_id == user_id, SearchSourceConnector.user_id == user_id,
SearchSourceConnector.connector_type == SearchSourceConnectorType.JIRA_CONNECTOR, SearchSourceConnector.connector_type
== SearchSourceConnectorType.JIRA_CONNECTOR,
) )
) )
connector = result.scalars().first() connector = result.scalars().first()
if not connector: 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] = {} fields: dict[str, Any] = {}
if final_summary: if final_summary:
@ -140,7 +162,12 @@ def create_update_jira_issue_tool(
fields["description"] = { fields["description"] = {
"type": "doc", "type": "doc",
"version": 1, "version": 1,
"content": [{"type": "paragraph", "content": [{"type": "text", "text": final_description}]}], "content": [
{
"type": "paragraph",
"content": [{"type": "text", "text": final_description}],
}
],
} }
if final_priority: if final_priority:
fields["priority"] = {"name": final_priority} fields["priority"] = {"name": final_priority}
@ -149,9 +176,13 @@ def create_update_jira_issue_tool(
return {"status": "error", "message": "No changes specified."} return {"status": "error", "message": "No changes specified."}
try: 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() 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: except Exception as api_err:
if "status code 403" in str(api_err).lower(): if "status code 403" in str(api_err).lower():
try: try:
@ -171,6 +202,7 @@ def create_update_jira_issue_tool(
if final_document_id: if final_document_id:
try: try:
from app.services.jira import JiraKBSyncService from app.services.jira import JiraKBSyncService
kb_service = JiraKBSyncService(db_session) kb_service = JiraKBSyncService(db_session)
kb_result = await kb_service.sync_after_update( kb_result = await kb_service.sync_after_update(
document_id=final_document_id, document_id=final_document_id,
@ -179,12 +211,18 @@ def create_update_jira_issue_tool(
search_space_id=search_space_id, search_space_id=search_space_id,
) )
if kb_result["status"] == "success": 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: 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: except Exception as kb_err:
logger.warning(f"KB sync after update failed: {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 { return {
"status": "success", "status": "success",
@ -194,9 +232,13 @@ def create_update_jira_issue_tool(
except Exception as e: except Exception as e:
from langgraph.errors import GraphInterrupt from langgraph.errors import GraphInterrupt
if isinstance(e, GraphInterrupt): if isinstance(e, GraphInterrupt):
raise raise
logger.error(f"Error updating Jira issue: {e}", exc_info=True) 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 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). # a real search. We want breadth (many docs) over depth (many chunks).
_BROWSE_MAX_CHUNKS_PER_DOC = 5 _BROWSE_MAX_CHUNKS_PER_DOC = 5
def _is_degenerate_query(query: str) -> bool: def _is_degenerate_query(query: str) -> bool:
"""Return True when the query carries no meaningful search signal. """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] base_conditions = [Document.search_space_id == search_space_id]
if document_type is not None: 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 = [] doc_type_enums = []
for dt in type_list: for dt in type_list:
if isinstance(dt, str): if isinstance(dt, str):

View file

@ -245,7 +245,9 @@ def create_create_notion_page_tool(
user_id=user_id, user_id=user_id,
) )
if kb_result["status"] == "success": 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: else:
kb_message_suffix = " This page will be added to your knowledge base in the next scheduled sync." kb_message_suffix = " This page will be added to your knowledge base in the next scheduled sync."
except Exception as kb_err: except Exception as kb_err:

View file

@ -280,7 +280,9 @@ def create_delete_notion_page_tool(
return { return {
"status": "auth_error", "status": "auth_error",
"message": str(e), "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", "connector_type": "notion",
} }
if isinstance(e, ValueError | NotionAPIError): if isinstance(e, ValueError | NotionAPIError):

View file

@ -281,7 +281,9 @@ def create_update_notion_page_tool(
return { return {
"status": "auth_error", "status": "auth_error",
"message": str(e), "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", "connector_type": "notion",
} }
if isinstance(e, ValueError | NotionAPIError): if isinstance(e, ValueError | NotionAPIError):

View file

@ -190,7 +190,9 @@ class ConfluenceHistoryConnector:
) )
# Lazy import to avoid circular dependency # 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) connector = await refresh_confluence_token(self._session, connector)
@ -375,13 +377,9 @@ class ConfluenceHistoryConnector:
url, headers=headers, json=json_payload, params=params url, headers=headers, json=json_payload, params=params
) )
elif method_upper == "DELETE": elif method_upper == "DELETE":
response = await http_client.delete( response = await http_client.delete(url, headers=headers, params=params)
url, headers=headers, params=params
)
else: else:
response = await http_client.get( response = await http_client.get(url, headers=headers, params=params)
url, headers=headers, params=params
)
response.raise_for_status() response.raise_for_status()
if response.status_code == 204 or not response.text: 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) has_standard_refresh = bool(self._credentials.refresh_token)
if has_standard_refresh: if has_standard_refresh:
if not all( if not all([self._credentials.client_id, self._credentials.client_secret]):
[self._credentials.client_id, self._credentials.client_secret]
):
raise ValueError( raise ValueError(
"Google OAuth credentials (client_id, client_secret) must be set" "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) has_standard_refresh = bool(self._credentials.refresh_token)
if has_standard_refresh: if has_standard_refresh:
if not all( if not all([self._credentials.client_id, self._credentials.client_secret]):
[self._credentials.client_id, self._credentials.client_secret]
):
raise ValueError( raise ValueError(
"Google OAuth credentials (client_id, client_secret) must be set" "Google OAuth credentials (client_id, client_secret) must be set"
) )
@ -139,18 +137,14 @@ class GoogleGmailConnector:
from app.utils.oauth_security import TokenEncryption from app.utils.oauth_security import TokenEncryption
creds_dict = json.loads(self._credentials.to_json()) creds_dict = json.loads(self._credentials.to_json())
token_encrypted = connector.config.get( token_encrypted = connector.config.get("_token_encrypted", False)
"_token_encrypted", False
)
if token_encrypted and config.SECRET_KEY: if token_encrypted and config.SECRET_KEY:
token_encryption = TokenEncryption(config.SECRET_KEY) token_encryption = TokenEncryption(config.SECRET_KEY)
if creds_dict.get("token"): if creds_dict.get("token"):
creds_dict["token"] = ( creds_dict["token"] = token_encryption.encrypt_token(
token_encryption.encrypt_token(
creds_dict["token"] creds_dict["token"]
) )
)
if creds_dict.get("refresh_token"): if creds_dict.get("refresh_token"):
creds_dict["refresh_token"] = ( creds_dict["refresh_token"] = (
token_encryption.encrypt_token( token_encryption.encrypt_token(

View file

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

View file

@ -199,7 +199,9 @@ class DocumentHybridSearchRetriever:
# Add document type filter if provided (single string or list of strings) # Add document type filter if provided (single string or list of strings)
if document_type is not None: 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 = [] doc_type_enums = []
for dt in type_list: for dt in type_list:
if isinstance(dt, str): 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 return_url: Optional frontend path to redirect to after completion
""" """
if not ComposioService.is_enabled(): 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: 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: try:
result = await session.execute( result = await session.execute(
@ -502,7 +506,9 @@ async def reauth_composio_connector(
callback_base = config.COMPOSIO_REDIRECT_URI callback_base = config.COMPOSIO_REDIRECT_URI
if not callback_base: if not callback_base:
backend_url = config.BACKEND_URL or "http://localhost:8000" 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: else:
# Replace the normal callback path with the reauth one # Replace the normal callback path with the reauth one
callback_base = callback_base.replace( callback_base = callback_base.replace(
@ -524,8 +530,13 @@ async def reauth_composio_connector(
connector.config = {**connector.config, "auth_expired": False} connector.config = {**connector.config, "auth_expired": False}
flag_modified(connector, "config") flag_modified(connector, "config")
await session.commit() await session.commit()
logger.info(f"Composio account {connected_account_id} refreshed server-side (no redirect needed)") logger.info(
return {"success": True, "message": "Authentication refreshed successfully."} 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}") logger.info(f"Initiating Composio re-auth for connector {connector_id}")
return {"auth_url": refresh_result["redirect_url"]} 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) credentials = build_composio_credentials(composio_connected_account_id)
drive_client = GoogleDriveClient( drive_client = GoogleDriveClient(session, connector_id, credentials=credentials)
session, connector_id, credentials=credentials
)
items, error = await list_folder_contents(drive_client, parent_id=parent_id) 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} connector.config = {**connector.config, "auth_expired": True}
flag_modified(connector, "config") flag_modified(connector, "config")
await session.commit() 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: 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( 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( raise HTTPException(
status_code=500, detail=f"Failed to list folder contents: {error}" 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} connector.config = {**connector.config, "auth_expired": True}
flag_modified(connector, "config") flag_modified(connector, "config")
await session.commit() 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: 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( 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 ) from e
raise HTTPException( raise HTTPException(
status_code=500, detail=f"Failed to list Drive contents: {e!s}" 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() await session.commit()
logger.info(f"Marked connector {connector_id} as auth_expired") logger.info(f"Marked connector {connector_id} as auth_expired")
except Exception: 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( 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( raise HTTPException(
status_code=500, detail=f"Failed to list folder contents: {error}" status_code=500, detail=f"Failed to list folder contents: {error}"
@ -562,9 +566,13 @@ async def list_google_drive_folders(
await session.commit() await session.commit()
logger.info(f"Marked connector {connector_id} as auth_expired") logger.info(f"Marked connector {connector_id} as auth_expired")
except Exception: 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( 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 ) from e
raise HTTPException( raise HTTPException(
status_code=500, detail=f"Failed to list Drive contents: {e!s}" 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 = credentials.to_dict()
credentials_dict["_token_encrypted"] = True credentials_dict["_token_encrypted"] = True
if connector.config.get("organization_name"): 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) credentials_dict.pop("auth_expired", None)
connector.config = credentials_dict connector.config = credentials_dict
flag_modified(connector, "config") flag_modified(connector, "config")

View file

@ -2374,7 +2374,11 @@ async def run_google_drive_indexing(
# Index each folder with indexing options # Index each folder with indexing options
for folder in items.folders: for folder in items.folders:
try: try:
indexed_count, skipped_count, error_message = await index_google_drive_files( (
indexed_count,
skipped_count,
error_message,
) = await index_google_drive_files(
session, session,
connector_id, connector_id,
search_space_id, search_space_id,
@ -2429,7 +2433,9 @@ async def run_google_drive_indexing(
) )
if _is_auth_error(error_message): if _is_auth_error(error_message):
await _persist_auth_expired(session, connector_id) 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: else:
# Update notification to storing stage # Update notification to storing stage
if notification: if notification:

View file

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

View file

@ -67,7 +67,10 @@ class ConfluenceKBSyncService:
content_hash = unique_hash content_hash = unique_hash
user_llm = await get_user_long_context_llm( 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 = { doc_metadata_for_summary = {
@ -116,17 +119,26 @@ class ConfluenceKBSyncService:
logger.info( logger.info(
"KB sync after create succeeded: doc_id=%s, page=%s", "KB sync after create succeeded: doc_id=%s, page=%s",
document.id, page_title, document.id,
page_title,
) )
return {"status": "success"} return {"status": "success"}
except Exception as e: except Exception as e:
error_str = str(e).lower() 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() await self.db_session.rollback()
return {"status": "error", "message": "Duplicate document detected"} 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() await self.db_session.rollback()
return {"status": "error", "message": str(e)} return {"status": "error", "message": str(e)}
@ -215,11 +227,14 @@ class ConfluenceKBSyncService:
logger.info( logger.info(
"KB sync successful for document %s (%s)", "KB sync successful for document %s (%s)",
document_id, page_title, document_id,
page_title,
) )
return {"status": "success"} return {"status": "success"}
except Exception as e: 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() await self.db_session.rollback()
return {"status": "error", "message": str(e)} return {"status": "error", "message": str(e)}

View file

@ -126,10 +126,12 @@ class ConfluenceToolMetadataService:
for connector in connectors: for connector in connectors:
auth_expired = await self._check_account_health(connector) auth_expired = await self._check_account_health(connector)
workspace = ConfluenceWorkspace.from_connector(connector) workspace = ConfluenceWorkspace.from_connector(connector)
accounts.append({ accounts.append(
{
**workspace.to_dict(), **workspace.to_dict(),
"auth_expired": auth_expired, "auth_expired": auth_expired,
}) }
)
if not auth_expired and not fetched_context: if not auth_expired and not fetched_context:
try: try:
@ -146,7 +148,8 @@ class ConfluenceToolMetadataService:
except Exception as e: except Exception as e:
logger.warning( logger.warning(
"Failed to fetch Confluence spaces for connector %s: %s", "Failed to fetch Confluence spaces for connector %s: %s",
connector.id, e, connector.id,
e,
) )
return { return {
@ -191,7 +194,11 @@ class ConfluenceToolMetadataService:
await client.close() await client.close()
except Exception as e: except Exception as e:
error_str = str(e).lower() 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 { return {
"error": f"Failed to fetch Confluence page: {e!s}", "error": f"Failed to fetch Confluence page: {e!s}",
"auth_expired": True, "auth_expired": True,
@ -207,7 +214,9 @@ class ConfluenceToolMetadataService:
body_storage = storage.get("value", "") body_storage = storage.get("value", "")
version_obj = page_data.get("version", {}) 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 { return {
"account": {**workspace.to_dict(), "auth_expired": False}, "account": {**workspace.to_dict(), "auth_expired": False},
@ -263,9 +272,7 @@ class ConfluenceToolMetadataService:
Document.document_type == DocumentType.CONFLUENCE_CONNECTOR, Document.document_type == DocumentType.CONFLUENCE_CONNECTOR,
SearchSourceConnector.user_id == user_id, SearchSourceConnector.user_id == user_id,
or_( or_(
func.lower( func.lower(Document.document_metadata.op("->>")("page_title"))
Document.document_metadata.op("->>")("page_title")
)
== ref_lower, == ref_lower,
func.lower(Document.title) == ref_lower, func.lower(Document.title) == ref_lower,
), ),

View file

@ -183,10 +183,12 @@ class GmailToolMetadataService:
and_( and_(
SearchSourceConnector.search_space_id == search_space_id, SearchSourceConnector.search_space_id == search_space_id,
SearchSourceConnector.user_id == user_id, SearchSourceConnector.user_id == user_id,
SearchSourceConnector.connector_type.in_([ SearchSourceConnector.connector_type.in_(
[
SearchSourceConnectorType.GOOGLE_GMAIL_CONNECTOR, SearchSourceConnectorType.GOOGLE_GMAIL_CONNECTOR,
SearchSourceConnectorType.COMPOSIO_GMAIL_CONNECTOR, SearchSourceConnectorType.COMPOSIO_GMAIL_CONNECTOR,
]), ]
),
) )
) )
.order_by(SearchSourceConnector.last_indexed_at.desc()) .order_by(SearchSourceConnector.last_indexed_at.desc())
@ -223,9 +225,7 @@ class GmailToolMetadataService:
service = build("gmail", "v1", credentials=creds) service = build("gmail", "v1", credentials=creds)
profile = await asyncio.get_event_loop().run_in_executor( profile = await asyncio.get_event_loop().run_in_executor(
None, None,
lambda: service.users() lambda: service.users().getProfile(userId="me").execute(),
.getProfile(userId="me")
.execute(),
) )
acc_dict["email"] = profile.get("emailAddress", "") acc_dict["email"] = profile.get("emailAddress", "")
except Exception: except Exception:
@ -306,10 +306,12 @@ class GmailToolMetadataService:
draft = await asyncio.get_event_loop().run_in_executor( draft = await asyncio.get_event_loop().run_in_executor(
None, None,
lambda: service.users() lambda: (
service.users()
.drafts() .drafts()
.get(userId="me", id=draft_id, format="full") .get(userId="me", id=draft_id, format="full")
.execute(), .execute()
),
) )
payload = draft.get("message", {}).get("payload", {}) payload = draft.get("message", {}).get("payload", {})
@ -422,15 +424,15 @@ class GmailToolMetadataService:
.filter( .filter(
and_( and_(
Document.search_space_id == search_space_id, Document.search_space_id == search_space_id,
Document.document_type.in_([ Document.document_type.in_(
[
DocumentType.GOOGLE_GMAIL_CONNECTOR, DocumentType.GOOGLE_GMAIL_CONNECTOR,
DocumentType.COMPOSIO_GMAIL_CONNECTOR, DocumentType.COMPOSIO_GMAIL_CONNECTOR,
]), ]
),
SearchSourceConnector.user_id == user_id, SearchSourceConnector.user_id == user_id,
or_( or_(
func.lower( func.lower(cast(Document.document_metadata["subject"], String))
cast(Document.document_metadata["subject"], String)
)
== func.lower(email_ref), == func.lower(email_ref),
func.lower(Document.title) == 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.future import select
from sqlalchemy.orm.attributes import flag_modified 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.services.llm_service import get_user_long_context_llm
from app.utils.document_converters import ( from app.utils.document_converters import (
create_document_chunks, create_document_chunks,
@ -107,7 +112,9 @@ class GoogleCalendarKBSyncService:
) )
else: else:
logger.warning("No LLM configured -- using fallback summary") 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) summary_embedding = embed_text(summary_content)
chunks = await create_document_chunks(indexable_content) chunks = await create_document_chunks(indexable_content)
@ -201,12 +208,16 @@ class GoogleCalendarKBSyncService:
None, lambda: build("calendar", "v3", credentials=creds) 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( live_event = await loop.run_in_executor(
None, None,
lambda: service.events() lambda: (
service.events()
.get(calendarId=calendar_id, eventId=event_id) .get(calendarId=calendar_id, eventId=event_id)
.execute(), .execute()
),
) )
event_summary = live_event.get("summary", "") event_summary = live_event.get("summary", "")
@ -220,7 +231,10 @@ class GoogleCalendarKBSyncService:
end_time = end_data.get("dateTime", end_data.get("date", "")) end_time = end_data.get("dateTime", end_data.get("date", ""))
attendees = [ attendees = [
{"email": a.get("email", ""), "responseStatus": a.get("responseStatus", "")} {
"email": a.get("email", ""),
"responseStatus": a.get("responseStatus", ""),
}
for a in live_event.get("attendees", []) for a in live_event.get("attendees", [])
] ]
@ -252,7 +266,9 @@ class GoogleCalendarKBSyncService:
indexable_content, user_llm, doc_metadata_for_summary indexable_content, user_llm, doc_metadata_for_summary
) )
else: 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) summary_embedding = embed_text(summary_content)
chunks = await create_document_chunks(indexable_content) chunks = await create_document_chunks(indexable_content)
@ -313,7 +329,10 @@ class GoogleCalendarKBSyncService:
if not connector: if not connector:
raise ValueError(f"Connector {connector_id} not found") 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") cca_id = connector.config.get("composio_connected_account_id")
if cca_id: if cca_id:
return build_composio_credentials(cca_id) return build_composio_credentials(cca_id)
@ -328,11 +347,17 @@ class GoogleCalendarKBSyncService:
if token_encrypted and app_config.SECRET_KEY: if token_encrypted and app_config.SECRET_KEY:
token_encryption = TokenEncryption(app_config.SECRET_KEY) token_encryption = TokenEncryption(app_config.SECRET_KEY)
if config_data.get("token"): 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"): 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"): 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", "") exp = config_data.get("expiry", "")
if exp: if exp:

View file

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

View file

@ -56,7 +56,9 @@ class GoogleDriveKBSyncService:
indexable_content = (content or "").strip() indexable_content = (content or "").strip()
if not indexable_content: 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) content_hash = generate_content_hash(indexable_content, search_space_id)
@ -93,7 +95,9 @@ class GoogleDriveKBSyncService:
) )
else: else:
logger.warning("No LLM configured — using fallback summary") 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) summary_embedding = embed_text(summary_content)
chunks = await create_document_chunks(indexable_content) chunks = await create_document_chunks(indexable_content)

View file

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

View file

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

View file

@ -98,9 +98,7 @@ class JiraToolMetadataService:
await asyncio.to_thread(jira_client.get_myself) await asyncio.to_thread(jira_client.get_myself)
return False return False
except Exception as e: except Exception as e:
logger.warning( logger.warning("Jira connector %s health check failed: %s", connector.id, e)
"Jira connector %s health check failed: %s", connector.id, e
)
try: try:
connector.config = {**connector.config, "auth_expired": True} connector.config = {**connector.config, "auth_expired": True}
flag_modified(connector, "config") flag_modified(connector, "config")
@ -165,7 +163,8 @@ class JiraToolMetadataService:
except Exception as e: except Exception as e:
logger.warning( logger.warning(
"Failed to fetch Jira context for connector %s: %s", "Failed to fetch Jira context for connector %s: %s",
connector.id, e, connector.id,
e,
) )
return { return {
@ -209,13 +208,15 @@ class JiraToolMetadataService:
session=self._db_session, connector_id=connector.id session=self._db_session, connector_id=connector.id
) )
jira_client = await jira_history._get_jira_client() jira_client = await jira_history._get_jira_client()
issue_data = await asyncio.to_thread( issue_data = await asyncio.to_thread(jira_client.get_issue, issue.issue_id)
jira_client.get_issue, issue.issue_id
)
formatted = jira_client.format_issue(issue_data) formatted = jira_client.format_issue(issue_data)
except Exception as e: except Exception as e:
error_str = str(e).lower() 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 { return {
"error": f"Failed to fetch Jira issue: {e!s}", "error": f"Failed to fetch Jira issue: {e!s}",
"auth_expired": True, "auth_expired": True,

View file

@ -66,7 +66,9 @@ class LinearKBSyncService:
if not indexable_content: if not indexable_content:
indexable_content = f"Linear Issue {issue_identifier}: {issue_title}" 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) 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) issue_api = await self._fetch_issue_context(linear_client, issue.id)
except Exception as e: except Exception as e:
error_str = str(e).lower() 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 { return {
"error": f"Failed to fetch Linear issue context: {e!s}", "error": f"Failed to fetch Linear issue context: {e!s}",
"auth_expired": True, "auth_expired": True,

View file

@ -102,7 +102,10 @@ class NotionToolMetadataService:
) )
db_connector = result.scalar_one_or_none() db_connector = result.scalar_one_or_none()
if db_connector and not db_connector.config.get("auth_expired"): 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") flag_modified(db_connector, "config")
await self._db_session.commit() await self._db_session.commit()
await self._db_session.refresh(db_connector) 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 # Build credentials based on connector type
if connector.connector_type in COMPOSIO_GOOGLE_CONNECTOR_TYPES: if connector.connector_type in COMPOSIO_GOOGLE_CONNECTOR_TYPES:
connected_account_id = connector.config.get( connected_account_id = connector.config.get("composio_connected_account_id")
"composio_connected_account_id"
)
if not connected_account_id: if not connected_account_id:
await task_logger.log_task_failure( await task_logger.log_task_failure(
log_entry, log_entry,
@ -396,10 +394,19 @@ async def index_google_calendar_events(
session, legacy_hash session, legacy_hash
) )
if existing_document: if existing_document:
existing_document.unique_identifier_hash = unique_identifier_hash existing_document.unique_identifier_hash = (
if existing_document.document_type == DocumentType.COMPOSIO_GOOGLE_CALENDAR_CONNECTOR: unique_identifier_hash
existing_document.document_type = DocumentType.GOOGLE_CALENDAR_CONNECTOR )
logger.info(f"Migrated legacy Composio Calendar document: {event_id}") 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: if existing_document:
# Document exists - check if content has changed # 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 # Build credentials based on connector type
pre_built_credentials = None pre_built_credentials = None
if connector.connector_type in COMPOSIO_GOOGLE_CONNECTOR_TYPES: if connector.connector_type in COMPOSIO_GOOGLE_CONNECTOR_TYPES:
connected_account_id = connector.config.get( connected_account_id = connector.config.get("composio_connected_account_id")
"composio_connected_account_id"
)
if not connected_account_id: if not connected_account_id:
error_msg = f"Composio connected_account_id not found for connector {connector_id}" error_msg = f"Composio connected_account_id not found for connector {connector_id}"
await task_logger.log_task_failure( await task_logger.log_task_failure(
log_entry, error_msg, "Missing Composio account", log_entry,
error_msg,
"Missing Composio account",
{"error_type": "MissingComposioAccount"}, {"error_type": "MissingComposioAccount"},
) )
return 0, 0, error_msg return 0, 0, error_msg
@ -355,13 +355,13 @@ async def index_google_drive_single_file(
pre_built_credentials = None pre_built_credentials = None
if connector.connector_type in COMPOSIO_GOOGLE_CONNECTOR_TYPES: if connector.connector_type in COMPOSIO_GOOGLE_CONNECTOR_TYPES:
connected_account_id = connector.config.get( connected_account_id = connector.config.get("composio_connected_account_id")
"composio_connected_account_id"
)
if not connected_account_id: if not connected_account_id:
error_msg = f"Composio connected_account_id not found for connector {connector_id}" error_msg = f"Composio connected_account_id not found for connector {connector_id}"
await task_logger.log_task_failure( await task_logger.log_task_failure(
log_entry, error_msg, "Missing Composio account", log_entry,
error_msg,
"Missing Composio account",
{"error_type": "MissingComposioAccount"}, {"error_type": "MissingComposioAccount"},
) )
return 0, error_msg return 0, error_msg
@ -611,7 +611,11 @@ async def _index_full_scan(
if not files_to_process and first_listing_error: if not files_to_process and first_listing_error:
error_lower = first_listing_error.lower() 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( raise Exception(
f"Google Drive authentication failed. Please re-authenticate. " f"Google Drive authentication failed. Please re-authenticate. "
f"(Error: {first_listing_error})" f"(Error: {first_listing_error})"
@ -704,7 +708,11 @@ async def _index_with_delta_sync(
if error: if error:
logger.error(f"Error fetching changes: {error}") logger.error(f"Error fetching changes: {error}")
error_lower = error.lower() 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( raise Exception(
f"Google Drive authentication failed. Please re-authenticate. " f"Google Drive authentication failed. Please re-authenticate. "
f"(Error: {error})" f"(Error: {error})"
@ -872,7 +880,10 @@ async def _create_pending_document_for_file(
) )
if existing_document: if existing_document:
existing_document.unique_identifier_hash = unique_identifier_hash 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 existing_document.document_type = DocumentType.GOOGLE_DRIVE_FILE
logger.info(f"Migrated legacy Composio document to native type: {file_id}") 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( result = await session.execute(
select(Document).where( select(Document).where(
Document.search_space_id == search_space_id, Document.search_space_id == search_space_id,
Document.document_type.in_([ Document.document_type.in_(
[
DocumentType.GOOGLE_DRIVE_FILE, DocumentType.GOOGLE_DRIVE_FILE,
DocumentType.COMPOSIO_GOOGLE_DRIVE_CONNECTOR, DocumentType.COMPOSIO_GOOGLE_DRIVE_CONNECTOR,
]), ]
),
cast(Document.document_metadata["google_drive_file_id"], String) cast(Document.document_metadata["google_drive_file_id"], String)
== file_id, == file_id,
) )
@ -1000,7 +1013,10 @@ async def _check_rename_only_update(
if existing_document: if existing_document:
if existing_document.unique_identifier_hash != primary_hash: if existing_document.unique_identifier_hash != primary_hash:
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 existing_document.document_type = DocumentType.GOOGLE_DRIVE_FILE
logger.info(f"Migrated legacy Composio Drive document: {file_id}") 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( result = await session.execute(
select(Document).where( select(Document).where(
Document.search_space_id == search_space_id, Document.search_space_id == search_space_id,
Document.document_type.in_([ Document.document_type.in_(
[
DocumentType.GOOGLE_DRIVE_FILE, DocumentType.GOOGLE_DRIVE_FILE,
DocumentType.COMPOSIO_GOOGLE_DRIVE_CONNECTOR, DocumentType.COMPOSIO_GOOGLE_DRIVE_CONNECTOR,
]), ]
),
cast(Document.document_metadata["google_drive_file_id"], String) cast(Document.document_metadata["google_drive_file_id"], String)
== file_id, == file_id,
) )

View file

@ -119,9 +119,7 @@ async def index_google_gmail_messages(
# Build credentials based on connector type # Build credentials based on connector type
if connector.connector_type in COMPOSIO_GOOGLE_CONNECTOR_TYPES: if connector.connector_type in COMPOSIO_GOOGLE_CONNECTOR_TYPES:
connected_account_id = connector.config.get( connected_account_id = connector.config.get("composio_connected_account_id")
"composio_connected_account_id"
)
if not connected_account_id: if not connected_account_id:
await task_logger.log_task_failure( await task_logger.log_task_failure(
log_entry, log_entry,
@ -323,10 +321,19 @@ async def index_google_gmail_messages(
session, legacy_hash session, legacy_hash
) )
if existing_document: if existing_document:
existing_document.unique_identifier_hash = unique_identifier_hash existing_document.unique_identifier_hash = (
if existing_document.document_type == DocumentType.COMPOSIO_GMAIL_CONNECTOR: unique_identifier_hash
existing_document.document_type = DocumentType.GOOGLE_GMAIL_CONNECTOR )
logger.info(f"Migrated legacy Composio Gmail document: {message_id}") 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: if existing_document:
# Document exists - check if content has changed # 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) print("Error deleting temp file", e)
pass 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( 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, enable_summary=enable_summary,
) )
@ -1414,7 +1421,9 @@ async def process_file_in_background(
# Extract text content from the markdown documents # Extract text content from the markdown documents
markdown_content = doc.text 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( doc_result = await add_received_file_document_using_llamacloud(
session, session,
filename, filename,
@ -1569,7 +1578,9 @@ async def process_file_in_background(
session, notification, stage="chunking" 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( doc_result = await add_received_file_document_using_docling(
session, session,
filename, filename,

View file

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

View file

@ -14,7 +14,12 @@ import pytest_asyncio
from app.db import SearchSourceConnectorType 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 pytestmark = pytest.mark.integration
@ -52,8 +57,10 @@ async def native_calendar(async_engine):
async_engine, async_engine,
connector_type=SearchSourceConnectorType.GOOGLE_CALENDAR_CONNECTOR, connector_type=SearchSourceConnectorType.GOOGLE_CALENDAR_CONNECTOR,
config={ config={
"token": "fake", "refresh_token": "fake", "token": "fake",
"client_id": "fake", "client_secret": "fake", "refresh_token": "fake",
"client_id": "fake",
"client_secret": "fake",
"token_uri": "https://oauth2.googleapis.com/token", "token_uri": "https://oauth2.googleapis.com/token",
}, },
name_prefix="cal-native", name_prefix="cal-native",
@ -66,10 +73,16 @@ async def native_calendar(async_engine):
@patch(f"{_INDEXER_MODULE}.GoogleCalendarConnector") @patch(f"{_INDEXER_MODULE}.GoogleCalendarConnector")
@patch(f"{_INDEXER_MODULE}.build_composio_credentials") @patch(f"{_INDEXER_MODULE}.build_composio_credentials")
async def test_composio_calendar_uses_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.""" """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 data = composio_calendar
mock_creds = MagicMock(name="composio-creds") 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_tl_cls.return_value = mock_task_logger()
mock_cal_instance = MagicMock() 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 mock_cal_cls.return_value = mock_cal_instance
maker = make_session_factory(async_engine) maker = make_session_factory(async_engine)
async with maker() as session: async with maker() as session:
await index_google_calendar_events( await index_google_calendar_events(
session=session, connector_id=data["connector_id"], session=session,
search_space_id=data["search_space_id"], user_id=data["user_id"], 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) 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}.TaskLoggingService")
@patch(f"{_INDEXER_MODULE}.build_composio_credentials") @patch(f"{_INDEXER_MODULE}.build_composio_credentials")
async def test_composio_calendar_without_account_id_returns_error( 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.""" """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 data = composio_calendar_no_id
mock_tl_cls.return_value = mock_task_logger() 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) maker = make_session_factory(async_engine)
async with maker() as session: async with maker() as session:
count, _skipped, error = await index_google_calendar_events( count, _skipped, error = await index_google_calendar_events(
session=session, connector_id=data["connector_id"], session=session,
search_space_id=data["search_space_id"], user_id=data["user_id"], connector_id=data["connector_id"],
search_space_id=data["search_space_id"],
user_id=data["user_id"],
) )
assert count == 0 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}.GoogleCalendarConnector")
@patch(f"{_INDEXER_MODULE}.build_composio_credentials") @patch(f"{_INDEXER_MODULE}.build_composio_credentials")
async def test_native_calendar_does_not_use_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.""" """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 data = native_calendar
mock_tl_cls.return_value = mock_task_logger() mock_tl_cls.return_value = mock_task_logger()
mock_cal_instance = MagicMock() 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 mock_cal_cls.return_value = mock_cal_instance
maker = make_session_factory(async_engine) maker = make_session_factory(async_engine)
async with maker() as session: async with maker() as session:
await index_google_calendar_events( await index_google_calendar_events(
session=session, connector_id=data["connector_id"], session=session,
search_space_id=data["search_space_id"], user_id=data["user_id"], connector_id=data["connector_id"],
search_space_id=data["search_space_id"],
user_id=data["user_id"],
) )
mock_build_creds.assert_not_called() mock_build_creds.assert_not_called()

View file

@ -14,7 +14,12 @@ import pytest_asyncio
from app.db import SearchSourceConnectorType 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 pytestmark = pytest.mark.integration
@ -129,7 +134,9 @@ async def test_composio_connector_without_account_id_returns_error(
assert count == 0 assert count == 0
assert error is not None 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() mock_build_creds.assert_not_called()

View file

@ -14,7 +14,12 @@ import pytest_asyncio
from app.db import SearchSourceConnectorType 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 pytestmark = pytest.mark.integration
@ -52,8 +57,10 @@ async def native_gmail(async_engine):
async_engine, async_engine,
connector_type=SearchSourceConnectorType.GOOGLE_GMAIL_CONNECTOR, connector_type=SearchSourceConnectorType.GOOGLE_GMAIL_CONNECTOR,
config={ config={
"token": "fake", "refresh_token": "fake", "token": "fake",
"client_id": "fake", "client_secret": "fake", "refresh_token": "fake",
"client_id": "fake",
"client_secret": "fake",
"token_uri": "https://oauth2.googleapis.com/token", "token_uri": "https://oauth2.googleapis.com/token",
}, },
name_prefix="gmail-native", name_prefix="gmail-native",
@ -66,10 +73,16 @@ async def native_gmail(async_engine):
@patch(f"{_INDEXER_MODULE}.GoogleGmailConnector") @patch(f"{_INDEXER_MODULE}.GoogleGmailConnector")
@patch(f"{_INDEXER_MODULE}.build_composio_credentials") @patch(f"{_INDEXER_MODULE}.build_composio_credentials")
async def test_composio_gmail_uses_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.""" """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 data = composio_gmail
mock_creds = MagicMock(name="composio-creds") mock_creds = MagicMock(name="composio-creds")
@ -83,8 +96,10 @@ async def test_composio_gmail_uses_composio_credentials(
maker = make_session_factory(async_engine) maker = make_session_factory(async_engine)
async with maker() as session: async with maker() as session:
await index_google_gmail_messages( await index_google_gmail_messages(
session=session, connector_id=data["connector_id"], session=session,
search_space_id=data["search_space_id"], user_id=data["user_id"], 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) 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}.TaskLoggingService")
@patch(f"{_INDEXER_MODULE}.build_composio_credentials") @patch(f"{_INDEXER_MODULE}.build_composio_credentials")
async def test_composio_gmail_without_account_id_returns_error( 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.""" """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 data = composio_gmail_no_id
mock_tl_cls.return_value = mock_task_logger() 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) maker = make_session_factory(async_engine)
async with maker() as session: async with maker() as session:
count, _skipped, error = await index_google_gmail_messages( count, _skipped, error = await index_google_gmail_messages(
session=session, connector_id=data["connector_id"], session=session,
search_space_id=data["search_space_id"], user_id=data["user_id"], connector_id=data["connector_id"],
search_space_id=data["search_space_id"],
user_id=data["user_id"],
) )
assert count == 0 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}.GoogleGmailConnector")
@patch(f"{_INDEXER_MODULE}.build_composio_credentials") @patch(f"{_INDEXER_MODULE}.build_composio_credentials")
async def test_native_gmail_does_not_use_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.""" """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 data = native_gmail
mock_tl_cls.return_value = mock_task_logger() 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) maker = make_session_factory(async_engine)
async with maker() as session: async with maker() as session:
await index_google_gmail_messages( await index_google_gmail_messages(
session=session, connector_id=data["connector_id"], session=session,
search_space_id=data["search_space_id"], user_id=data["user_id"], connector_id=data["connector_id"],
search_space_id=data["search_space_id"],
user_id=data["user_id"],
) )
mock_build_creds.assert_not_called() 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 assert "FILE" not in returned_types
async def test_single_string_type_returns_only_that_type( async def test_single_string_type_returns_only_that_type(db_session, seed_google_docs):
db_session, seed_google_docs
):
"""Searching with a single string type returns only documents of that exact type.""" """Searching with a single string type returns only documents of that exact type."""
space_id = seed_google_docs["search_space"].id 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 mock_build.return_value = mock_service
connector = GoogleGmailConnector( connector = GoogleGmailConnector(
creds, session=MagicMock(), user_id="test-user", creds,
session=MagicMock(),
user_id="test-user",
) )
profile, error = await connector.get_user_profile() 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.Request")
@patch("app.connectors.google_gmail_connector.build") @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 """GoogleGmailConnector handles expired Composio credentials via refresh_handler
without attempting DB persistence.""" without attempting DB persistence."""
from app.connectors.google_gmail_connector import GoogleGmailConnector 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() mock_session = AsyncMock()
connector = GoogleGmailConnector( 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() 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 mock_build.return_value = mock_service
connector = GoogleCalendarConnector( connector = GoogleCalendarConnector(
creds, session=MagicMock(), user_id="test-user", creds,
session=MagicMock(),
user_id="test-user",
) )
calendars, error = await connector.get_calendars() 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.Request")
@patch("app.connectors.google_calendar_connector.build") @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 """GoogleCalendarConnector handles expired Composio credentials via refresh_handler
without attempting DB persistence.""" without attempting DB persistence."""
from app.connectors.google_calendar_connector import GoogleCalendarConnector 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() mock_session = AsyncMock()
connector = GoogleCalendarConnector( connector = GoogleCalendarConnector(
creds, session=mock_session, user_id="test-user", creds,
session=mock_session,
user_id="test-user",
) )
calendars, error = await connector.get_calendars() 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 mock_build.return_value = mock_service
client = GoogleDriveClient( client = GoogleDriveClient(
session=MagicMock(), connector_id=999, credentials=creds, session=MagicMock(),
connector_id=999,
credentials=creds,
) )
files, next_token, error = await client.list_files() 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 mock_build.return_value = mock_service
client = GoogleDriveClient( client = GoogleDriveClient(
session=MagicMock(), connector_id=999, credentials=creds, session=MagicMock(),
connector_id=999,
credentials=creds,
) )
await client.list_files() await client.list_files()

View file

@ -20,8 +20,14 @@ def test_drive_indexer_accepts_both_native_and_composio():
ACCEPTED_DRIVE_CONNECTOR_TYPES, ACCEPTED_DRIVE_CONNECTOR_TYPES,
) )
assert SearchSourceConnectorType.GOOGLE_DRIVE_CONNECTOR in ACCEPTED_DRIVE_CONNECTOR_TYPES assert (
assert SearchSourceConnectorType.COMPOSIO_GOOGLE_DRIVE_CONNECTOR in ACCEPTED_DRIVE_CONNECTOR_TYPES 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(): 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, ACCEPTED_GMAIL_CONNECTOR_TYPES,
) )
assert SearchSourceConnectorType.GOOGLE_GMAIL_CONNECTOR in ACCEPTED_GMAIL_CONNECTOR_TYPES assert (
assert SearchSourceConnectorType.COMPOSIO_GMAIL_CONNECTOR in ACCEPTED_GMAIL_CONNECTOR_TYPES 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(): 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, ACCEPTED_CALENDAR_CONNECTOR_TYPES,
) )
assert SearchSourceConnectorType.GOOGLE_CALENDAR_CONNECTOR in ACCEPTED_CALENDAR_CONNECTOR_TYPES assert (
assert SearchSourceConnectorType.COMPOSIO_GOOGLE_CALENDAR_CONNECTOR in ACCEPTED_CALENDAR_CONNECTOR_TYPES 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(): def test_composio_connector_types_set_covers_all_google_services():
"""COMPOSIO_GOOGLE_CONNECTOR_TYPES should contain all three Composio Google types.""" """COMPOSIO_GOOGLE_CONNECTOR_TYPES should contain all three Composio Google types."""
from app.utils.google_credentials import COMPOSIO_GOOGLE_CONNECTOR_TYPES from app.utils.google_credentials import COMPOSIO_GOOGLE_CONNECTOR_TYPES
assert SearchSourceConnectorType.COMPOSIO_GOOGLE_DRIVE_CONNECTOR in COMPOSIO_GOOGLE_CONNECTOR_TYPES assert (
assert SearchSourceConnectorType.COMPOSIO_GMAIL_CONNECTOR in COMPOSIO_GOOGLE_CONNECTOR_TYPES SearchSourceConnectorType.COMPOSIO_GOOGLE_DRIVE_CONNECTOR
assert SearchSourceConnectorType.COMPOSIO_GOOGLE_CALENDAR_CONNECTOR in COMPOSIO_GOOGLE_CONNECTOR_TYPES 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} 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" 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 ? ( {isLoggingIn ? <Spinner size="sm" className="text-white" /> : t("sign_in")}
<Spinner size="sm" className="text-white" />
) : (
t("sign_in")
)}
</button> </button>
</form> </form>

View file

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

View file

@ -1,5 +1,6 @@
"use client"; "use client";
import { useAtomValue, useSetAtom } from "jotai";
import { import {
AlertCircle, AlertCircle,
CheckCircle2, CheckCircle2,
@ -16,12 +17,11 @@ import {
Trash2, Trash2,
User, User,
} from "lucide-react"; } from "lucide-react";
import { useAtomValue, useSetAtom } from "jotai";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; 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 { 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 { useDocumentUploadDialog } from "@/components/assistant-ui/document-upload-popup";
import { JsonMetadataViewer } from "@/components/json-metadata-viewer"; import { JsonMetadataViewer } from "@/components/json-metadata-viewer";
import { MarkdownViewer } from "@/components/markdown-viewer"; import { MarkdownViewer } from "@/components/markdown-viewer";
@ -35,14 +35,9 @@ import {
AlertDialogHeader, AlertDialogHeader,
AlertDialogTitle, AlertDialogTitle,
} from "@/components/ui/alert-dialog"; } from "@/components/ui/alert-dialog";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox"; 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 { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { import {
Drawer, Drawer,
@ -51,7 +46,12 @@ import {
DrawerHeader, DrawerHeader,
DrawerTitle, DrawerTitle,
} from "@/components/ui/drawer"; } 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 { Skeleton } from "@/components/ui/skeleton";
import { Spinner } from "@/components/ui/spinner"; import { Spinner } from "@/components/ui/spinner";
import { import {

View file

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

View file

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

View file

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

View file

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

View file

@ -214,11 +214,7 @@ export const ConnectorIndicator = forwardRef<ConnectorIndicatorHandle, Connector
if (!searchSpaceId) return null; if (!searchSpaceId) return null;
return ( return (
<Dialog <Dialog open={isOpen} modal={false} onOpenChange={handleOpenChange}>
open={isOpen}
modal={false}
onOpenChange={handleOpenChange}
>
{showTrigger && ( {showTrigger && (
<TooltipIconButton <TooltipIconButton
data-joyride="connector-icon" data-joyride="connector-icon"
@ -354,7 +350,8 @@ export const ConnectorIndicator = forwardRef<ConnectorIndicatorHandle, Connector
onBack={handleBackFromEdit} onBack={handleBackFromEdit}
onQuickIndex={(() => { onQuickIndex={(() => {
const cfg = connectorConfig || editingConnector.config; 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"; editingConnector.connector_type === "COMPOSIO_GOOGLE_DRIVE_CONNECTOR";
const hasDriveItems = isDrive const hasDriveItems = isDrive
? ((cfg?.selected_folders as unknown[]) ?? []).length > 0 || ? ((cfg?.selected_folders as unknown[]) ?? []).length > 0 ||

View file

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

View file

@ -82,10 +82,7 @@ function getFileIconFromName(fileName: string, className: string = "size-3.5 shr
return <File className={`${className} text-gray-500`} />; return <File className={`${className} text-gray-500`} />;
} }
export const ComposioDriveConfig: FC<ConnectorConfigProps> = ({ export const ComposioDriveConfig: FC<ConnectorConfigProps> = ({ connector, onConfigChange }) => {
connector,
onConfigChange,
}) => {
const isIndexable = connector.config?.is_indexable as boolean; const isIndexable = connector.config?.is_indexable as boolean;
const existingFolders = const existingFolders =
@ -238,7 +235,8 @@ export const ComposioDriveConfig: FC<ConnectorConfigProps> = ({
{isAuthExpired && ( {isAuthExpired && (
<p className="text-xs text-amber-600 dark:text-amber-500"> <p className="text-xs text-amber-600 dark:text-amber-500">
Your Google Drive authentication has expired. Please re-authenticate using the button below. Your Google Drive authentication has expired. Please re-authenticate using the button
below.
</p> </p>
)} )}

View file

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

View file

@ -242,13 +242,12 @@ export const GoogleDriveConfig: FC<ConnectorConfigProps> = ({ connector, onConfi
{totalSelected > 0 ? "Change Selection" : "Select from Google Drive"} {totalSelected > 0 ? "Change Selection" : "Select from Google Drive"}
</Button> </Button>
{pickerError && !isAuthExpired && ( {pickerError && !isAuthExpired && <p className="text-xs text-destructive">{pickerError}</p>}
<p className="text-xs text-destructive">{pickerError}</p>
)}
{isAuthExpired && ( {isAuthExpired && (
<p className="text-xs text-amber-600 dark:text-amber-500"> <p className="text-xs text-amber-600 dark:text-amber-500">
Your Google Drive authentication has expired. Please re-authenticate using the button below. Your Google Drive authentication has expired. Please re-authenticate using the button
below.
</p> </p>
)} )}
</div> </div>

View file

@ -221,9 +221,7 @@ export const ConnectorEditView: FC<ConnectorEditViewProps> = ({
</div> </div>
</div> </div>
{/* Quick Index Button - hidden when auth is expired */} {/* Quick Index Button - hidden when auth is expired */}
{connector.is_indexable && {connector.is_indexable && onQuickIndex && !isAuthExpired && (
onQuickIndex &&
!isAuthExpired && (
<Button <Button
variant="secondary" variant="secondary"
size="sm" size="sm"

View file

@ -263,18 +263,12 @@ export const useConnectorDialog = () => {
if (result.connectorId) { if (result.connectorId) {
const connectorId = parseInt(result.connectorId, 10); const connectorId = parseInt(result.connectorId, 10);
newConnector = fetchResult.data.find( newConnector = fetchResult.data.find((c: SearchSourceConnector) => c.id === connectorId);
(c: SearchSourceConnector) => c.id === connectorId
);
if (newConnector) { if (newConnector) {
const connectorType = newConnector.connector_type; const connectorType = newConnector.connector_type;
oauthConnector = oauthConnector =
OAUTH_CONNECTORS.find( OAUTH_CONNECTORS.find((c) => c.connectorType === connectorType) ||
(c) => c.connectorType === connectorType COMPOSIO_CONNECTORS.find((c) => c.connectorType === connectorType);
) ||
COMPOSIO_CONNECTORS.find(
(c) => c.connectorType === connectorType
);
} }
} }
@ -285,8 +279,7 @@ export const useConnectorDialog = () => {
if (oauthConnector) { if (oauthConnector) {
const oauthType = oauthConnector.connectorType; const oauthType = oauthConnector.connectorType;
newConnector = fetchResult.data.find( newConnector = fetchResult.data.find(
(c: SearchSourceConnector) => (c: SearchSourceConnector) => c.connector_type === oauthType
c.connector_type === oauthType
); );
} }
} }

View file

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

View file

@ -32,7 +32,9 @@ export const ThinkingStepsDisplay: FC<{ steps: ThinkingStep[]; isThreadRunning?:
const inProgressStep = steps.find((s) => getEffectiveStatus(s) === "in_progress"); const inProgressStep = steps.find((s) => getEffectiveStatus(s) === "in_progress");
const allCompleted = 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; const isProcessing = isThreadRunning && !allCompleted;
// Auto-collapse when all tasks are completed // Auto-collapse when all tasks are completed

View file

@ -90,7 +90,11 @@ import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { getConnectorIcon } from "@/contracts/enums/connectorIcons"; 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 type { Document } from "@/contracts/types/document.types";
import { useBatchCommentsPreload } from "@/hooks/use-comments"; import { useBatchCommentsPreload } from "@/hooks/use-comments";
import { useCommentsElectric } from "@/hooks/use-comments-electric"; import { useCommentsElectric } from "@/hooks/use-comments-electric";
@ -735,7 +739,9 @@ const ComposerAction: FC<ComposerActionProps> = ({ isBlockedByOtherUser = false
</span> </span>
</div> </div>
<div className="overflow-y-auto pb-6" onScroll={handleToolsScroll}> <div className="overflow-y-auto pb-6" onScroll={handleToolsScroll}>
{groupedTools.filter((g) => !g.connectorIcon).map((group) => ( {groupedTools
.filter((g) => !g.connectorIcon)
.map((group) => (
<div key={group.label}> <div key={group.label}>
<div className="px-4 pt-3 pb-1 text-xs text-muted-foreground/80 font-medium select-none"> <div className="px-4 pt-3 pb-1 text-xs text-muted-foreground/80 font-medium select-none">
{group.label} {group.label}
@ -767,7 +773,9 @@ const ComposerAction: FC<ComposerActionProps> = ({ isBlockedByOtherUser = false
<div className="px-4 pt-3 pb-1 text-xs text-muted-foreground/80 font-medium select-none"> <div className="px-4 pt-3 pb-1 text-xs text-muted-foreground/80 font-medium select-none">
Connector Actions Connector Actions
</div> </div>
{groupedTools.filter((g) => g.connectorIcon).map((group) => { {groupedTools
.filter((g) => g.connectorIcon)
.map((group) => {
const iconKey = group.connectorIcon ?? ""; const iconKey = group.connectorIcon ?? "";
const iconInfo = CONNECTOR_TOOL_ICON_PATHS[iconKey]; const iconInfo = CONNECTOR_TOOL_ICON_PATHS[iconKey];
const toolNames = group.tools.map((t) => t.name); const toolNames = group.tools.map((t) => t.name);
@ -857,7 +865,9 @@ 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"})`, 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) => ( {groupedTools
.filter((g) => !g.connectorIcon)
.map((group) => (
<div key={group.label}> <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"> <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} {group.label}
@ -894,7 +904,9 @@ const ComposerAction: FC<ComposerActionProps> = ({ isBlockedByOtherUser = false
<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"> <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 Connector Actions
</div> </div>
{groupedTools.filter((g) => g.connectorIcon).map((group) => { {groupedTools
.filter((g) => g.connectorIcon)
.map((group) => {
const iconKey = group.connectorIcon ?? ""; const iconKey = group.connectorIcon ?? "";
const iconInfo = CONNECTOR_TOOL_ICON_PATHS[iconKey]; const iconInfo = CONNECTOR_TOOL_ICON_PATHS[iconKey];
const toolNames = group.tools.map((t) => t.name); const toolNames = group.tools.map((t) => t.name);
@ -928,7 +940,8 @@ const ComposerAction: FC<ComposerActionProps> = ({ isBlockedByOtherUser = false
<Tooltip key={group.label}> <Tooltip key={group.label}>
<TooltipTrigger asChild>{row}</TooltipTrigger> <TooltipTrigger asChild>{row}</TooltipTrigger>
<TooltipContent side="right" className="max-w-72 text-xs"> <TooltipContent side="right" className="max-w-72 text-xs">
{groupDef?.tooltip ?? group.tools.map((t) => t.description).join(" · ")} {groupDef?.tooltip ??
group.tools.map((t) => t.description).join(" · ")}
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
); );

View file

@ -78,14 +78,21 @@ export function ComposioDriveFolderTree({
}: ComposioDriveFolderTreeProps) { }: ComposioDriveFolderTreeProps) {
const [itemStates, setItemStates] = useState<Map<string, ItemTreeNode>>(new Map()); 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, connectorId,
}); });
useEffect(() => { useEffect(() => {
if (rootError && onAuthError) { if (rootError && onAuthError) {
const msg = rootError instanceof Error ? rootError.message : String(rootError); 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); onAuthError(msg);
} }
} }
@ -365,7 +372,9 @@ export function ComposioDriveFolderTree({
{!isLoadingRoot && rootError && ( {!isLoadingRoot && rootError && (
<div className="text-center text-xs sm:text-sm text-amber-600 dark:text-amber-500 py-4 sm:py-8"> <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") {(rootError instanceof Error ? rootError.message : String(rootError)).includes(
"authentication expired"
)
? "Google Drive authentication has expired. Please re-authenticate above." ? "Google Drive authentication has expired. Please re-authenticate above."
: "Failed to load Google Drive contents."} : "Failed to load Google Drive contents."}
</div> </div>

View file

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

View file

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

View file

@ -114,7 +114,13 @@ export function RightPanel({ documentsPanel }: RightPanelProps) {
} else if (effectiveTab === "report" && !reportOpen) { } else if (effectiveTab === "report" && !reportOpen) {
effectiveTab = editorOpen ? "editor" : "sources"; effectiveTab = editorOpen ? "editor" : "sources";
} else if (effectiveTab === "sources" && !documentsOpen) { } 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]; const targetWidth = PANEL_WIDTHS[effectiveTab];

View file

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

View file

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

View file

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

View file

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

View file

@ -3,9 +3,9 @@
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { KeyRound, User } from "lucide-react"; import { KeyRound, User } from "lucide-react";
import { useTranslations } from "next-intl"; 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 { ApiKeyContent } from "@/app/dashboard/[search_space_id]/user-settings/components/ApiKeyContent";
import { ProfileContent } from "@/app/dashboard/[search_space_id]/user-settings/components/ProfileContent"; 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"; import { SettingsDialog } from "@/components/settings/settings-dialog";
export function UserSettingsDialog() { export function UserSettingsDialog() {

View file

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

View file

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

View file

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

View file

@ -1,14 +1,13 @@
"use client"; "use client";
import { makeAssistantToolUI } from "@assistant-ui/react"; import { makeAssistantToolUI } from "@assistant-ui/react";
import { import { useSetAtom } from "jotai";
CornerDownLeftIcon, import { CornerDownLeftIcon, MailIcon, Pen, UserIcon, UsersIcon } from "lucide-react";
MailIcon,
Pen,
UserIcon,
UsersIcon,
} from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "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 { Button } from "@/components/ui/button";
import { import {
Select, Select,
@ -17,11 +16,6 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/components/ui/select"; } 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"; import { useHitlPhase } from "@/hooks/use-hitl-phase";
interface GmailAccount { interface GmailAccount {
@ -132,7 +126,11 @@ function ApprovalCard({
const [isPanelOpen, setIsPanelOpen] = useState(false); const [isPanelOpen, setIsPanelOpen] = useState(false);
const openHitlEditPanel = useSetAtom(openHitlEditPanelAtom); const openHitlEditPanel = useSetAtom(openHitlEditPanelAtom);
const [pendingEdits, setPendingEdits] = useState<{ 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); } | null>(null);
const accounts = interruptData.context?.accounts ?? []; 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(() => { useEffect(() => {
const handler = (e: KeyboardEvent) => { const handler = (e: KeyboardEvent) => {
@ -201,15 +210,16 @@ function ApprovalCard({
: "Send Email"} : "Send Email"}
</p> </p>
{phase === "processing" ? ( {phase === "processing" ? (
<TextShimmerLoader text={pendingEdits ? "Sending email with your changes" : "Sending email"} size="sm" /> <TextShimmerLoader
text={pendingEdits ? "Sending email with your changes" : "Sending email"}
size="sm"
/>
) : phase === "complete" ? ( ) : phase === "complete" ? (
<p className="text-xs text-muted-foreground mt-0.5"> <p className="text-xs text-muted-foreground mt-0.5">
{pendingEdits ? "Email sent with your changes" : "Email sent"} {pendingEdits ? "Email sent with your changes" : "Email sent"}
</p> </p>
) : phase === "rejected" ? ( ) : phase === "rejected" ? (
<p className="text-xs text-muted-foreground mt-0.5"> <p className="text-xs text-muted-foreground mt-0.5">Email sending was cancelled</p>
Email sending was cancelled
</p>
) : ( ) : (
<p className="text-xs text-muted-foreground mt-0.5"> <p className="text-xs text-muted-foreground mt-0.5">
Requires your approval to proceed Requires your approval to proceed
@ -225,13 +235,28 @@ function ApprovalCard({
onClick={() => { onClick={() => {
setIsPanelOpen(true); setIsPanelOpen(true);
const extraFields: ExtraField[] = [ 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: "to",
{ key: "bcc", label: "BCC", type: "emails", value: pendingEdits?.bcc ?? args.bcc ?? "" }, 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({ openHitlEditPanel({
title: pendingEdits?.subject ?? (args.subject ?? ""), title: pendingEdits?.subject ?? args.subject ?? "",
content: pendingEdits?.body ?? (args.body ?? ""), content: pendingEdits?.body ?? args.body ?? "",
toolName: "Send Email", toolName: "Send Email",
extraFields, extraFields,
onSave: (newTitle, newContent, extraFieldValues) => { onSave: (newTitle, newContent, extraFieldValues) => {
@ -320,7 +345,9 @@ function ApprovalCard({
<div className="px-5 pt-1"> <div className="px-5 pt-1">
{(pendingEdits?.subject ?? args.subject) != null && ( {(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 && ( {(pendingEdits?.body ?? args.body) != null && (
<div <div
@ -396,9 +423,7 @@ function AuthErrorCard({ result }: { result: AuthErrorResult }) {
return ( return (
<div className="my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30 select-none"> <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"> <div className="px-5 pt-5 pb-4">
<p className="text-sm font-semibold text-destructive"> <p className="text-sm font-semibold text-destructive">Gmail authentication expired</p>
Gmail authentication expired
</p>
</div> </div>
<div className="mx-5 h-px bg-border/50" /> <div className="mx-5 h-px bg-border/50" />
<div className="px-5 py-4"> <div className="px-5 py-4">

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,12 +1,12 @@
"use client"; "use client";
import { makeAssistantToolUI } from "@assistant-ui/react"; import { makeAssistantToolUI } from "@assistant-ui/react";
import { import { useSetAtom } from "jotai";
CornerDownLeftIcon, import { CornerDownLeftIcon, FileIcon, Pen } from "lucide-react";
FileIcon,
Pen,
} from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "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 { Button } from "@/components/ui/button";
import { import {
Select, Select,
@ -15,11 +15,7 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/components/ui/select"; } 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 { useHitlPhase } from "@/hooks/use-hitl-phase";
import { useSetAtom } from "jotai";
import { openHitlEditPanelAtom } from "@/atoms/chat/hitl-edit-panel.atom";
interface GoogleDriveAccount { interface GoogleDriveAccount {
id: number; id: number;
@ -139,8 +135,8 @@ function ApprovalCard({
const [pendingEdits, setPendingEdits] = useState<{ name: string; content: string } | null>(null); const [pendingEdits, setPendingEdits] = useState<{ name: string; content: string } | null>(null);
const accounts = interruptData.context?.accounts ?? []; const accounts = interruptData.context?.accounts ?? [];
const validAccounts = accounts.filter(a => !a.auth_expired); const validAccounts = accounts.filter((a) => !a.auth_expired);
const expiredAccounts = accounts.filter(a => a.auth_expired); const expiredAccounts = accounts.filter((a) => a.auth_expired);
const defaultAccountId = useMemo(() => { const defaultAccountId = useMemo(() => {
if (validAccounts.length === 1) return String(validAccounts[0].id); if (validAccounts.length === 1) return String(validAccounts[0].id);
@ -162,7 +158,8 @@ function ApprovalCard({
setParentFolderId("__root__"); 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 isNameValid = useMemo(() => {
const name = pendingEdits?.name ?? args.name; 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(() => { useEffect(() => {
const handler = (e: KeyboardEvent) => { const handler = (e: KeyboardEvent) => {
@ -219,15 +229,16 @@ function ApprovalCard({
: `Create ${fileTypeLabel}`} : `Create ${fileTypeLabel}`}
</p> </p>
{phase === "processing" ? ( {phase === "processing" ? (
<TextShimmerLoader text={pendingEdits ? "Creating file with your changes" : "Creating file"} size="sm" /> <TextShimmerLoader
text={pendingEdits ? "Creating file with your changes" : "Creating file"}
size="sm"
/>
) : phase === "complete" ? ( ) : phase === "complete" ? (
<p className="text-xs text-muted-foreground mt-0.5"> <p className="text-xs text-muted-foreground mt-0.5">
{pendingEdits ? "File created with your changes" : "File created"} {pendingEdits ? "File created with your changes" : "File created"}
</p> </p>
) : phase === "rejected" ? ( ) : phase === "rejected" ? (
<p className="text-xs text-muted-foreground mt-0.5"> <p className="text-xs text-muted-foreground mt-0.5">File creation was cancelled</p>
File creation was cancelled
</p>
) : ( ) : (
<p className="text-xs text-muted-foreground mt-0.5"> <p className="text-xs text-muted-foreground mt-0.5">
Requires your approval to proceed Requires your approval to proceed
@ -242,8 +253,8 @@ function ApprovalCard({
onClick={() => { onClick={() => {
setIsPanelOpen(true); setIsPanelOpen(true);
openHitlEditPanel({ openHitlEditPanel({
title: pendingEdits?.name ?? (args.name ?? ""), title: pendingEdits?.name ?? args.name ?? "",
content: pendingEdits?.content ?? (args.content ?? ""), content: pendingEdits?.content ?? args.content ?? "",
toolName: fileTypeLabel, toolName: fileTypeLabel,
onSave: (newName, newContent) => { onSave: (newName, newContent) => {
setIsPanelOpen(false); setIsPanelOpen(false);
@ -313,9 +324,7 @@ function ApprovalCard({
{selectedAccountId && ( {selectedAccountId && (
<div className="space-y-2"> <div className="space-y-2">
<p className="text-xs font-medium text-muted-foreground"> <p className="text-xs font-medium text-muted-foreground">Parent Folder</p>
Parent Folder
</p>
<Select value={parentFolderId} onValueChange={setParentFolderId}> <Select value={parentFolderId} onValueChange={setParentFolderId}>
<SelectTrigger className="w-full"> <SelectTrigger className="w-full">
<SelectValue placeholder="Drive Root" /> <SelectValue placeholder="Drive Root" />
@ -346,7 +355,9 @@ function ApprovalCard({
<div className="mx-5 h-px bg-border/50" /> <div className="mx-5 h-px bg-border/50" />
<div className="px-5 pt-3"> <div className="px-5 pt-3">
{(pendingEdits?.name ?? args.name) != null && ( {(pendingEdits?.name ?? args.name) != null && (
<p className="text-sm font-medium text-foreground">{String(pendingEdits?.name ?? args.name)}</p> <p className="text-sm font-medium text-foreground">
{String(pendingEdits?.name ?? args.name)}
</p>
)} )}
{(pendingEdits?.content ?? args.content) != null && ( {(pendingEdits?.content ?? args.content) != null && (
<div <div

View file

@ -1,14 +1,11 @@
"use client"; "use client";
import { makeAssistantToolUI } from "@assistant-ui/react"; import { makeAssistantToolUI } from "@assistant-ui/react";
import { import { CornerDownLeftIcon, InfoIcon } from "lucide-react";
CornerDownLeftIcon,
InfoIcon,
} from "lucide-react";
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { TextShimmerLoader } from "@/components/prompt-kit/loader";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox"; import { Checkbox } from "@/components/ui/checkbox";
import { TextShimmerLoader } from "@/components/prompt-kit/loader";
import { useHitlPhase } from "@/hooks/use-hitl-phase"; import { useHitlPhase } from "@/hooks/use-hitl-phase";
interface GoogleDriveAccount { interface GoogleDriveAccount {
@ -212,9 +209,7 @@ function ApprovalCard({
) : phase === "complete" ? ( ) : phase === "complete" ? (
<p className="text-xs text-muted-foreground mt-0.5">File trashed</p> <p className="text-xs text-muted-foreground mt-0.5">File trashed</p>
) : phase === "rejected" ? ( ) : phase === "rejected" ? (
<p className="text-xs text-muted-foreground mt-0.5"> <p className="text-xs text-muted-foreground mt-0.5">File deletion was cancelled</p>
File deletion was cancelled
</p>
) : ( ) : (
<p className="text-xs text-muted-foreground mt-0.5"> <p className="text-xs text-muted-foreground mt-0.5">
Requires your approval to proceed Requires your approval to proceed
@ -274,7 +269,8 @@ function ApprovalCard({
<div className="mx-5 h-px bg-border/50" /> <div className="mx-5 h-px bg-border/50" />
<div className="px-5 py-4 space-y-3 select-none"> <div className="px-5 py-4 space-y-3 select-none">
<p className="text-xs text-muted-foreground"> <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> </p>
<div className="flex items-center gap-2.5"> <div className="flex items-center gap-2.5">
<Checkbox <Checkbox
@ -299,11 +295,7 @@ function ApprovalCard({
<> <>
<div className="mx-5 h-px bg-border/50" /> <div className="mx-5 h-px bg-border/50" />
<div className="px-5 py-4 flex items-center gap-2 select-none"> <div className="px-5 py-4 flex items-center gap-2 select-none">
<Button <Button size="sm" className="rounded-lg gap-1.5" onClick={handleApprove}>
size="sm"
className="rounded-lg gap-1.5"
onClick={handleApprove}
>
Approve Approve
<CornerDownLeftIcon className="size-3 opacity-60" /> <CornerDownLeftIcon className="size-3 opacity-60" />
</Button> </Button>

View file

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

View file

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

View file

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

View file

@ -1,8 +1,12 @@
"use client"; "use client";
import { makeAssistantToolUI } from "@assistant-ui/react"; import { makeAssistantToolUI } from "@assistant-ui/react";
import { useSetAtom } from "jotai";
import { CornerDownLeftIcon, Pen } from "lucide-react"; import { CornerDownLeftIcon, Pen } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "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 { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
@ -13,11 +17,7 @@ import {
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; 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 { useHitlPhase } from "@/hooks/use-hitl-phase";
import { useSetAtom } from "jotai";
import { openHitlEditPanelAtom } from "@/atoms/chat/hitl-edit-panel.atom";
interface LinearLabel { interface LinearLabel {
id: string; id: string;
@ -148,7 +148,9 @@ function ApprovalCard({
const { phase, setProcessing, setRejected } = useHitlPhase(interruptData); const { phase, setProcessing, setRejected } = useHitlPhase(interruptData);
const [isPanelOpen, setIsPanelOpen] = useState(false); const [isPanelOpen, setIsPanelOpen] = useState(false);
const openHitlEditPanel = useSetAtom(openHitlEditPanelAtom); 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 [selectedWorkspaceId, setSelectedWorkspaceId] = useState("");
const [selectedTeamId, setSelectedTeamId] = useState(""); const [selectedTeamId, setSelectedTeamId] = useState("");
@ -178,10 +180,12 @@ function ApprovalCard({
const allowedDecisions = reviewConfig?.allowed_decisions ?? ["approve", "reject"]; const allowedDecisions = reviewConfig?.allowed_decisions ?? ["approve", "reject"];
const canEdit = allowedDecisions.includes("edit"); const canEdit = allowedDecisions.includes("edit");
const buildFinalArgs = useCallback((overrides?: { title?: string; description?: string }) => { const buildFinalArgs = useCallback(
(overrides?: { title?: string; description?: string }) => {
return { return {
title: overrides?.title ?? pendingEdits?.title ?? args.title, title: overrides?.title ?? pendingEdits?.title ?? args.title,
description: overrides?.description ?? pendingEdits?.description ?? args.description ?? null, description:
overrides?.description ?? pendingEdits?.description ?? args.description ?? null,
connector_id: selectedWorkspaceId ? Number(selectedWorkspaceId) : null, connector_id: selectedWorkspaceId ? Number(selectedWorkspaceId) : null,
team_id: selectedTeamId || null, team_id: selectedTeamId || null,
state_id: selectedStateId === "__none__" ? null : selectedStateId, state_id: selectedStateId === "__none__" ? null : selectedStateId,
@ -189,7 +193,19 @@ function ApprovalCard({
priority: Number(selectedPriority), priority: Number(selectedPriority),
label_ids: selectedLabelIds, label_ids: selectedLabelIds,
}; };
}, [args.title, args.description, selectedWorkspaceId, selectedTeamId, selectedStateId, selectedAssigneeId, selectedPriority, selectedLabelIds, pendingEdits]); },
[
args.title,
args.description,
selectedWorkspaceId,
selectedTeamId,
selectedStateId,
selectedAssigneeId,
selectedPriority,
selectedLabelIds,
pendingEdits,
]
);
const handleApprove = useCallback(() => { const handleApprove = useCallback(() => {
if (phase !== "pending") return; if (phase !== "pending") return;
@ -204,7 +220,17 @@ function ApprovalCard({
args: buildFinalArgs(), args: buildFinalArgs(),
}, },
}); });
}, [phase, setProcessing, isPanelOpen, canApprove, allowedDecisions, onDecision, interruptData, buildFinalArgs, pendingEdits]); }, [
phase,
setProcessing,
isPanelOpen,
canApprove,
allowedDecisions,
onDecision,
interruptData,
buildFinalArgs,
pendingEdits,
]);
useEffect(() => { useEffect(() => {
const handler = (e: KeyboardEvent) => { const handler = (e: KeyboardEvent) => {
@ -229,15 +255,16 @@ function ApprovalCard({
: "Create Linear Issue"} : "Create Linear Issue"}
</p> </p>
{phase === "processing" ? ( {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" ? ( ) : phase === "complete" ? (
<p className="text-xs text-muted-foreground mt-0.5"> <p className="text-xs text-muted-foreground mt-0.5">
{pendingEdits ? "Issue created with your changes" : "Issue created"} {pendingEdits ? "Issue created with your changes" : "Issue created"}
</p> </p>
) : phase === "rejected" ? ( ) : phase === "rejected" ? (
<p className="text-xs text-muted-foreground mt-0.5"> <p className="text-xs text-muted-foreground mt-0.5">Issue creation was cancelled</p>
Issue creation was cancelled
</p>
) : ( ) : (
<p className="text-xs text-muted-foreground mt-0.5"> <p className="text-xs text-muted-foreground mt-0.5">
Requires your approval to proceed Requires your approval to proceed
@ -252,8 +279,8 @@ function ApprovalCard({
onClick={() => { onClick={() => {
setIsPanelOpen(true); setIsPanelOpen(true);
openHitlEditPanel({ openHitlEditPanel({
title: pendingEdits?.title ?? (args.title ?? ""), title: pendingEdits?.title ?? args.title ?? "",
content: pendingEdits?.description ?? (args.description ?? ""), content: pendingEdits?.description ?? args.description ?? "",
toolName: "Linear Issue", toolName: "Linear Issue",
onSave: (newTitle, newDescription) => { onSave: (newTitle, newDescription) => {
setIsPanelOpen(false); setIsPanelOpen(false);
@ -366,7 +393,10 @@ function ApprovalCard({
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5"> <div className="space-y-1.5">
<p className="text-xs font-medium text-muted-foreground">Assignee</p> <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"> <SelectTrigger className="w-full">
<SelectValue placeholder="Unassigned" /> <SelectValue placeholder="Unassigned" />
</SelectTrigger> </SelectTrigger>
@ -520,9 +550,7 @@ function AuthErrorCard({ result }: { result: AuthErrorResult }) {
return ( return (
<div className="my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30 select-none"> <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"> <div className="px-5 pt-5 pb-4">
<p className="text-sm font-semibold text-destructive"> <p className="text-sm font-semibold text-destructive">All Linear accounts expired</p>
All Linear accounts expired
</p>
</div> </div>
<div className="mx-5 h-px bg-border/50" /> <div className="mx-5 h-px bg-border/50" />
<div className="px-5 py-4"> <div className="px-5 py-4">

View file

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

View file

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

View file

@ -4,6 +4,9 @@ import { makeAssistantToolUI } from "@assistant-ui/react";
import { useSetAtom } from "jotai"; import { useSetAtom } from "jotai";
import { CornerDownLeftIcon, Pen } from "lucide-react"; import { CornerDownLeftIcon, Pen } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "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 { Button } from "@/components/ui/button";
import { import {
Select, Select,
@ -12,9 +15,6 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import { PlateEditor } from "@/components/editor/plate-editor";
import { TextShimmerLoader } from "@/components/prompt-kit/loader";
import { openHitlEditPanelAtom } from "@/atoms/chat/hitl-edit-panel.atom";
import { useHitlPhase } from "@/hooks/use-hitl-phase"; import { useHitlPhase } from "@/hooks/use-hitl-phase";
interface InterruptResult { interface InterruptResult {
@ -123,8 +123,8 @@ function ApprovalCard({
const [pendingEdits, setPendingEdits] = useState<{ title: string; content: string } | null>(null); const [pendingEdits, setPendingEdits] = useState<{ title: string; content: string } | null>(null);
const accounts = interruptData.context?.accounts ?? []; const accounts = interruptData.context?.accounts ?? [];
const validAccounts = accounts.filter(a => !a.auth_expired); const validAccounts = accounts.filter((a) => !a.auth_expired);
const expiredAccounts = accounts.filter(a => a.auth_expired); const expiredAccounts = accounts.filter((a) => a.auth_expired);
const parentPages = interruptData.context?.parent_pages ?? {}; const parentPages = interruptData.context?.parent_pages ?? {};
const defaultAccountId = useMemo(() => { const defaultAccountId = useMemo(() => {
@ -166,12 +166,23 @@ function ApprovalCard({
...args, ...args,
...(pendingEdits && { title: pendingEdits.title, content: pendingEdits.content }), ...(pendingEdits && { title: pendingEdits.title, content: pendingEdits.content }),
connector_id: selectedAccountId ? Number(selectedAccountId) : null, connector_id: selectedAccountId ? Number(selectedAccountId) : null,
parent_page_id: parent_page_id: selectedParentPageId === "__none__" ? null : selectedParentPageId,
selectedParentPageId === "__none__" ? null : selectedParentPageId,
}, },
}, },
}); });
}, [phase, isPanelOpen, selectedAccountId, isTitleValid, allowedDecisions, setProcessing, onDecision, interruptData, args, selectedParentPageId, pendingEdits]); }, [
phase,
isPanelOpen,
selectedAccountId,
isTitleValid,
allowedDecisions,
setProcessing,
onDecision,
interruptData,
args,
selectedParentPageId,
pendingEdits,
]);
useEffect(() => { useEffect(() => {
const handler = (e: KeyboardEvent) => { const handler = (e: KeyboardEvent) => {
@ -184,9 +195,7 @@ function ApprovalCard({
}, [handleApprove]); }, [handleApprove]);
return ( return (
<div <div className="my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30 transition-all duration-300">
className="my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30 transition-all duration-300"
>
{/* Header */} {/* Header */}
<div className="flex items-start justify-between px-5 pt-5 pb-4 select-none"> <div className="flex items-start justify-between px-5 pt-5 pb-4 select-none">
<div> <div>
@ -198,15 +207,16 @@ function ApprovalCard({
: "Create Notion Page"} : "Create Notion Page"}
</p> </p>
{phase === "processing" ? ( {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" ? ( ) : phase === "complete" ? (
<p className="text-xs text-muted-foreground mt-0.5"> <p className="text-xs text-muted-foreground mt-0.5">
{pendingEdits ? "Page created with your changes" : "Page created"} {pendingEdits ? "Page created with your changes" : "Page created"}
</p> </p>
) : phase === "rejected" ? ( ) : phase === "rejected" ? (
<p className="text-xs text-muted-foreground mt-0.5"> <p className="text-xs text-muted-foreground mt-0.5">Page creation was cancelled</p>
Page creation was cancelled
</p>
) : ( ) : (
<p className="text-xs text-muted-foreground mt-0.5"> <p className="text-xs text-muted-foreground mt-0.5">
Requires your approval to proceed Requires your approval to proceed
@ -316,7 +326,9 @@ function ApprovalCard({
<div className="mx-5 h-px bg-border/50" /> <div className="mx-5 h-px bg-border/50" />
<div className="px-5 pt-3"> <div className="px-5 pt-3">
{(pendingEdits?.title ?? args.title) != null && ( {(pendingEdits?.title ?? args.title) != null && (
<p className="text-sm font-medium text-foreground">{String(pendingEdits?.title ?? args.title)}</p> <p className="text-sm font-medium text-foreground">
{String(pendingEdits?.title ?? args.title)}
</p>
)} )}
{(pendingEdits?.content ?? args.content) != null && ( {(pendingEdits?.content ?? args.content) != null && (
<div <div
@ -378,9 +390,7 @@ function AuthErrorCard({ result }: { result: AuthErrorResult }) {
return ( return (
<div className="my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30 select-none"> <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"> <div className="px-5 pt-5 pb-4">
<p className="text-sm font-semibold text-destructive"> <p className="text-sm font-semibold text-destructive">Notion authentication expired</p>
Notion authentication expired
</p>
</div> </div>
<div className="mx-5 h-px bg-border/50" /> <div className="mx-5 h-px bg-border/50" />
<div className="px-5 py-4"> <div className="px-5 py-4">

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