Merge remote-tracking branch 'upstream/dev' into fix/ui-mcp

This commit is contained in:
Anish Sarkar 2026-01-20 11:39:45 +05:30
commit a536ad1590
64 changed files with 4297 additions and 401 deletions

View file

@ -3,17 +3,8 @@ name: Build and Push Docker Image
on:
workflow_dispatch:
inputs:
bump_type:
description: 'Version bump type (patch, minor, major)'
required: true
default: 'patch'
type: choice
options:
- patch
- minor
- major
branch:
description: 'Branch to tag (leave empty for default branch)'
description: 'Branch to build from (leave empty for default branch)'
required: false
default: ''
@ -34,55 +25,37 @@ jobs:
ref: ${{ github.event.inputs.branch }}
token: ${{ secrets.GITHUB_TOKEN }}
- name: Get latest SemVer tag and calculate next version
- name: Read app version and calculate next Docker build version
id: tag_version
run: |
git fetch --tags
LATEST_TAG=$(git tag --list 'v[0-9]*.[0-9]*.[0-9]*' --sort='v:refname' | tail -n 1)
if [ -z "$LATEST_TAG" ]; then
echo "No previous SemVer tag found. Starting with v0.1.0"
case "${{ github.event.inputs.bump_type }}" in
patch|minor)
NEXT_VERSION="v0.1.0"
;;
major)
NEXT_VERSION="v1.0.0"
;;
*)
echo "Invalid bump type: ${{ github.event.inputs.bump_type }}"
exit 1
;;
esac
else
echo "Latest tag found: $LATEST_TAG"
VERSION=${LATEST_TAG#v}
MAJOR=$(echo $VERSION | cut -d. -f1)
MINOR=$(echo $VERSION | cut -d. -f2)
PATCH=$(echo $VERSION | cut -d. -f3)
case "${{ github.event.inputs.bump_type }}" in
patch)
PATCH=$((PATCH + 1))
;;
minor)
MINOR=$((MINOR + 1))
PATCH=0
;;
major)
MAJOR=$((MAJOR + 1))
MINOR=0
PATCH=0
;;
*)
echo "Invalid bump type: ${{ github.event.inputs.bump_type }}"
exit 1
;;
esac
NEXT_VERSION="v${MAJOR}.${MINOR}.${PATCH}"
# Read version from pyproject.toml
APP_VERSION=$(grep -E '^version = ' surfsense_backend/pyproject.toml | sed 's/version = "\(.*\)"/\1/')
echo "App version from pyproject.toml: $APP_VERSION"
if [ -z "$APP_VERSION" ]; then
echo "Error: Could not read version from surfsense_backend/pyproject.toml"
exit 1
fi
echo "Calculated next version: $NEXT_VERSION"
# Fetch all tags
git fetch --tags
# Find the latest docker build tag for this app version (format: APP_VERSION.BUILD_NUMBER)
# Tags follow pattern: 0.0.11.1, 0.0.11.2, etc.
LATEST_BUILD_TAG=$(git tag --list "${APP_VERSION}.*" --sort='-v:refname' | head -n 1)
if [ -z "$LATEST_BUILD_TAG" ]; then
echo "No previous Docker build tag found for version ${APP_VERSION}. Starting with ${APP_VERSION}.1"
NEXT_VERSION="${APP_VERSION}.1"
else
echo "Latest Docker build tag found: $LATEST_BUILD_TAG"
# Extract the build number (4th component)
BUILD_NUMBER=$(echo "$LATEST_BUILD_TAG" | rev | cut -d. -f1 | rev)
NEXT_BUILD=$((BUILD_NUMBER + 1))
NEXT_VERSION="${APP_VERSION}.${NEXT_BUILD}"
fi
echo "Calculated next Docker version: $NEXT_VERSION"
echo "next_version=$NEXT_VERSION" >> $GITHUB_OUTPUT
- name: Create and Push Tag
@ -94,7 +67,7 @@ jobs:
COMMIT_SHA=$(git rev-parse HEAD)
echo "Tagging commit $COMMIT_SHA with $NEXT_TAG"
git tag -a "$NEXT_TAG" -m "Release $NEXT_TAG"
git tag -a "$NEXT_TAG" -m "Docker build $NEXT_TAG"
echo "Pushing tag $NEXT_TAG to origin"
git push origin "$NEXT_TAG"

View file

@ -37,10 +37,5 @@ def upgrade() -> None:
def downgrade() -> None:
"""Remove author_id column from new_chat_messages table."""
op.execute(
"""
DROP INDEX IF EXISTS ix_new_chat_messages_author_id;
ALTER TABLE new_chat_messages
DROP COLUMN IF EXISTS author_id;
"""
)
op.execute("DROP INDEX IF EXISTS ix_new_chat_messages_author_id")
op.execute("ALTER TABLE new_chat_messages DROP COLUMN IF EXISTS author_id")

View file

@ -0,0 +1,52 @@
"""Add chat_comments table for comments on AI responses
Revision ID: 68
Revises: 67
"""
from collections.abc import Sequence
from alembic import op
revision: str = "68"
down_revision: str | None = "67"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
"""Create chat_comments table."""
op.execute(
"""
CREATE TABLE IF NOT EXISTS chat_comments (
id SERIAL PRIMARY KEY,
message_id INTEGER NOT NULL REFERENCES new_chat_messages(id) ON DELETE CASCADE,
parent_id INTEGER REFERENCES chat_comments(id) ON DELETE CASCADE,
author_id UUID REFERENCES "user"(id) ON DELETE SET NULL,
content TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
"""
)
op.execute(
"CREATE INDEX IF NOT EXISTS idx_chat_comments_message_id ON chat_comments(message_id)"
)
op.execute(
"CREATE INDEX IF NOT EXISTS idx_chat_comments_parent_id ON chat_comments(parent_id)"
)
op.execute(
"CREATE INDEX IF NOT EXISTS idx_chat_comments_author_id ON chat_comments(author_id)"
)
op.execute(
"CREATE INDEX IF NOT EXISTS idx_chat_comments_created_at ON chat_comments(created_at)"
)
def downgrade() -> None:
"""Drop chat_comments table."""
op.execute(
"""
DROP TABLE IF EXISTS chat_comments;
"""
)

View file

@ -0,0 +1,41 @@
"""Add chat_comment_mentions table for @mentions in comments
Revision ID: 69
Revises: 68
"""
from collections.abc import Sequence
from alembic import op
revision: str = "69"
down_revision: str | None = "68"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
"""Create chat_comment_mentions table."""
op.execute(
"""
CREATE TABLE IF NOT EXISTS chat_comment_mentions (
id SERIAL PRIMARY KEY,
comment_id INTEGER NOT NULL REFERENCES chat_comments(id) ON DELETE CASCADE,
mentioned_user_id UUID NOT NULL REFERENCES "user"(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (comment_id, mentioned_user_id)
)
"""
)
op.execute(
"CREATE INDEX IF NOT EXISTS idx_chat_comment_mentions_comment_id ON chat_comment_mentions(comment_id)"
)
def downgrade() -> None:
"""Drop chat_comment_mentions table."""
op.execute(
"""
DROP TABLE IF EXISTS chat_comment_mentions;
"""
)

View file

@ -0,0 +1,94 @@
"""Add comments permissions to existing roles
Revision ID: 70
Revises: 69
Create Date: 2024-01-16
"""
from sqlalchemy import text
from alembic import op
# revision identifiers, used by Alembic.
revision = "70"
down_revision = "69"
branch_labels = None
depends_on = None
def upgrade():
connection = op.get_bind()
# Add comments:create to Admin, Editor, Viewer roles (if not already present)
connection.execute(
text(
"""
UPDATE search_space_roles
SET permissions = array_append(permissions, 'comments:create')
WHERE name IN ('Admin', 'Editor', 'Viewer')
AND NOT ('comments:create' = ANY(permissions))
"""
)
)
# Add comments:read to Admin, Editor, Viewer roles (if not already present)
connection.execute(
text(
"""
UPDATE search_space_roles
SET permissions = array_append(permissions, 'comments:read')
WHERE name IN ('Admin', 'Editor', 'Viewer')
AND NOT ('comments:read' = ANY(permissions))
"""
)
)
# Add comments:delete to Admin roles only (if not already present)
connection.execute(
text(
"""
UPDATE search_space_roles
SET permissions = array_append(permissions, 'comments:delete')
WHERE name = 'Admin'
AND NOT ('comments:delete' = ANY(permissions))
"""
)
)
def downgrade():
connection = op.get_bind()
# Remove comments:create from Admin, Editor, Viewer roles
connection.execute(
text(
"""
UPDATE search_space_roles
SET permissions = array_remove(permissions, 'comments:create')
WHERE name IN ('Admin', 'Editor', 'Viewer')
"""
)
)
# Remove comments:read from Admin, Editor, Viewer roles
connection.execute(
text(
"""
UPDATE search_space_roles
SET permissions = array_remove(permissions, 'comments:read')
WHERE name IN ('Admin', 'Editor', 'Viewer')
"""
)
)
# Remove comments:delete from Admin roles only
connection.execute(
text(
"""
UPDATE search_space_roles
SET permissions = array_remove(permissions, 'comments:delete')
WHERE name = 'Admin'
"""
)
)

View file

@ -0,0 +1,59 @@
"""Add Electric SQL replication for chat_comment_mentions table
Revision ID: 71
Revises: 70
Enables Electric SQL replication for the chat_comment_mentions table to support
real-time live updates for mentions.
"""
from collections.abc import Sequence
from alembic import op
revision: str = "71"
down_revision: str | None = "70"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
"""Enable Electric SQL replication for chat_comment_mentions table."""
op.execute("ALTER TABLE chat_comment_mentions REPLICA IDENTITY FULL;")
op.execute(
"""
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_publication_tables
WHERE pubname = 'electric_publication_default'
AND tablename = 'chat_comment_mentions'
) THEN
ALTER PUBLICATION electric_publication_default ADD TABLE chat_comment_mentions;
END IF;
END
$$;
"""
)
def downgrade() -> None:
"""Remove chat_comment_mentions from Electric SQL replication."""
op.execute(
"""
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM pg_publication_tables
WHERE pubname = 'electric_publication_default'
AND tablename = 'chat_comment_mentions'
) THEN
ALTER PUBLICATION electric_publication_default DROP TABLE chat_comment_mentions;
END IF;
END
$$;
"""
)
op.execute("ALTER TABLE chat_comment_mentions REPLICA IDENTITY DEFAULT;")

View file

@ -116,47 +116,6 @@ You have access to the following tools:
* This makes your response more visual and engaging.
* Prioritize showing: diagrams, charts, infographics, key illustrations, or images that help explain the content.
* Don't show every image - just the most relevant 1-3 images that enhance understanding.
6. write_todos: Create and update a planning/todo list.
- Args:
- todos: List of todo items, each with:
* content: Description of the task (required)
* status: "pending", "in_progress", "completed", or "cancelled" (required)
STRICT MODE SELECTION - CHOOSE ONE:
[MODE A] AGENT PLAN (you will work through it)
Use when: User asks you to explain, teach, plan, or break down a concept.
Examples: "Explain how to set up Python", "Plan my trip", "Break down machine learning"
Rules:
- Create plan with first item "in_progress", rest "pending"
- After explaining each step, call write_todos again to update progress
- Only ONE item "in_progress" at a time
- Mark items "completed" as you finish explaining them
- Final call: all items "completed"
[MODE B] EXTERNAL TASK DISPLAY (from connectors - you CANNOT complete these)
Use when: User asks to show/list/display tasks from Linear, Jira, ClickUp, GitHub, Airtable, Notion, or any connector.
Examples: "Show my Linear tasks", "List Jira tickets", "Create todos from ClickUp", "Show GitHub issues"
STRICT RULES:
1. You CANNOT complete these tasks - only the user can in the actual tool
2. PRESERVE original status from source - DO NOT use agent workflow
3. Call write_todos ONCE with all tasks and their REAL statuses
4. Provide insights/summary as TEXT after the todo list, NOT as todo items
5. NO INTERNAL REASONING - Never expose your process. Do NOT say "Let me map...", "Converting statuses...", "Here's how I'll organize...", or explain mapping logic. Just call write_todos silently and provide insights.
STATUS MAPPING (apply strictly):
- "completed" Done, Completed, Complete, Closed, Resolved, Fixed, Merged, Shipped, Released
- "in_progress" In Progress, In Review, Testing, QA, Active, Doing, Started, Review, Working
- "pending" Todo, To Do, Backlog, Open, New, Pending, Triage, Reopened, Unstarted
- "cancelled" Cancelled, Canceled, Won't Fix, Duplicate, Invalid, Rejected, Archived, Obsolete
CONNECTOR-SPECIFIC:
- Linear: state.name = "Done", "In Progress", "Todo", "Backlog", "Cancelled"
- Jira: statusCategory.name = "To Do", "In Progress", "Done"
- ClickUp: status = "complete", "in progress", "open", "closed"
- GitHub: state = "open", "closed"; PRs also "merged"
- Airtable/Notion: Check field values, apply mapping above
</tools>
<tool_call_examples>
- User: "How do I install SurfSense?"
@ -189,12 +148,24 @@ You have access to the following tools:
- User: "Check out https://dev.to/some-article"
- Call: `link_preview(url="https://dev.to/some-article")`
- Call: `scrape_webpage(url="https://dev.to/some-article")`
- After getting the content, if the content contains useful diagrams/images like `![Neural Network Diagram](https://example.com/nn-diagram.png)`:
- Call: `display_image(src="https://example.com/nn-diagram.png", alt="Neural Network Diagram", title="Neural Network Architecture")`
- Then provide your analysis, referencing the displayed image
- User: "What's this blog post about? https://example.com/blog/post"
- Call: `link_preview(url="https://example.com/blog/post")`
- Call: `scrape_webpage(url="https://example.com/blog/post")`
- After getting the content, if the content contains useful diagrams/images like `![Neural Network Diagram](https://example.com/nn-diagram.png)`:
- Call: `display_image(src="https://example.com/nn-diagram.png", alt="Neural Network Diagram", title="Neural Network Architecture")`
- Then provide your analysis, referencing the displayed image
- User: "https://github.com/some/repo"
- Call: `link_preview(url="https://github.com/some/repo")`
- Call: `scrape_webpage(url="https://github.com/some/repo")`
- After getting the content, if the content contains useful diagrams/images like `![Neural Network Diagram](https://example.com/nn-diagram.png)`:
- Call: `display_image(src="https://example.com/nn-diagram.png", alt="Neural Network Diagram", title="Neural Network Architecture")`
- Then provide your analysis, referencing the displayed image
- User: "Show me this image: https://example.com/image.png"
- Call: `display_image(src="https://example.com/image.png", alt="User shared image")`
@ -210,86 +181,31 @@ You have access to the following tools:
- The user can already see their screenshot - they don't need you to display it again.
- User: "Read this article and summarize it for me: https://example.com/blog/ai-trends"
- Call: `link_preview(url="https://example.com/blog/ai-trends")`
- Call: `scrape_webpage(url="https://example.com/blog/ai-trends")`
- After getting the content, provide a summary based on the scraped text
- After getting the content, if the content contains useful diagrams/images like `![Neural Network Diagram](https://example.com/nn-diagram.png)`:
- Call: `display_image(src="https://example.com/nn-diagram.png", alt="Neural Network Diagram", title="Neural Network Architecture")`
- Then provide a summary based on the scraped text
- User: "What does this page say about machine learning? https://docs.example.com/ml-guide"
- Call: `link_preview(url="https://docs.example.com/ml-guide")`
- Call: `scrape_webpage(url="https://docs.example.com/ml-guide")`
- After getting the content, if the content contains useful diagrams/images like `![Neural Network Diagram](https://example.com/nn-diagram.png)`:
- Call: `display_image(src="https://example.com/nn-diagram.png", alt="Neural Network Diagram", title="Neural Network Architecture")`
- Then answer the question using the extracted content
- User: "Summarize this blog post: https://medium.com/some-article"
- Call: `link_preview(url="https://medium.com/some-article")`
- Call: `scrape_webpage(url="https://medium.com/some-article")`
- Provide a comprehensive summary of the article content
- After getting the content, if the content contains useful diagrams/images like `![Neural Network Diagram](https://example.com/nn-diagram.png)`:
- Call: `display_image(src="https://example.com/nn-diagram.png", alt="Neural Network Diagram", title="Neural Network Architecture")`
- Then provide a comprehensive summary of the article content
- User: "Read this tutorial and explain it: https://example.com/ml-tutorial"
- First: `scrape_webpage(url="https://example.com/ml-tutorial")`
- Then, if the content contains useful diagrams/images like `![Neural Network Diagram](https://example.com/nn-diagram.png)`:
- Call: `display_image(src="https://example.com/nn-diagram.png", alt="Neural Network Diagram", title="Neural Network Architecture")`
- Then provide your explanation, referencing the displayed image
[MODE A EXAMPLES] Agent Plan - you work through it:
- User: "Create a plan for building a user authentication system"
- Call: `write_todos(todos=[{"content": "Design database schema for users and sessions", "status": "in_progress"}, {"content": "Implement registration and login endpoints", "status": "pending"}, {"content": "Add password reset functionality", "status": "pending"}])`
- Then explain each step in detail as you work through them
- User: "Break down how to build a REST API into steps"
- Call: `write_todos(todos=[{"content": "Design API endpoints and data models", "status": "in_progress"}, {"content": "Set up server framework and routing", "status": "pending"}, {"content": "Implement CRUD operations", "status": "pending"}, {"content": "Add authentication and error handling", "status": "pending"}])`
- Then provide detailed explanations for each step
- User: "Help me plan my trip to Japan"
- Call: `write_todos(todos=[{"content": "Research best time to visit and book flights", "status": "in_progress"}, {"content": "Plan itinerary for cities to visit", "status": "pending"}, {"content": "Book accommodations", "status": "pending"}, {"content": "Prepare travel documents and currency", "status": "pending"}])`
- Then provide travel preparation guidance
- COMPLETE WORKFLOW EXAMPLE - User: "Explain how to set up a Python project"
- STEP 1 (Create initial plan):
Call: `write_todos(todos=[{"content": "Set up virtual environment", "status": "in_progress"}, {"content": "Create project structure", "status": "pending"}, {"content": "Configure dependencies", "status": "pending"}])`
Then explain virtual environment setup in detail...
- STEP 2 (After explaining virtual environments, update progress):
Call: `write_todos(todos=[{"content": "Set up virtual environment", "status": "completed"}, {"content": "Create project structure", "status": "in_progress"}, {"content": "Configure dependencies", "status": "pending"}])`
Then explain project structure in detail...
- STEP 3 (After explaining project structure, update progress):
Call: `write_todos(todos=[{"content": "Set up virtual environment", "status": "completed"}, {"content": "Create project structure", "status": "completed"}, {"content": "Configure dependencies", "status": "in_progress"}])`
Then explain dependency configuration in detail...
- STEP 4 (After completing all explanations, mark all done):
Call: `write_todos(todos=[{"content": "Set up virtual environment", "status": "completed"}, {"content": "Create project structure", "status": "completed"}, {"content": "Configure dependencies", "status": "completed"}])`
Provide final summary
[MODE B EXAMPLES] External Tasks - preserve original status, you CANNOT complete:
- User: "Show my Linear tasks" or "Create todos for Linear tasks"
- First search: `search_knowledge_base(query="Linear tasks issues", connectors_to_search=["LINEAR_CONNECTOR"])`
- Then call write_todos ONCE with ORIGINAL statuses preserved:
Call: `write_todos(todos=[
{"content": "SUR-21: Add refresh button in manage documents page", "status": "completed"},
{"content": "SUR-22: Logs page not accessible in docker", "status": "completed"},
{"content": "SUR-27: Add Google Drive connector", "status": "in_progress"},
{"content": "SUR-28: Logs page should show all logs", "status": "pending"}
])`
- Then provide INSIGHTS as text (NOT as todos):
"You have 2 completed, 1 in progress, and 1 pending task. SUR-27 (Google Drive connector) is currently active. Consider prioritizing SUR-28 next."
- User: "List my Jira tickets"
- First search: `search_knowledge_base(query="Jira tickets issues", connectors_to_search=["JIRA_CONNECTOR"])`
- Map Jira statuses: "Done" completed, "In Progress"/"In Review" in_progress, "To Do" pending
- Call write_todos ONCE with mapped statuses
- Provide summary as text after
- User: "Show ClickUp tasks"
- First search: `search_knowledge_base(query="ClickUp tasks", connectors_to_search=["CLICKUP_CONNECTOR"])`
- Map: "complete"/"closed" completed, "in progress" in_progress, "open" pending
- Call write_todos ONCE, then provide insights as text
- User: "Show my GitHub issues"
- First search: `search_knowledge_base(query="GitHub issues", connectors_to_search=["GITHUB_CONNECTOR"])`
- Map: "closed"/"merged" completed, "open" pending
- Call write_todos ONCE, then summarize as text
CRITICAL FOR MODE B:
- NEVER use the "first item in_progress, rest pending" pattern for external tasks
- NEVER pretend you will complete external tasks - be honest that only the user can
- ALWAYS preserve the actual status from the source system
- ALWAYS provide insights/summaries as regular text, not as todo items
</tool_call_examples>
"""

View file

@ -1,6 +1,6 @@
"""MCP Client Wrapper.
This module provides a client for communicating with MCP servers via stdio transport.
This module provides a client for communicating with MCP servers via stdio and HTTP transports.
It handles server lifecycle management, tool discovery, and tool execution.
"""
@ -12,6 +12,7 @@ from typing import Any
from mcp import ClientSession
from mcp.client.stdio import StdioServerParameters, stdio_client
from mcp.client.streamable_http import streamablehttp_client
logger = logging.getLogger(__name__)
@ -222,7 +223,7 @@ class MCPClient:
async def test_mcp_connection(
command: str, args: list[str], env: dict[str, str] | None = None
) -> dict[str, Any]:
"""Test connection to an MCP server and fetch available tools.
"""Test connection to an MCP server via stdio and fetch available tools.
Args:
command: Command to spawn the MCP server
@ -249,3 +250,51 @@ async def test_mcp_connection(
"message": f"Failed to connect: {e!s}",
"tools": [],
}
async def test_mcp_http_connection(
url: str, headers: dict[str, str] | None = None, transport: str = "streamable-http"
) -> dict[str, Any]:
"""Test connection to an MCP server via HTTP and fetch available tools.
Args:
url: URL of the MCP server
headers: Optional HTTP headers for authentication
transport: Transport type ("streamable-http", "http", or "sse")
Returns:
Dict with connection status and available tools
"""
try:
logger.info("Testing HTTP MCP connection to: %s (transport: %s)", url, transport)
# Use streamable HTTP client for all HTTP-based transports
async with streamablehttp_client(url, headers=headers or {}) as (read, write, _):
async with ClientSession(read, write) as session:
await session.initialize()
# List available tools
response = await session.list_tools()
tools = []
for tool in response.tools:
tools.append({
"name": tool.name,
"description": tool.description or "",
"input_schema": tool.inputSchema if hasattr(tool, "inputSchema") else {},
})
logger.info("HTTP MCP connection successful. Found %d tools.", len(tools))
return {
"status": "success",
"message": f"Connected successfully. Found {len(tools)} tools.",
"tools": tools,
}
except Exception as e:
logger.error("Failed to connect to HTTP MCP server: %s", e, exc_info=True)
return {
"status": "error",
"message": f"Failed to connect: {e!s}",
"tools": [],
}

View file

@ -3,6 +3,10 @@
This module creates LangChain tools from MCP servers using the Model Context Protocol.
Tools are dynamically discovered from MCP servers - no manual configuration needed.
Supports both transport types:
- stdio: Local process-based MCP servers (command, args, env)
- streamable-http/http/sse: Remote HTTP-based MCP servers (url, headers)
This implements real MCP protocol support similar to Cursor's implementation.
"""
@ -10,6 +14,8 @@ import logging
from typing import Any
from langchain_core.tools import StructuredTool
from mcp import ClientSession
from mcp.client.streamable_http import streamablehttp_client
from pydantic import BaseModel, create_model
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
@ -65,11 +71,11 @@ def _create_dynamic_input_model_from_schema(
return create_model(model_name, **field_definitions)
async def _create_mcp_tool_from_definition(
async def _create_mcp_tool_from_definition_stdio(
tool_def: dict[str, Any],
mcp_client: MCPClient,
) -> StructuredTool:
"""Create a LangChain tool from an MCP tool definition.
"""Create a LangChain tool from an MCP tool definition (stdio transport).
Args:
tool_def: Tool definition from MCP server with name, description, input_schema
@ -116,13 +122,223 @@ async def _create_mcp_tool_from_definition(
coroutine=mcp_tool_call,
args_schema=input_model,
# Store the original MCP schema as metadata so we can access it later
metadata={"mcp_input_schema": input_schema},
metadata={"mcp_input_schema": input_schema, "mcp_transport": "stdio"},
)
logger.info(f"Created MCP tool: '{tool_name}'")
logger.info(f"Created MCP tool (stdio): '{tool_name}'")
return tool
async def _create_mcp_tool_from_definition_http(
tool_def: dict[str, Any],
url: str,
headers: dict[str, str],
) -> StructuredTool:
"""Create a LangChain tool from an MCP tool definition (HTTP transport).
Args:
tool_def: Tool definition from MCP server with name, description, input_schema
url: URL of the MCP server
headers: HTTP headers for authentication
Returns:
LangChain StructuredTool instance
"""
tool_name = tool_def.get("name", "unnamed_tool")
tool_description = tool_def.get("description", "No description provided")
input_schema = tool_def.get("input_schema", {"type": "object", "properties": {}})
# Log the actual schema for debugging
logger.info(f"MCP HTTP tool '{tool_name}' input schema: {input_schema}")
# Create dynamic input model from schema
input_model = _create_dynamic_input_model_from_schema(tool_name, input_schema)
async def mcp_http_tool_call(**kwargs) -> str:
"""Execute the MCP tool call via HTTP transport."""
logger.info(f"MCP HTTP tool '{tool_name}' called with params: {kwargs}")
try:
async with streamablehttp_client(url, headers=headers) as (read, write, _):
async with ClientSession(read, write) as session:
await session.initialize()
# Call the tool
response = await session.call_tool(tool_name, arguments=kwargs)
# Extract content from response
result = []
for content in response.content:
if hasattr(content, "text"):
result.append(content.text)
elif hasattr(content, "data"):
result.append(str(content.data))
else:
result.append(str(content))
result_str = "\n".join(result) if result else ""
logger.info(f"MCP HTTP tool '{tool_name}' succeeded: {result_str[:200]}")
return result_str
except Exception as e:
error_msg = f"MCP HTTP tool '{tool_name}' execution failed: {e!s}"
logger.exception(error_msg)
return f"Error: {error_msg}"
# Create StructuredTool
tool = StructuredTool(
name=tool_name,
description=tool_description,
coroutine=mcp_http_tool_call,
args_schema=input_model,
metadata={"mcp_input_schema": input_schema, "mcp_transport": "http", "mcp_url": url},
)
logger.info(f"Created MCP tool (HTTP): '{tool_name}'")
return tool
async def _load_stdio_mcp_tools(
connector_id: int,
connector_name: str,
server_config: dict[str, Any],
) -> list[StructuredTool]:
"""Load tools from a stdio-based MCP server.
Args:
connector_id: Connector ID for logging
connector_name: Connector name for logging
server_config: Server configuration with command, args, env
Returns:
List of tools from the MCP server
"""
tools: list[StructuredTool] = []
# Validate required command field
command = server_config.get("command")
if not command or not isinstance(command, str):
logger.warning(
f"MCP connector {connector_id} (name: '{connector_name}') missing or invalid command field, skipping"
)
return tools
# Validate args field (must be list if present)
args = server_config.get("args", [])
if not isinstance(args, list):
logger.warning(
f"MCP connector {connector_id} (name: '{connector_name}') has invalid args field (must be list), skipping"
)
return tools
# Validate env field (must be dict if present)
env = server_config.get("env", {})
if not isinstance(env, dict):
logger.warning(
f"MCP connector {connector_id} (name: '{connector_name}') has invalid env field (must be dict), skipping"
)
return tools
# Create MCP client
mcp_client = MCPClient(command, args, env)
# Connect and discover tools
async with mcp_client.connect():
tool_definitions = await mcp_client.list_tools()
logger.info(
f"Discovered {len(tool_definitions)} tools from stdio MCP server "
f"'{command}' (connector {connector_id})"
)
# Create LangChain tools from definitions
for tool_def in tool_definitions:
try:
tool = await _create_mcp_tool_from_definition_stdio(tool_def, mcp_client)
tools.append(tool)
except Exception as e:
logger.exception(
f"Failed to create tool '{tool_def.get('name')}' "
f"from connector {connector_id}: {e!s}"
)
return tools
async def _load_http_mcp_tools(
connector_id: int,
connector_name: str,
server_config: dict[str, Any],
) -> list[StructuredTool]:
"""Load tools from an HTTP-based MCP server.
Args:
connector_id: Connector ID for logging
connector_name: Connector name for logging
server_config: Server configuration with url, headers
Returns:
List of tools from the MCP server
"""
tools: list[StructuredTool] = []
# Validate required url field
url = server_config.get("url")
if not url or not isinstance(url, str):
logger.warning(
f"MCP connector {connector_id} (name: '{connector_name}') missing or invalid url field, skipping"
)
return tools
# Validate headers field (must be dict if present)
headers = server_config.get("headers", {})
if not isinstance(headers, dict):
logger.warning(
f"MCP connector {connector_id} (name: '{connector_name}') has invalid headers field (must be dict), skipping"
)
return tools
# Connect and discover tools via HTTP
try:
async with streamablehttp_client(url, headers=headers) as (read, write, _):
async with ClientSession(read, write) as session:
await session.initialize()
# List available tools
response = await session.list_tools()
tool_definitions = []
for tool in response.tools:
tool_definitions.append({
"name": tool.name,
"description": tool.description or "",
"input_schema": tool.inputSchema if hasattr(tool, "inputSchema") else {},
})
logger.info(
f"Discovered {len(tool_definitions)} tools from HTTP MCP server "
f"'{url}' (connector {connector_id})"
)
# Create LangChain tools from definitions
for tool_def in tool_definitions:
try:
tool = await _create_mcp_tool_from_definition_http(tool_def, url, headers)
tools.append(tool)
except Exception as e:
logger.exception(
f"Failed to create HTTP tool '{tool_def.get('name')}' "
f"from connector {connector_id}: {e!s}"
)
except Exception as e:
logger.exception(
f"Failed to connect to HTTP MCP server at '{url}' (connector {connector_id}): {e!s}"
)
return tools
async def load_mcp_tools(
session: AsyncSession,
search_space_id: int,
@ -130,6 +346,7 @@ async def load_mcp_tools(
"""Load all MCP tools from user's active MCP server connectors.
This discovers tools dynamically from MCP servers using the protocol.
Supports both stdio (local process) and HTTP (remote server) transports.
Args:
session: Database session
@ -163,54 +380,22 @@ async def load_mcp_tools(
)
continue
# Validate required command field
command = server_config.get("command")
if not command or not isinstance(command, str):
logger.warning(
f"MCP connector {connector.id} (name: '{connector.name}') missing or invalid command field, skipping"
# Determine transport type
transport = server_config.get("transport", "stdio")
if transport in ("streamable-http", "http", "sse"):
# HTTP-based MCP server
connector_tools = await _load_http_mcp_tools(
connector.id, connector.name, server_config
)
continue
# Validate args field (must be list if present)
args = server_config.get("args", [])
if not isinstance(args, list):
logger.warning(
f"MCP connector {connector.id} (name: '{connector.name}') has invalid args field (must be list), skipping"
else:
# stdio-based MCP server (default)
connector_tools = await _load_stdio_mcp_tools(
connector.id, connector.name, server_config
)
continue
# Validate env field (must be dict if present)
env = server_config.get("env", {})
if not isinstance(env, dict):
logger.warning(
f"MCP connector {connector.id} (name: '{connector.name}') has invalid env field (must be dict), skipping"
)
continue
# Create MCP client
mcp_client = MCPClient(command, args, env)
# Connect and discover tools
async with mcp_client.connect():
tool_definitions = await mcp_client.list_tools()
logger.info(
f"Discovered {len(tool_definitions)} tools from MCP server "
f"'{command}' (connector {connector.id})"
)
# Create LangChain tools from definitions
for tool_def in tool_definitions:
try:
tool = await _create_mcp_tool_from_definition(
tool_def, mcp_client
)
tools.append(tool)
except Exception as e:
logger.exception(
f"Failed to create tool '{tool_def.get('name')}' "
f"from connector {connector.id}: {e!s}"
)
tools.extend(connector_tools)
except Exception as e:
logger.exception(
f"Failed to load tools from MCP connector {connector.id}: {e!s}"

View file

@ -152,6 +152,11 @@ class Permission(str, Enum):
CHATS_UPDATE = "chats:update"
CHATS_DELETE = "chats:delete"
# Comments
COMMENTS_CREATE = "comments:create"
COMMENTS_READ = "comments:read"
COMMENTS_DELETE = "comments:delete"
# LLM Configs
LLM_CONFIGS_CREATE = "llm_configs:create"
LLM_CONFIGS_READ = "llm_configs:read"
@ -209,6 +214,10 @@ DEFAULT_ROLE_PERMISSIONS = {
Permission.CHATS_READ.value,
Permission.CHATS_UPDATE.value,
Permission.CHATS_DELETE.value,
# Comments
Permission.COMMENTS_CREATE.value,
Permission.COMMENTS_READ.value,
Permission.COMMENTS_DELETE.value,
# LLM Configs
Permission.LLM_CONFIGS_CREATE.value,
Permission.LLM_CONFIGS_READ.value,
@ -252,6 +261,9 @@ DEFAULT_ROLE_PERMISSIONS = {
Permission.CHATS_READ.value,
Permission.CHATS_UPDATE.value,
Permission.CHATS_DELETE.value,
# Comments (no delete)
Permission.COMMENTS_CREATE.value,
Permission.COMMENTS_READ.value,
# LLM Configs (read only)
Permission.LLM_CONFIGS_READ.value,
Permission.LLM_CONFIGS_CREATE.value,
@ -279,6 +291,9 @@ DEFAULT_ROLE_PERMISSIONS = {
Permission.DOCUMENTS_READ.value,
# Chats (read only)
Permission.CHATS_READ.value,
# Comments (no delete)
Permission.COMMENTS_CREATE.value,
Permission.COMMENTS_READ.value,
# LLM Configs (read only)
Permission.LLM_CONFIGS_READ.value,
# Podcasts (read only)
@ -424,6 +439,84 @@ class NewChatMessage(BaseModel, TimestampMixin):
# Relationships
thread = relationship("NewChatThread", back_populates="messages")
author = relationship("User")
comments = relationship(
"ChatComment",
back_populates="message",
cascade="all, delete-orphan",
)
class ChatComment(BaseModel, TimestampMixin):
"""
Comment model for comments on AI chat responses.
Supports one level of nesting (replies to comments, but no replies to replies).
"""
__tablename__ = "chat_comments"
message_id = Column(
Integer,
ForeignKey("new_chat_messages.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
parent_id = Column(
Integer,
ForeignKey("chat_comments.id", ondelete="CASCADE"),
nullable=True,
index=True,
)
author_id = Column(
UUID(as_uuid=True),
ForeignKey("user.id", ondelete="SET NULL"),
nullable=True,
index=True,
)
content = Column(Text, nullable=False)
updated_at = Column(
TIMESTAMP(timezone=True),
nullable=False,
default=lambda: datetime.now(UTC),
onupdate=lambda: datetime.now(UTC),
index=True,
)
# Relationships
message = relationship("NewChatMessage", back_populates="comments")
author = relationship("User")
parent = relationship(
"ChatComment", remote_side="ChatComment.id", backref="replies"
)
mentions = relationship(
"ChatCommentMention",
back_populates="comment",
cascade="all, delete-orphan",
)
class ChatCommentMention(BaseModel, TimestampMixin):
"""
Tracks @mentions in chat comments for notification purposes.
"""
__tablename__ = "chat_comment_mentions"
comment_id = Column(
Integer,
ForeignKey("chat_comments.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
mentioned_user_id = Column(
UUID(as_uuid=True),
ForeignKey("user.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
# Relationships
comment = relationship("ChatComment", back_populates="mentions")
mentioned_user = relationship("User")
class Document(BaseModel, TimestampMixin):

View file

@ -3,6 +3,7 @@ from fastapi import APIRouter
from .airtable_add_connector_route import (
router as airtable_add_connector_router,
)
from .chat_comments_routes import router as chat_comments_router
from .circleback_webhook_route import router as circleback_webhook_router
from .clickup_add_connector_route import router as clickup_add_connector_router
from .confluence_add_connector_route import router as confluence_add_connector_router
@ -43,6 +44,7 @@ router.include_router(editor_router)
router.include_router(documents_router)
router.include_router(notes_router)
router.include_router(new_chat_router) # Chat with assistant-ui persistence
router.include_router(chat_comments_router)
router.include_router(podcasts_router) # Podcast task status and audio
router.include_router(search_source_connectors_router)
router.include_router(google_calendar_add_connector_router)

View file

@ -0,0 +1,95 @@
"""
Routes for chat comments and mentions.
"""
from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from app.db import User, get_async_session
from app.schemas.chat_comments import (
CommentCreateRequest,
CommentListResponse,
CommentReplyResponse,
CommentResponse,
CommentUpdateRequest,
MentionListResponse,
)
from app.services.chat_comments_service import (
create_comment,
create_reply,
delete_comment,
get_comments_for_message,
get_user_mentions,
update_comment,
)
from app.users import current_active_user
router = APIRouter()
@router.get("/messages/{message_id}/comments", response_model=CommentListResponse)
async def list_comments(
message_id: int,
session: AsyncSession = Depends(get_async_session),
user: User = Depends(current_active_user),
):
"""List all comments for a message with their replies."""
return await get_comments_for_message(session, message_id, user)
@router.post("/messages/{message_id}/comments", response_model=CommentResponse)
async def add_comment(
message_id: int,
request: CommentCreateRequest,
session: AsyncSession = Depends(get_async_session),
user: User = Depends(current_active_user),
):
"""Create a top-level comment on an AI response."""
return await create_comment(session, message_id, request.content, user)
@router.post("/comments/{comment_id}/replies", response_model=CommentReplyResponse)
async def add_reply(
comment_id: int,
request: CommentCreateRequest,
session: AsyncSession = Depends(get_async_session),
user: User = Depends(current_active_user),
):
"""Reply to an existing comment."""
return await create_reply(session, comment_id, request.content, user)
@router.put("/comments/{comment_id}", response_model=CommentReplyResponse)
async def edit_comment(
comment_id: int,
request: CommentUpdateRequest,
session: AsyncSession = Depends(get_async_session),
user: User = Depends(current_active_user),
):
"""Update a comment's content (author only)."""
return await update_comment(session, comment_id, request.content, user)
@router.delete("/comments/{comment_id}")
async def remove_comment(
comment_id: int,
session: AsyncSession = Depends(get_async_session),
user: User = Depends(current_active_user),
):
"""Delete a comment (author or user with COMMENTS_DELETE permission)."""
return await delete_comment(session, comment_id, user)
# =============================================================================
# Mention Routes
# =============================================================================
@router.get("/mentions", response_model=MentionListResponse)
async def list_mentions(
search_space_id: int | None = None,
session: AsyncSession = Depends(get_async_session),
user: User = Depends(current_active_user),
):
"""List mentions for the current user."""
return await get_user_mentions(session, user, search_space_id)

View file

@ -19,13 +19,14 @@ from datetime import UTC, datetime
from fastapi import APIRouter, Depends, File, HTTPException, Request, UploadFile
from fastapi.responses import StreamingResponse
from sqlalchemy import or_
from sqlalchemy import func, or_
from sqlalchemy.exc import IntegrityError, OperationalError
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select
from sqlalchemy.orm import selectinload
from app.db import (
ChatComment,
ChatVisibility,
NewChatMessage,
NewChatMessageRole,
@ -508,7 +509,19 @@ async def get_thread_full(
# Check thread-level access based on visibility
await check_thread_access(session, thread, user)
return thread
# Check if thread has any comments
comment_count = await session.scalar(
select(func.count())
.select_from(ChatComment)
.join(NewChatMessage, ChatComment.message_id == NewChatMessage.id)
.where(NewChatMessage.thread_id == thread.id)
)
return {
**thread.__dict__,
"messages": thread.messages,
"has_comments": (comment_count or 0) > 0,
}
except HTTPException:
raise

View file

@ -452,6 +452,8 @@ async def list_members(
"created_at": membership.created_at,
"role": membership.role,
"user_email": member_user.email if member_user else None,
"user_display_name": member_user.display_name if member_user else None,
"user_avatar_url": member_user.avatar_url if member_user else None,
}
response.append(membership_dict)

View file

@ -2385,22 +2385,43 @@ async def test_mcp_server_connection(
This endpoint allows users to test their MCP server configuration
before saving it, similar to Cursor's flow.
Supports two transport types:
- stdio: Local process with command, args, env
- streamable-http/http/sse: Remote HTTP server with url, headers
Args:
server_config: Server configuration with command, args, env
server_config: Server configuration
user: Current authenticated user
Returns:
Connection status and list of available tools
"""
try:
from app.agents.new_chat.tools.mcp_client import test_mcp_connection
from app.agents.new_chat.tools.mcp_client import (
test_mcp_connection,
test_mcp_http_connection,
)
transport = server_config.get("transport", "stdio")
# HTTP transport (streamable-http, http, sse)
if transport in ("streamable-http", "http", "sse"):
url = server_config.get("url")
headers = server_config.get("headers", {})
if not url:
raise HTTPException(status_code=400, detail="Server URL is required for HTTP transport")
result = await test_mcp_http_connection(url, headers, transport)
return result
# stdio transport (default)
command = server_config.get("command")
args = server_config.get("args", [])
env = server_config.get("env", {})
if not command:
raise HTTPException(status_code=400, detail="Server command is required")
raise HTTPException(status_code=400, detail="Server command is required for stdio transport")
# Test the connection
result = await test_mcp_connection(command, args, env)

View file

@ -0,0 +1,129 @@
"""
Pydantic schemas for chat comments and mentions.
"""
from datetime import datetime
from uuid import UUID
from pydantic import BaseModel, ConfigDict, Field
# =============================================================================
# Request Schemas
# =============================================================================
class CommentCreateRequest(BaseModel):
"""Schema for creating a comment or reply."""
content: str = Field(..., min_length=1, max_length=5000)
class CommentUpdateRequest(BaseModel):
"""Schema for updating a comment."""
content: str = Field(..., min_length=1, max_length=5000)
# =============================================================================
# Author Schema
# =============================================================================
class AuthorResponse(BaseModel):
"""Author information for comments."""
id: UUID
display_name: str | None = None
avatar_url: str | None = None
email: str
model_config = ConfigDict(from_attributes=True)
# =============================================================================
# Comment Schemas
# =============================================================================
class CommentReplyResponse(BaseModel):
"""Schema for a comment reply (no nested replies)."""
id: int
content: str
content_rendered: str
author: AuthorResponse | None = None
created_at: datetime
updated_at: datetime
is_edited: bool
can_edit: bool = False
can_delete: bool = False
model_config = ConfigDict(from_attributes=True)
class CommentResponse(BaseModel):
"""Schema for a top-level comment with replies."""
id: int
message_id: int
content: str
content_rendered: str
author: AuthorResponse | None = None
created_at: datetime
updated_at: datetime
is_edited: bool
can_edit: bool = False
can_delete: bool = False
reply_count: int
replies: list[CommentReplyResponse] = []
model_config = ConfigDict(from_attributes=True)
class CommentListResponse(BaseModel):
"""Response for listing comments on a message."""
comments: list[CommentResponse]
total_count: int
# =============================================================================
# Mention Schemas
# =============================================================================
class MentionContextResponse(BaseModel):
"""Context information for where a mention occurred."""
thread_id: int
thread_title: str
message_id: int
search_space_id: int
search_space_name: str
class MentionCommentResponse(BaseModel):
"""Abbreviated comment info for mention display."""
id: int
content_preview: str
author: AuthorResponse | None = None
created_at: datetime
class MentionResponse(BaseModel):
"""Schema for a mention notification."""
id: int
created_at: datetime
comment: MentionCommentResponse
context: MentionContextResponse
model_config = ConfigDict(from_attributes=True)
class MentionListResponse(BaseModel):
"""Response for listing user's mentions."""
mentions: list[MentionResponse]
total_count: int

View file

@ -105,6 +105,7 @@ class NewChatThreadWithMessages(NewChatThreadRead):
"""Schema for reading a thread with its messages."""
messages: list[NewChatMessageRead] = []
has_comments: bool = False
# =============================================================================

View file

@ -73,8 +73,10 @@ class MembershipRead(BaseModel):
created_at: datetime
# Nested role info
role: RoleRead | None = None
# User email (populated separately)
# User details (populated separately)
user_email: str | None = None
user_display_name: str | None = None
user_avatar_url: str | None = None
class Config:
from_attributes = True

View file

@ -83,12 +83,27 @@ class SearchSourceConnectorRead(SearchSourceConnectorBase, IDModel, TimestampMod
class MCPServerConfig(BaseModel):
"""Configuration for an MCP server connection (similar to Cursor's config)."""
"""Configuration for an MCP server connection.
Supports two transport types:
- stdio: Local process (command, args, env)
- streamable-http/http/sse: Remote HTTP server (url, headers)
"""
command: str # e.g., "uvx", "node", "python"
# stdio transport fields
command: str | None = None # e.g., "uvx", "node", "python"
args: list[str] = [] # e.g., ["mcp-server-git", "--repository", "/path"]
env: dict[str, str] = {} # Environment variables for the server process
transport: str = "stdio" # "stdio" | "sse" | "http" (stdio is most common)
# HTTP transport fields
url: str | None = None # e.g., "https://mcp-server.com/mcp"
headers: dict[str, str] = {} # HTTP headers for authentication
transport: str = "stdio" # "stdio" | "streamable-http" | "http" | "sse"
def is_http_transport(self) -> bool:
"""Check if this config uses HTTP transport."""
return self.transport in ("streamable-http", "http", "sse")
class MCPConnectorCreate(BaseModel):

View file

@ -0,0 +1,733 @@
"""
Service layer for chat comments and mentions.
"""
from uuid import UUID
from fastapi import HTTPException
from sqlalchemy import delete, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.db import (
ChatComment,
ChatCommentMention,
NewChatMessage,
NewChatMessageRole,
NewChatThread,
Permission,
SearchSpaceMembership,
User,
has_permission,
)
from app.schemas.chat_comments import (
AuthorResponse,
CommentListResponse,
CommentReplyResponse,
CommentResponse,
MentionCommentResponse,
MentionContextResponse,
MentionListResponse,
MentionResponse,
)
from app.services.notification_service import NotificationService
from app.utils.chat_comments import parse_mentions, render_mentions
from app.utils.rbac import check_permission, get_user_permissions
async def get_user_names_for_mentions(
session: AsyncSession,
user_ids: set[UUID],
) -> dict[UUID, str]:
"""
Fetch display names for a set of user IDs.
Args:
session: Database session
user_ids: Set of user UUIDs to look up
Returns:
Dictionary mapping user UUID to display name
"""
if not user_ids:
return {}
result = await session.execute(
select(User.id, User.display_name).filter(User.id.in_(user_ids))
)
return {row.id: row.display_name or "Unknown" for row in result.all()}
async def process_mentions(
session: AsyncSession,
comment_id: int,
content: str,
search_space_id: int,
) -> dict[UUID, int]:
"""
Parse mentions from content, validate users are members, and insert mention records.
Args:
session: Database session
comment_id: ID of the comment containing mentions
content: Comment text with @[uuid] mentions
search_space_id: ID of the search space for membership validation
Returns:
Dictionary mapping mentioned user UUID to their mention record ID
"""
mentioned_uuids = parse_mentions(content)
if not mentioned_uuids:
return {}
# Get valid members from the mentioned UUIDs
result = await session.execute(
select(SearchSpaceMembership.user_id).filter(
SearchSpaceMembership.search_space_id == search_space_id,
SearchSpaceMembership.user_id.in_(mentioned_uuids),
)
)
valid_member_ids = result.scalars().all()
# Insert mention records for valid members and collect their IDs
mentions_map: dict[UUID, int] = {}
for user_id in valid_member_ids:
mention = ChatCommentMention(
comment_id=comment_id,
mentioned_user_id=user_id,
)
session.add(mention)
await session.flush()
mentions_map[user_id] = mention.id
return mentions_map
async def get_comments_for_message(
session: AsyncSession,
message_id: int,
user: User,
) -> CommentListResponse:
"""
Get all comments for a message with their replies.
Args:
session: Database session
message_id: ID of the message to get comments for
user: The current authenticated user
Returns:
CommentListResponse with all top-level comments and their replies
Raises:
HTTPException: If message not found or user lacks COMMENTS_READ permission
"""
result = await session.execute(
select(NewChatMessage)
.options(selectinload(NewChatMessage.thread))
.filter(NewChatMessage.id == message_id)
)
message = result.scalars().first()
if not message:
raise HTTPException(status_code=404, detail="Message not found")
search_space_id = message.thread.search_space_id
# Check permission to read comments
await check_permission(
session,
user,
search_space_id,
Permission.COMMENTS_READ.value,
"You don't have permission to read comments in this search space",
)
# Get user permissions for can_delete computation
user_permissions = await get_user_permissions(session, user.id, search_space_id)
can_delete_any = has_permission(user_permissions, Permission.COMMENTS_DELETE.value)
# Get top-level comments (parent_id IS NULL) with their authors and replies
result = await session.execute(
select(ChatComment)
.options(
selectinload(ChatComment.author),
selectinload(ChatComment.replies).selectinload(ChatComment.author),
)
.filter(
ChatComment.message_id == message_id,
ChatComment.parent_id.is_(None),
)
.order_by(ChatComment.created_at)
)
top_level_comments = result.scalars().all()
# Collect all mentioned UUIDs from comments and replies for rendering
all_mentioned_uuids: set[UUID] = set()
for comment in top_level_comments:
all_mentioned_uuids.update(parse_mentions(comment.content))
for reply in comment.replies:
all_mentioned_uuids.update(parse_mentions(reply.content))
# Fetch display names for mentioned users
user_names = await get_user_names_for_mentions(session, all_mentioned_uuids)
comments = []
for comment in top_level_comments:
author = None
if comment.author:
author = AuthorResponse(
id=comment.author.id,
display_name=comment.author.display_name,
avatar_url=comment.author.avatar_url,
email=comment.author.email,
)
replies = []
for reply in sorted(comment.replies, key=lambda r: r.created_at):
reply_author = None
if reply.author:
reply_author = AuthorResponse(
id=reply.author.id,
display_name=reply.author.display_name,
avatar_url=reply.author.avatar_url,
email=reply.author.email,
)
is_reply_author = reply.author_id == user.id if reply.author_id else False
replies.append(
CommentReplyResponse(
id=reply.id,
content=reply.content,
content_rendered=render_mentions(reply.content, user_names),
author=reply_author,
created_at=reply.created_at,
updated_at=reply.updated_at,
is_edited=reply.updated_at > reply.created_at,
can_edit=is_reply_author,
can_delete=is_reply_author or can_delete_any,
)
)
is_comment_author = comment.author_id == user.id if comment.author_id else False
comments.append(
CommentResponse(
id=comment.id,
message_id=comment.message_id,
content=comment.content,
content_rendered=render_mentions(comment.content, user_names),
author=author,
created_at=comment.created_at,
updated_at=comment.updated_at,
is_edited=comment.updated_at > comment.created_at,
can_edit=is_comment_author,
can_delete=is_comment_author or can_delete_any,
reply_count=len(replies),
replies=replies,
)
)
return CommentListResponse(
comments=comments,
total_count=len(comments),
)
async def create_comment(
session: AsyncSession,
message_id: int,
content: str,
user: User,
) -> CommentResponse:
"""
Create a top-level comment on an AI response.
Args:
session: Database session
message_id: ID of the message to comment on
content: Comment text content
user: The current authenticated user
Returns:
CommentResponse for the created comment
Raises:
HTTPException: If message not found, not AI response, or user lacks COMMENTS_CREATE permission
"""
result = await session.execute(
select(NewChatMessage)
.options(selectinload(NewChatMessage.thread))
.filter(NewChatMessage.id == message_id)
)
message = result.scalars().first()
if not message:
raise HTTPException(status_code=404, detail="Message not found")
# Validate message is an AI response
if message.role != NewChatMessageRole.ASSISTANT:
raise HTTPException(
status_code=400,
detail="Comments can only be added to AI responses",
)
search_space_id = message.thread.search_space_id
# Check permission to create comments
user_permissions = await get_user_permissions(session, user.id, search_space_id)
if not has_permission(user_permissions, Permission.COMMENTS_CREATE.value):
raise HTTPException(
status_code=403,
detail="You don't have permission to create comments in this search space",
)
comment = ChatComment(
message_id=message_id,
author_id=user.id,
content=content,
)
session.add(comment)
await session.flush()
# Process mentions - returns map of user_id -> mention_id
mentions_map = await process_mentions(session, comment.id, content, search_space_id)
await session.commit()
await session.refresh(comment)
# Fetch user names for rendering mentions (reuse mentions_map keys)
user_names = await get_user_names_for_mentions(session, set(mentions_map.keys()))
# Create notifications for mentioned users (excluding author)
thread = message.thread
author_name = user.display_name or user.email
content_preview = render_mentions(content, user_names)
for mentioned_user_id, mention_id in mentions_map.items():
if mentioned_user_id == user.id:
continue # Don't notify yourself
await NotificationService.mention.notify_new_mention(
session=session,
mentioned_user_id=mentioned_user_id,
mention_id=mention_id,
comment_id=comment.id,
message_id=message_id,
thread_id=thread.id,
thread_title=thread.title or "Untitled thread",
author_id=str(user.id),
author_name=author_name,
content_preview=content_preview[:200],
search_space_id=search_space_id,
)
author = AuthorResponse(
id=user.id,
display_name=user.display_name,
avatar_url=user.avatar_url,
email=user.email,
)
return CommentResponse(
id=comment.id,
message_id=comment.message_id,
content=comment.content,
content_rendered=render_mentions(content, user_names),
author=author,
created_at=comment.created_at,
updated_at=comment.updated_at,
is_edited=False,
can_edit=True,
can_delete=True,
reply_count=0,
replies=[],
)
async def create_reply(
session: AsyncSession,
comment_id: int,
content: str,
user: User,
) -> CommentReplyResponse:
"""
Create a reply to an existing comment.
Args:
session: Database session
comment_id: ID of the parent comment to reply to
content: Reply text content
user: The current authenticated user
Returns:
CommentReplyResponse for the created reply
Raises:
HTTPException: If comment not found, is already a reply, or user lacks COMMENTS_CREATE permission
"""
# Get parent comment with its message and thread
result = await session.execute(
select(ChatComment)
.options(selectinload(ChatComment.message).selectinload(NewChatMessage.thread))
.filter(ChatComment.id == comment_id)
)
parent_comment = result.scalars().first()
if not parent_comment:
raise HTTPException(status_code=404, detail="Comment not found")
# Validate parent is a top-level comment (cannot reply to a reply)
if parent_comment.parent_id is not None:
raise HTTPException(
status_code=400,
detail="Cannot reply to a reply",
)
search_space_id = parent_comment.message.thread.search_space_id
# Check permission to create comments
user_permissions = await get_user_permissions(session, user.id, search_space_id)
if not has_permission(user_permissions, Permission.COMMENTS_CREATE.value):
raise HTTPException(
status_code=403,
detail="You don't have permission to create comments in this search space",
)
reply = ChatComment(
message_id=parent_comment.message_id,
parent_id=comment_id,
author_id=user.id,
content=content,
)
session.add(reply)
await session.flush()
# Process mentions - returns map of user_id -> mention_id
mentions_map = await process_mentions(session, reply.id, content, search_space_id)
await session.commit()
await session.refresh(reply)
# Fetch user names for rendering mentions (reuse mentions_map keys)
user_names = await get_user_names_for_mentions(session, set(mentions_map.keys()))
# Create notifications for mentioned users (excluding author)
thread = parent_comment.message.thread
author_name = user.display_name or user.email
content_preview = render_mentions(content, user_names)
for mentioned_user_id, mention_id in mentions_map.items():
if mentioned_user_id == user.id:
continue # Don't notify yourself
await NotificationService.mention.notify_new_mention(
session=session,
mentioned_user_id=mentioned_user_id,
mention_id=mention_id,
comment_id=reply.id,
message_id=parent_comment.message_id,
thread_id=thread.id,
thread_title=thread.title or "Untitled thread",
author_id=str(user.id),
author_name=author_name,
content_preview=content_preview[:200],
search_space_id=search_space_id,
)
author = AuthorResponse(
id=user.id,
display_name=user.display_name,
avatar_url=user.avatar_url,
email=user.email,
)
return CommentReplyResponse(
id=reply.id,
content=reply.content,
content_rendered=render_mentions(content, user_names),
author=author,
created_at=reply.created_at,
updated_at=reply.updated_at,
is_edited=False,
can_edit=True,
can_delete=True,
)
async def update_comment(
session: AsyncSession,
comment_id: int,
content: str,
user: User,
) -> CommentReplyResponse:
"""
Update a comment's content (author only).
Args:
session: Database session
comment_id: ID of the comment to update
content: New comment text content
user: The current authenticated user
Returns:
CommentReplyResponse for the updated comment
Raises:
HTTPException: If comment not found or user is not the author
"""
result = await session.execute(
select(ChatComment)
.options(
selectinload(ChatComment.author),
selectinload(ChatComment.message).selectinload(NewChatMessage.thread),
)
.filter(ChatComment.id == comment_id)
)
comment = result.scalars().first()
if not comment:
raise HTTPException(status_code=404, detail="Comment not found")
if comment.author_id != user.id:
raise HTTPException(
status_code=403,
detail="You can only edit your own comments",
)
search_space_id = comment.message.thread.search_space_id
# Get existing mentioned user IDs
existing_result = await session.execute(
select(ChatCommentMention.mentioned_user_id).filter(
ChatCommentMention.comment_id == comment_id
)
)
existing_mention_ids = set(existing_result.scalars().all())
# Parse new mentions from updated content
new_mention_uuids = set(parse_mentions(content))
# Validate new mentions are search space members
if new_mention_uuids:
valid_result = await session.execute(
select(SearchSpaceMembership.user_id).filter(
SearchSpaceMembership.search_space_id == search_space_id,
SearchSpaceMembership.user_id.in_(new_mention_uuids),
)
)
valid_new_mentions = set(valid_result.scalars().all())
else:
valid_new_mentions = set()
# Compute diff: removed, kept (preserve read status), added
mentions_to_remove = existing_mention_ids - valid_new_mentions
mentions_to_add = valid_new_mentions - existing_mention_ids
# Delete removed mentions
if mentions_to_remove:
await session.execute(
delete(ChatCommentMention).where(
ChatCommentMention.comment_id == comment_id,
ChatCommentMention.mentioned_user_id.in_(mentions_to_remove),
)
)
# Add new mentions and collect their IDs for notifications
new_mentions_map: dict[UUID, int] = {}
for user_id in mentions_to_add:
mention = ChatCommentMention(
comment_id=comment_id,
mentioned_user_id=user_id,
)
session.add(mention)
await session.flush()
new_mentions_map[user_id] = mention.id
comment.content = content
await session.commit()
await session.refresh(comment)
# Fetch user names for rendering mentions
user_names = await get_user_names_for_mentions(session, valid_new_mentions)
# Create notifications for newly added mentions (excluding author)
if new_mentions_map:
thread = comment.message.thread
author_name = user.display_name or user.email
content_preview = render_mentions(content, user_names)
for mentioned_user_id, mention_id in new_mentions_map.items():
if mentioned_user_id == user.id:
continue # Don't notify yourself
await NotificationService.mention.notify_new_mention(
session=session,
mentioned_user_id=mentioned_user_id,
mention_id=mention_id,
comment_id=comment_id,
message_id=comment.message_id,
thread_id=thread.id,
thread_title=thread.title or "Untitled thread",
author_id=str(user.id),
author_name=author_name,
content_preview=content_preview[:200],
search_space_id=search_space_id,
)
author = AuthorResponse(
id=user.id,
display_name=user.display_name,
avatar_url=user.avatar_url,
email=user.email,
)
return CommentReplyResponse(
id=comment.id,
content=comment.content,
content_rendered=render_mentions(content, user_names),
author=author,
created_at=comment.created_at,
updated_at=comment.updated_at,
is_edited=comment.updated_at > comment.created_at,
can_edit=True,
can_delete=True,
)
async def delete_comment(
session: AsyncSession,
comment_id: int,
user: User,
) -> dict:
"""
Delete a comment (author or user with COMMENTS_DELETE permission).
Args:
session: Database session
comment_id: ID of the comment to delete
user: The current authenticated user
Returns:
Dict with deletion confirmation
Raises:
HTTPException: If comment not found or user lacks permission to delete
"""
result = await session.execute(
select(ChatComment)
.options(selectinload(ChatComment.message).selectinload(NewChatMessage.thread))
.filter(ChatComment.id == comment_id)
)
comment = result.scalars().first()
if not comment:
raise HTTPException(status_code=404, detail="Comment not found")
is_author = comment.author_id == user.id
# Check if user has COMMENTS_DELETE permission
search_space_id = comment.message.thread.search_space_id
user_permissions = await get_user_permissions(session, user.id, search_space_id)
can_delete_any = has_permission(user_permissions, Permission.COMMENTS_DELETE.value)
if not is_author and not can_delete_any:
raise HTTPException(
status_code=403,
detail="You do not have permission to delete this comment",
)
await session.delete(comment)
await session.commit()
return {"message": "Comment deleted successfully", "comment_id": comment_id}
async def get_user_mentions(
session: AsyncSession,
user: User,
search_space_id: int | None = None,
) -> MentionListResponse:
"""
Get mentions for the current user, optionally filtered by search space.
Args:
session: Database session
user: The current authenticated user
search_space_id: Optional search space ID to filter mentions
Returns:
MentionListResponse with mentions and total count
"""
# Build query with joins for filtering by search_space_id
query = (
select(ChatCommentMention)
.join(ChatComment, ChatCommentMention.comment_id == ChatComment.id)
.join(NewChatMessage, ChatComment.message_id == NewChatMessage.id)
.join(NewChatThread, NewChatMessage.thread_id == NewChatThread.id)
.options(
selectinload(ChatCommentMention.comment).selectinload(ChatComment.author),
selectinload(ChatCommentMention.comment).selectinload(ChatComment.message),
)
.filter(ChatCommentMention.mentioned_user_id == user.id)
.order_by(ChatCommentMention.created_at.desc())
)
if search_space_id is not None:
query = query.filter(NewChatThread.search_space_id == search_space_id)
result = await session.execute(query)
mention_records = result.scalars().all()
# Fetch search space info for context (single query for all unique search spaces)
thread_ids = {m.comment.message.thread_id for m in mention_records}
if thread_ids:
thread_result = await session.execute(
select(NewChatThread)
.options(selectinload(NewChatThread.search_space))
.filter(NewChatThread.id.in_(thread_ids))
)
threads_map = {t.id: t for t in thread_result.scalars().all()}
else:
threads_map = {}
mentions = []
for mention in mention_records:
comment = mention.comment
message = comment.message
thread = threads_map.get(message.thread_id)
search_space = thread.search_space if thread else None
author = None
if comment.author:
author = AuthorResponse(
id=comment.author.id,
display_name=comment.author.display_name,
avatar_url=comment.author.avatar_url,
email=comment.author.email,
)
content_preview = (
comment.content[:100] + "..."
if len(comment.content) > 100
else comment.content
)
mentions.append(
MentionResponse(
id=mention.id,
created_at=mention.created_at,
comment=MentionCommentResponse(
id=comment.id,
content_preview=content_preview,
author=author,
created_at=comment.created_at,
),
context=MentionContextResponse(
thread_id=thread.id if thread else 0,
thread_title=thread.title or "Untitled" if thread else "Unknown",
message_id=message.id,
search_space_id=search_space.id if search_space else 0,
search_space_name=search_space.name if search_space else "Unknown",
),
)
)
return MentionListResponse(
mentions=mentions,
total_count=len(mentions),
)

View file

@ -617,12 +617,83 @@ class DocumentProcessingNotificationHandler(BaseNotificationHandler):
)
class MentionNotificationHandler(BaseNotificationHandler):
"""Handler for new mention notifications."""
def __init__(self):
super().__init__("new_mention")
async def notify_new_mention(
self,
session: AsyncSession,
mentioned_user_id: UUID,
mention_id: int,
comment_id: int,
message_id: int,
thread_id: int,
thread_title: str,
author_id: str,
author_name: str,
content_preview: str,
search_space_id: int,
) -> Notification:
"""
Create notification when a user is @mentioned in a comment.
Args:
session: Database session
mentioned_user_id: User who was mentioned
mention_id: ID of the mention record
comment_id: ID of the comment containing the mention
message_id: ID of the message being commented on
thread_id: ID of the chat thread
thread_title: Title of the chat thread
author_id: ID of the comment author
author_name: Display name of the comment author
content_preview: First ~100 chars of the comment
search_space_id: Search space ID
Returns:
Notification: The created notification
"""
title = f"{author_name} mentioned you"
message = content_preview[:100] + ("..." if len(content_preview) > 100 else "")
metadata = {
"mention_id": mention_id,
"comment_id": comment_id,
"message_id": message_id,
"thread_id": thread_id,
"thread_title": thread_title,
"author_id": author_id,
"author_name": author_name,
"content_preview": content_preview[:200],
}
notification = Notification(
user_id=mentioned_user_id,
search_space_id=search_space_id,
type=self.notification_type,
title=title,
message=message,
notification_metadata=metadata,
)
session.add(notification)
await session.commit()
await session.refresh(notification)
logger.info(
f"Created new_mention notification {notification.id} for user {mentioned_user_id}"
)
return notification
class NotificationService:
"""Service for creating and managing notifications that sync via Electric SQL."""
# Handler instances
connector_indexing = ConnectorIndexingNotificationHandler()
document_processing = DocumentProcessingNotificationHandler()
mention = MentionNotificationHandler()
@staticmethod
async def create_notification(

View file

@ -0,0 +1,64 @@
"""
Utility functions for chat comments, including mention parsing.
"""
import re
from uuid import UUID
# Pattern to match @[uuid] mentions in comment content
MENTION_PATTERN = re.compile(r"@\[([0-9a-fA-F-]{36})\]")
def parse_mentions(content: str) -> list[UUID]:
"""
Extract user UUIDs from @[uuid] mentions in content.
Args:
content: Comment text that may contain @[uuid] mentions
Returns:
List of unique user UUIDs found in the content
"""
matches = MENTION_PATTERN.findall(content)
unique_uuids = []
seen = set()
for match in matches:
try:
uuid = UUID(match)
if uuid not in seen:
seen.add(uuid)
unique_uuids.append(uuid)
except ValueError:
# Invalid UUID format, skip
continue
return unique_uuids
def render_mentions(content: str, user_names: dict[UUID, str]) -> str:
"""
Replace @[uuid] mentions with @{DisplayName} in content.
Uses curly braces as delimiters for unambiguous frontend parsing.
Args:
content: Comment text with @[uuid] mentions
user_names: Dict mapping user UUIDs to display names
Returns:
Content with mentions rendered as @{DisplayName}
"""
def replace_mention(match: re.Match) -> str:
try:
uuid = UUID(match.group(1))
name = user_names.get(uuid)
if name:
return f"@{{{name}}}"
# Keep original format if user not found
return match.group(0)
except ValueError:
return match.group(0)
return MENTION_PATTERN.sub(replace_mention, content)

View file

@ -8,10 +8,11 @@ import {
} from "@assistant-ui/react";
import { useQueryClient } from "@tanstack/react-query";
import { useAtomValue, useSetAtom } from "jotai";
import { useParams } from "next/navigation";
import { useParams, useSearchParams } from "next/navigation";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
import { z } from "zod";
import { currentThreadAtom } from "@/atoms/chat/current-thread.atom";
import {
type MentionedDocumentInfo,
mentionedDocumentIdsAtom,
@ -251,6 +252,7 @@ export default function NewChatPage() {
const setMentionedDocuments = useSetAtom(mentionedDocumentsAtom);
const setMessageDocumentsMap = useSetAtom(messageDocumentsMapAtom);
const hydratePlanState = useSetAtom(hydratePlanStateAtom);
const setCurrentThreadState = useSetAtom(currentThreadAtom);
// Get current user for author info in shared chats
const { data: currentUser } = useAtomValue(currentUserAtom);
@ -365,6 +367,48 @@ export default function NewChatPage() {
initializeThread();
}, [initializeThread]);
// Handle scroll to comment from URL query params (e.g., from notification click)
const searchParams = useSearchParams();
const targetCommentId = searchParams.get("commentId");
useEffect(() => {
if (!targetCommentId || isInitializing || messages.length === 0) return;
const tryScroll = () => {
const el = document.querySelector(`[data-comment-id="${targetCommentId}"]`);
if (el) {
el.scrollIntoView({ behavior: "smooth", block: "center" });
return true;
}
return false;
};
// Try immediately
if (tryScroll()) return;
// Retry every 200ms for up to 10 seconds
const intervalId = setInterval(() => {
if (tryScroll()) clearInterval(intervalId);
}, 200);
const timeoutId = setTimeout(() => clearInterval(intervalId), 10000);
return () => {
clearInterval(intervalId);
clearTimeout(timeoutId);
};
}, [targetCommentId, isInitializing, messages.length]);
// Sync current thread state to atom
useEffect(() => {
setCurrentThreadState({
id: currentThread?.id ?? null,
visibility: currentThread?.visibility ?? null,
hasComments: currentThread?.has_comments ?? false,
addingCommentToMessageId: null,
});
}, [currentThread, setCurrentThreadState]);
// Cancel ongoing request
const cancelRun = useCallback(async () => {
if (abortControllerRef.current) {
@ -842,10 +886,32 @@ export default function NewChatPage() {
// Persist assistant message (with thinking steps for restoration on refresh)
const finalContent = buildContentForPersistence();
if (contentParts.length > 0) {
appendMessage(currentThreadId, {
role: "assistant",
content: finalContent,
}).catch((err) => console.error("Failed to persist assistant message:", err));
try {
const savedMessage = await appendMessage(currentThreadId, {
role: "assistant",
content: finalContent,
});
// Update message ID from temporary to database ID so comments work immediately
const newMsgId = `msg-${savedMessage.id}`;
setMessages((prev) =>
prev.map((m) => (m.id === assistantMsgId ? { ...m, id: newMsgId } : m))
);
// Also update thinking steps map with new ID
setMessageThinkingSteps((prev) => {
const steps = prev.get(assistantMsgId);
if (steps) {
const newMap = new Map(prev);
newMap.delete(assistantMsgId);
newMap.set(newMsgId, steps);
return newMap;
}
return prev;
});
} catch (err) {
console.error("Failed to persist assistant message:", err);
}
// Track successful response
trackChatResponseReceived(searchSpaceId, currentThreadId);
@ -860,10 +926,20 @@ export default function NewChatPage() {
);
if (hasContent && currentThreadId) {
const partialContent = buildContentForPersistence();
appendMessage(currentThreadId, {
role: "assistant",
content: partialContent,
}).catch((err) => console.error("Failed to persist partial assistant message:", err));
try {
const savedMessage = await appendMessage(currentThreadId, {
role: "assistant",
content: partialContent,
});
// Update message ID from temporary to database ID
const newMsgId = `msg-${savedMessage.id}`;
setMessages((prev) =>
prev.map((m) => (m.id === assistantMsgId ? { ...m, id: newMsgId } : m))
);
} catch (err) {
console.error("Failed to persist partial assistant message:", err);
}
}
return;
}

View file

@ -157,5 +157,33 @@ button {
cursor: pointer;
}
/* Custom scrollbar styles */
.scrollbar-thin {
scrollbar-width: thin;
scrollbar-color: hsl(var(--muted-foreground) / 0.2) transparent;
}
.scrollbar-thin:hover {
scrollbar-color: hsl(var(--muted-foreground) / 0.4) transparent;
}
/* Webkit scrollbar styles */
.scrollbar-thin::-webkit-scrollbar {
width: 6px;
}
.scrollbar-thin::-webkit-scrollbar-track {
background: transparent;
}
.scrollbar-thin::-webkit-scrollbar-thumb {
background-color: hsl(var(--muted-foreground) / 0.2);
border-radius: 3px;
}
.scrollbar-thin::-webkit-scrollbar-thumb:hover {
background-color: hsl(var(--muted-foreground) / 0.4);
}
@source '../node_modules/@llamaindex/chat-ui/**/*.{ts,tsx}';
@source '../node_modules/streamdown/dist/*.js';

View file

@ -0,0 +1,72 @@
import { atomWithMutation } from "jotai-tanstack-query";
import { toast } from "sonner";
import type {
CreateCommentRequest,
CreateReplyRequest,
DeleteCommentRequest,
UpdateCommentRequest,
} from "@/contracts/types/chat-comments.types";
import { chatCommentsApiService } from "@/lib/apis/chat-comments-api.service";
import { cacheKeys } from "@/lib/query-client/cache-keys";
import { queryClient } from "@/lib/query-client/client";
export const createCommentMutationAtom = atomWithMutation(() => ({
mutationFn: async (request: CreateCommentRequest) => {
return chatCommentsApiService.createComment(request);
},
onSuccess: (_, variables) => {
queryClient.invalidateQueries({
queryKey: cacheKeys.comments.byMessage(variables.message_id),
});
},
onError: (error: Error) => {
console.error("Error creating comment:", error);
toast.error("Failed to create comment");
},
}));
export const createReplyMutationAtom = atomWithMutation(() => ({
mutationFn: async (request: CreateReplyRequest & { message_id: number }) => {
return chatCommentsApiService.createReply(request);
},
onSuccess: (_, variables) => {
queryClient.invalidateQueries({
queryKey: cacheKeys.comments.byMessage(variables.message_id),
});
},
onError: (error: Error) => {
console.error("Error creating reply:", error);
toast.error("Failed to create reply");
},
}));
export const updateCommentMutationAtom = atomWithMutation(() => ({
mutationFn: async (request: UpdateCommentRequest & { message_id: number }) => {
return chatCommentsApiService.updateComment(request);
},
onSuccess: (_, variables) => {
queryClient.invalidateQueries({
queryKey: cacheKeys.comments.byMessage(variables.message_id),
});
},
onError: (error: Error) => {
console.error("Error updating comment:", error);
toast.error("Failed to update comment");
},
}));
export const deleteCommentMutationAtom = atomWithMutation(() => ({
mutationFn: async (request: DeleteCommentRequest & { message_id: number }) => {
return chatCommentsApiService.deleteComment(request);
},
onSuccess: (_, variables) => {
queryClient.invalidateQueries({
queryKey: cacheKeys.comments.byMessage(variables.message_id),
});
toast.success("Comment deleted");
},
onError: (error: Error) => {
console.error("Error deleting comment:", error);
toast.error("Failed to delete comment");
},
}));

View file

@ -0,0 +1,52 @@
import { atom } from "jotai";
import type { ChatVisibility } from "@/lib/chat/thread-persistence";
// TODO: Update `hasComments` to true when the first comment is created on a thread.
// Currently it only updates on thread load. The gutter still works because
// `addingCommentToMessageId` keeps it open, but the state is technically stale.
// TODO: Reset `addingCommentToMessageId` to null after a comment is successfully created.
// Currently it stays set until navigation or clicking another message's bubble.
// Not causing issues since panel visibility is driven by per-message comment count.
// TODO: Consider calling `resetCurrentThreadAtom` when unmounting the chat page
// for explicit cleanup, though React navigation handles this implicitly.
interface CurrentThreadState {
id: number | null;
visibility: ChatVisibility | null;
hasComments: boolean;
addingCommentToMessageId: number | null;
}
const initialState: CurrentThreadState = {
id: null,
visibility: null,
hasComments: false,
addingCommentToMessageId: null,
};
export const currentThreadAtom = atom<CurrentThreadState>(initialState);
export const commentsEnabledAtom = atom(
(get) => get(currentThreadAtom).visibility === "SEARCH_SPACE"
);
export const showCommentsGutterAtom = atom((get) => {
const thread = get(currentThreadAtom);
return (
thread.visibility === "SEARCH_SPACE" &&
(thread.hasComments || thread.addingCommentToMessageId !== null)
);
});
export const addingCommentToMessageIdAtom = atom(
(get) => get(currentThreadAtom).addingCommentToMessageId,
(get, set, messageId: number | null) => {
set(currentThreadAtom, { ...get(currentThreadAtom), addingCommentToMessageId: messageId });
}
);
export const resetCurrentThreadAtom = atom(null, (_, set) => {
set(currentThreadAtom, initialState);
});

View file

@ -5,9 +5,15 @@ import {
MessagePrimitive,
useAssistantState,
} from "@assistant-ui/react";
import { CheckIcon, CopyIcon, DownloadIcon, RefreshCwIcon } from "lucide-react";
import { useAtom, useAtomValue } from "jotai";
import { CheckIcon, CopyIcon, DownloadIcon, MessageSquare, RefreshCwIcon } from "lucide-react";
import type { FC } from "react";
import { useContext } from "react";
import { useContext, useEffect, useRef, useState } from "react";
import {
addingCommentToMessageIdAtom,
commentsEnabledAtom,
} from "@/atoms/chat/current-thread.atom";
import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms";
import { BranchPicker } from "@/components/assistant-ui/branch-picker";
import { MarkdownText } from "@/components/assistant-ui/markdown-text";
import {
@ -16,6 +22,12 @@ import {
} from "@/components/assistant-ui/thinking-steps";
import { ToolFallback } from "@/components/assistant-ui/tool-fallback";
import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
import { CommentPanelContainer } from "@/components/chat-comments/comment-panel-container/comment-panel-container";
import { CommentSheet } from "@/components/chat-comments/comment-sheet/comment-sheet";
import { CommentTrigger } from "@/components/chat-comments/comment-trigger/comment-trigger";
import { useComments } from "@/hooks/use-comments";
import { useMediaQuery } from "@/hooks/use-media-query";
import { cn } from "@/lib/utils";
export const MessageError: FC = () => {
return (
@ -76,13 +88,140 @@ const AssistantMessageInner: FC = () => {
);
};
function parseMessageId(assistantUiMessageId: string | undefined): number | null {
if (!assistantUiMessageId) return null;
const match = assistantUiMessageId.match(/^msg-(\d+)$/);
return match ? Number.parseInt(match[1], 10) : null;
}
export const AssistantMessage: FC = () => {
const [messageHeight, setMessageHeight] = useState<number | undefined>(undefined);
const [isSheetOpen, setIsSheetOpen] = useState(false);
const messageRef = useRef<HTMLDivElement>(null);
const messageId = useAssistantState(({ message }) => message?.id);
const searchSpaceId = useAtomValue(activeSearchSpaceIdAtom);
const dbMessageId = parseMessageId(messageId);
const commentsEnabled = useAtomValue(commentsEnabledAtom);
const [addingCommentToMessageId, setAddingCommentToMessageId] = useAtom(
addingCommentToMessageIdAtom
);
// Screen size detection for responsive comment UI
// Mobile: < 768px (bottom sheet), Medium: 768px - 1024px (right sheet), Desktop: >= 1024px (inline panel)
const isMediumScreen = useMediaQuery("(min-width: 768px) and (max-width: 1023px)");
const isDesktop = useMediaQuery("(min-width: 1024px)");
const isThreadRunning = useAssistantState(({ thread }) => thread.isRunning);
const isLastMessage = useAssistantState(({ message }) => message?.isLast ?? false);
const isMessageStreaming = isThreadRunning && isLastMessage;
const { data: commentsData } = useComments({
messageId: dbMessageId ?? 0,
enabled: !!dbMessageId,
});
const commentCount = commentsData?.total_count ?? 0;
const hasComments = commentCount > 0;
const isAddingComment = dbMessageId !== null && addingCommentToMessageId === dbMessageId;
const showCommentPanel = hasComments || isAddingComment;
const handleToggleAddComment = () => {
if (!dbMessageId) return;
setAddingCommentToMessageId(isAddingComment ? null : dbMessageId);
};
const handleCommentTriggerClick = () => {
setIsSheetOpen(true);
};
useEffect(() => {
if (!messageRef.current) return;
const el = messageRef.current;
const update = () => setMessageHeight(el.offsetHeight);
update();
const observer = new ResizeObserver(update);
observer.observe(el);
return () => observer.disconnect();
}, []);
const showCommentTrigger = searchSpaceId && commentsEnabled && !isMessageStreaming && dbMessageId;
// Determine sheet side based on screen size
const sheetSide = isMediumScreen ? "right" : "bottom";
return (
<MessagePrimitive.Root
className="aui-assistant-message-root fade-in slide-in-from-bottom-1 relative mx-auto w-full max-w-(--thread-max-width) animate-in py-3 duration-150"
ref={messageRef}
className="aui-assistant-message-root group fade-in slide-in-from-bottom-1 relative mx-auto w-full max-w-(--thread-max-width) animate-in py-3 duration-150"
data-role="assistant"
>
<AssistantMessageInner />
{/* Desktop comment panel - only on lg screens and above */}
{searchSpaceId && commentsEnabled && !isMessageStreaming && (
<div className="absolute left-full top-0 ml-4 hidden lg:block w-72">
<div
className={`sticky top-3 ${showCommentPanel ? "opacity-100" : "opacity-0 group-hover:opacity-100"} transition-opacity`}
>
{!hasComments && (
<CommentTrigger
commentCount={0}
isOpen={isAddingComment}
onClick={handleToggleAddComment}
disabled={!dbMessageId}
/>
)}
{showCommentPanel && dbMessageId && (
<div
className={
hasComments ? "" : "mt-2 animate-in fade-in slide-in-from-top-2 duration-200"
}
>
<CommentPanelContainer
messageId={dbMessageId}
isOpen={true}
maxHeight={messageHeight}
/>
</div>
)}
</div>
</div>
)}
{/* Mobile & Medium screen comment trigger - shown below lg breakpoint */}
{showCommentTrigger && !isDesktop && (
<div className="mt-2 flex justify-start">
<button
type="button"
onClick={handleCommentTriggerClick}
className={cn(
"flex items-center gap-2 rounded-full px-3 py-1.5 text-sm transition-colors",
hasComments
? "border border-primary/50 bg-primary/5 text-primary hover:bg-primary/10"
: "text-muted-foreground hover:bg-muted hover:text-foreground"
)}
>
<MessageSquare className={cn("size-4", hasComments && "fill-current")} />
{hasComments ? (
<span>{commentCount} {commentCount === 1 ? "comment" : "comments"}</span>
) : (
<span>Add comment</span>
)}
</button>
</div>
)}
{/* Comment sheet - bottom for mobile, right for medium screens */}
{showCommentTrigger && !isDesktop && (
<CommentSheet
messageId={dbMessageId}
isOpen={isSheetOpen}
onOpenChange={setIsSheetOpen}
commentCount={commentCount}
side={sheetSide}
/>
)}
</MessagePrimitive.Root>
);
};

View file

@ -23,7 +23,8 @@ export const MCPConnectForm: FC<ConnectFormProps> = ({ onSubmit, isSubmitting })
const [showDetails, setShowDetails] = useState(false);
const [testResult, setTestResult] = useState<MCPConnectionTestResult | null>(null);
const DEFAULT_CONFIG = JSON.stringify(
// Default config for stdio transport (local process)
const DEFAULT_STDIO_CONFIG = JSON.stringify(
{
name: "My MCP Server",
command: "npx",
@ -37,6 +38,22 @@ export const MCPConnectForm: FC<ConnectFormProps> = ({ onSubmit, isSubmitting })
2
);
// Default config for HTTP transport (remote server)
const DEFAULT_HTTP_CONFIG = JSON.stringify(
{
name: "My Remote MCP Server",
url: "https://your-mcp-server.com/mcp",
headers: {
"API_KEY": "your_api_key_here",
},
transport: "streamable-http",
},
null,
2
);
const DEFAULT_CONFIG = DEFAULT_STDIO_CONFIG;
const parseConfig = () => {
const result = parseMCPConfig(configJson);
if (result.error) {
@ -120,19 +137,40 @@ export const MCPConnectForm: FC<ConnectFormProps> = ({ onSubmit, isSubmitting })
return (
<div className="space-y-6 pb-6">
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20 p-2 sm:p-3 [&>svg]:top-2 sm:[&>svg]:top-3">
<div className="flex items-center gap-2">
<Server className="h-4 w-4 shrink-0" />
<AlertDescription className="text-[10px] sm:text-xs">
Connect to an MCP (Model Context Protocol) server. Each MCP server is added as a
separate connector.
</AlertDescription>
</div>
</Alert>
<Server className="h-4 w-4 shrink-0" />
<AlertDescription className="text-[10px] sm:text-xs">
Connect to an MCP (Model Context Protocol) server. Each MCP server is added as a separate connector.
</AlertDescription>
</Alert>
<form id="mcp-connect-form" onSubmit={handleSubmit} className="space-y-6">
<div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 p-4 sm:p-6 space-y-4">
<div className="space-y-2">
<Label htmlFor="config">MCP Server Configuration (JSON)</Label>
<div className="flex items-center justify-between flex-wrap gap-2">
<Label htmlFor="config">MCP Server Configuration (JSON)</Label>
{!configJson && (
<div className="flex gap-1">
<Button
type="button"
variant="ghost"
size="sm"
className="h-6 px-2 text-xs text-muted-foreground hover:text-foreground"
onClick={() => handleConfigChange(DEFAULT_STDIO_CONFIG)}
>
Local Example
</Button>
<Button
type="button"
variant="ghost"
size="sm"
className="h-6 px-2 text-xs text-muted-foreground hover:text-foreground"
onClick={() => handleConfigChange(DEFAULT_HTTP_CONFIG)}
>
Remote Example
</Button>
</div>
)}
</div>
<Textarea
id="config"
value={configJson}
@ -141,10 +179,11 @@ export const MCPConnectForm: FC<ConnectFormProps> = ({ onSubmit, isSubmitting })
rows={16}
className={`font-mono text-xs ${jsonError ? "border-red-500" : ""}`}
/>
{jsonError && <p className="text-xs text-red-500">{jsonError}</p>}
{jsonError && (
<p className="text-xs text-red-500">JSON Error: {jsonError}</p>
)}
<p className="text-[10px] sm:text-xs text-muted-foreground">
Paste a single MCP server configuration. Must include: name, command, args (optional),
env (optional), transport (optional).
Paste a single MCP server configuration. Must include: name, command, args (optional), env (optional), transport (optional).
</p>
</div>
@ -176,11 +215,9 @@ export const MCPConnectForm: FC<ConnectFormProps> = ({ onSubmit, isSubmitting })
<XCircle className="h-4 w-4 text-red-600" />
)}
<div className="flex-1">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 sm:gap-0">
<AlertTitle className="text-xs sm:text-sm">
{testResult.status === "success"
? "Connection Successful"
: "Connection Failed"}
<div className="flex items-center justify-between">
<AlertTitle className="text-sm">
{testResult.status === "success" ? "Connection Successful" : "Connection Failed"}
</AlertTitle>
{testResult.tools.length > 0 && (
<Button
@ -214,10 +251,12 @@ export const MCPConnectForm: FC<ConnectFormProps> = ({ onSubmit, isSubmitting })
{testResult.message}
{showDetails && testResult.tools.length > 0 && (
<div className="mt-3 pt-3 border-t border-green-500/20">
<p className="font-semibold mb-2 text-[10px] sm:text-xs">Available tools:</p>
<ul className="list-disc list-inside text-[10px] sm:text-xs space-y-0.5">
{testResult.tools.map((tool) => (
<li key={tool.name}>{tool.name}</li>
<p className="font-semibold mb-2">
Available tools:
</p>
<ul className="list-disc list-inside text-xs space-y-0.5">
{testResult.tools.map((tool, i) => (
<li key={i}>{tool.name}</li>
))}
</ul>
</div>

View file

@ -2,14 +2,14 @@
import { CheckCircle2, ChevronDown, ChevronUp, Server, XCircle } from "lucide-react";
import type { FC } from "react";
import { useEffect, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { EnumConnectorName } from "@/contracts/enums/connector";
import type { MCPServerConfig, MCPToolDefinition } from "@/contracts/types/mcp.types";
import type { MCPServerConfig } from "@/contracts/types/mcp.types";
import type { ConnectorConfigProps } from "../index";
import {
parseMCPConfig,
@ -28,46 +28,61 @@ export const MCPConfig: FC<MCPConfigProps> = ({ connector, onConfigChange, onNam
const [isTesting, setIsTesting] = useState(false);
const [showDetails, setShowDetails] = useState(false);
const [testResult, setTestResult] = useState<MCPConnectionTestResult | null>(null);
const initializedRef = useRef(false);
// Check if this is a valid MCP connector
const isValidConnector = connector.connector_type === EnumConnectorName.MCP_CONNECTOR;
// Initialize form from connector config (only on mount)
// We intentionally only read connector.name and connector.config on initial mount
// to preserve user edits during the session
useEffect(() => {
if (!isValidConnector || initializedRef.current) return;
initializedRef.current = true;
if (connector.name) {
setName(connector.name);
}
const serverConfig = connector.config?.server_config as MCPServerConfig | undefined;
if (serverConfig) {
// Convert server config to JSON string for editing (name is in separate field)
const configObj = {
command: serverConfig.command || "",
args: serverConfig.args || [],
env: serverConfig.env || {},
transport: serverConfig.transport || "stdio",
};
const transport = serverConfig.transport || "stdio";
// Build config object based on transport type
let configObj: Record<string, unknown>;
if (transport === "streamable-http" || transport === "http" || transport === "sse") {
// HTTP transport - use url and headers
configObj = {
url: (serverConfig as any).url || "",
headers: (serverConfig as any).headers || {},
transport: transport,
};
} else {
// stdio transport (default) - use command, args, env
configObj = {
command: (serverConfig as any).command || "",
args: (serverConfig as any).args || [],
env: (serverConfig as any).env || {},
transport: transport,
};
}
setConfigJson(JSON.stringify(configObj, null, 2));
}
}, []);
}, [isValidConnector, connector.name, connector.config?.server_config]);
// Validate that this is an MCP connector (after hooks)
if (connector.connector_type !== EnumConnectorName.MCP_CONNECTOR) {
console.error("MCPConfig received non-MCP connector:", connector.connector_type);
return (
<Alert className="border-red-500/50 bg-red-500/10">
<XCircle className="h-4 w-4 text-red-600" />
<AlertTitle>Invalid Connector Type</AlertTitle>
<AlertDescription>This component can only be used with MCP connectors.</AlertDescription>
</Alert>
);
}
const handleNameChange = useCallback(
(value: string) => {
setName(value);
if (onNameChange) {
onNameChange(value);
}
},
[onNameChange]
);
const handleNameChange = (value: string) => {
setName(value);
if (onNameChange) {
onNameChange(value);
}
};
const parseConfig = () => {
const parseConfig = useCallback(() => {
const result = parseMCPConfig(configJson);
if (result.error) {
setJsonError(result.error);
@ -75,25 +90,26 @@ export const MCPConfig: FC<MCPConfigProps> = ({ connector, onConfigChange, onNam
setJsonError(null);
}
return result.config;
};
}, [configJson]);
const handleConfigChange = (value: string) => {
setConfigJson(value);
if (jsonError) {
const handleConfigChange = useCallback(
(value: string) => {
setConfigJson(value);
setJsonError(null);
}
// Use shared utility for validation and parsing (with caching)
const result = parseMCPConfig(value);
// Use shared utility for validation and parsing (with caching)
const result = parseMCPConfig(value);
if (result.config && onConfigChange) {
// Valid config - update parent immediately
onConfigChange({ server_config: result.config });
}
// Ignore errors while typing - only show errors when user tests or saves
};
if (result.config && onConfigChange) {
// Valid config - update parent immediately
onConfigChange({ server_config: result.config });
}
// Ignore errors while typing - only show errors when user tests or saves
},
[onConfigChange]
);
const handleTestConnection = async () => {
const handleTestConnection = useCallback(async () => {
const serverConfig = parseConfig();
if (!serverConfig) {
setTestResult({
@ -115,7 +131,19 @@ export const MCPConfig: FC<MCPConfigProps> = ({ connector, onConfigChange, onNam
const result = await testMCPConnection(serverConfig);
setTestResult(result);
setIsTesting(false);
};
}, [parseConfig, jsonError, onConfigChange]);
// Validate that this is an MCP connector - must be after all hooks
if (!isValidConnector) {
console.error("MCPConfig received non-MCP connector:", connector.connector_type);
return (
<Alert className="border-red-500/50 bg-red-500/10">
<XCircle className="h-4 w-4 text-red-600" />
<AlertTitle>Invalid Connector Type</AlertTitle>
<AlertDescription>This component can only be used with MCP connectors.</AlertDescription>
</Alert>
);
}
return (
<div className="space-y-6">
@ -158,8 +186,8 @@ export const MCPConfig: FC<MCPConfigProps> = ({ connector, onConfigChange, onNam
/>
{jsonError && <p className="text-xs text-red-500">JSON Error: {jsonError}</p>}
<p className="text-[10px] sm:text-xs text-muted-foreground">
Edit your MCP server configuration. Must include: name, command, args (optional), env
(optional), transport (optional).
<strong>Local (stdio):</strong> command, args, env, transport: "stdio"<br />
<strong>Remote (HTTP):</strong> url, headers, transport: "streamable-http"
</p>
</div>

View file

@ -35,20 +35,27 @@ import { connectorsApiService } from "@/lib/apis/connectors-api.service";
/**
* Zod schema for MCP server configuration
* Provides compile-time and runtime type safety
* Supports both stdio (local process) and HTTP (remote server) transports
*
* Exported for advanced use cases (e.g., form builders)
*/
export const MCPServerConfigSchema = z.object({
const StdioConfigSchema = z.object({
name: z.string().optional(),
command: z
.string({ required_error: "Command field is required" })
.min(1, "Command cannot be empty"),
command: z.string().min(1, "Command cannot be empty"),
args: z.array(z.string()).optional().default([]),
env: z.record(z.string(), z.string()).optional().default({}),
transport: z.enum(["stdio", "sse"]).optional().default("stdio"),
transport: z.enum(["stdio"]).optional().default("stdio"),
});
const HttpConfigSchema = z.object({
name: z.string().optional(),
url: z.string().url("URL must be a valid URL"),
headers: z.record(z.string(), z.string()).optional().default({}),
transport: z.enum(["streamable-http", "http", "sse"]),
});
export const MCPServerConfigSchema = z.union([StdioConfigSchema, HttpConfigSchema]);
/**
* Shared MCP configuration validation result
*/
@ -148,12 +155,19 @@ export const parseMCPConfig = (configJson: string): MCPConfigValidationResult =>
};
}
const config: MCPServerConfig = {
command: result.data.command,
args: result.data.args,
env: result.data.env,
transport: result.data.transport,
};
// Build config based on transport type
const config: MCPServerConfig = result.data.transport === "stdio" || !result.data.transport
? {
command: (result.data as z.infer<typeof StdioConfigSchema>).command,
args: (result.data as z.infer<typeof StdioConfigSchema>).args,
env: (result.data as z.infer<typeof StdioConfigSchema>).env,
transport: "stdio" as const,
}
: {
url: (result.data as z.infer<typeof HttpConfigSchema>).url,
headers: (result.data as z.infer<typeof HttpConfigSchema>).headers,
transport: result.data.transport as "streamable-http" | "http" | "sse",
};
// Cache the successfully parsed config
configCache.set(configJson, {

View file

@ -108,7 +108,10 @@ export const ThinkingStepsDisplay: FC<{ steps: ThinkingStep[]; isThreadRunning?:
{/* Step dot - on top of line */}
<div className="relative z-10 mt-[7px] flex shrink-0 items-center justify-center">
{effectiveStatus === "in_progress" ? (
<span className="size-2 rounded-full bg-muted-foreground/30" />
<span className="relative flex size-2">
<span className="absolute inline-flex size-full animate-ping rounded-full bg-primary/60" />
<span className="relative inline-flex size-2 rounded-full bg-primary" />
</span>
) : (
<span className="size-2 rounded-full bg-muted-foreground/30" />
)}

View file

@ -26,6 +26,7 @@ import {
import { useParams } from "next/navigation";
import { type FC, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { showCommentsGutterAtom } from "@/atoms/chat/current-thread.atom";
import {
mentionedDocumentIdsAtom,
mentionedDocumentsAtom,
@ -36,6 +37,7 @@ import {
newLLMConfigsAtom,
} from "@/atoms/new-llm-config/new-llm-config-query.atoms";
import { currentUserAtom } from "@/atoms/user/user-query.atoms";
import { AssistantMessage } from "@/components/assistant-ui/assistant-message";
import { ComposerAddAttachment, ComposerAttachments } from "@/components/assistant-ui/attachment";
import { ConnectorIndicator } from "@/components/assistant-ui/connector-popup";
import {
@ -59,57 +61,63 @@ import { Button } from "@/components/ui/button";
import type { Document } from "@/contracts/types/document.types";
import { cn } from "@/lib/utils";
/**
* Props for the Thread component
*/
interface ThreadProps {
messageThinkingSteps?: Map<string, ThinkingStep[]>;
/** Optional header component to render at the top of the viewport (sticky) */
header?: React.ReactNode;
}
export const Thread: FC<ThreadProps> = ({ messageThinkingSteps = new Map(), header }) => {
return (
<ThinkingStepsContext.Provider value={messageThinkingSteps}>
<ThreadPrimitive.Root
className="aui-root aui-thread-root @container flex h-full min-h-0 flex-col bg-background"
style={{
["--thread-max-width" as string]: "44rem",
}}
>
<ThreadPrimitive.Viewport
turnAnchor="top"
className="aui-thread-viewport relative flex flex-1 min-h-0 flex-col overflow-y-auto px-4 pt-4"
>
{/* Optional sticky header for model selector etc. */}
{header && <div className="sticky top-0 z-10 mb-4">{header}</div>}
<AssistantIf condition={({ thread }) => thread.isEmpty}>
<ThreadWelcome />
</AssistantIf>
<ThreadPrimitive.Messages
components={{
UserMessage,
EditComposer,
AssistantMessage,
}}
/>
<ThreadPrimitive.ViewportFooter className="aui-thread-viewport-footer sticky bottom-0 mx-auto mt-auto flex w-full max-w-(--thread-max-width) flex-col gap-4 overflow-visible rounded-t-3xl bg-background pb-4 md:pb-6">
<ThreadScrollToBottom />
<AssistantIf condition={({ thread }) => !thread.isEmpty}>
<div className="fade-in slide-in-from-bottom-4 animate-in duration-500 ease-out fill-mode-both">
<Composer />
</div>
</AssistantIf>
</ThreadPrimitive.ViewportFooter>
</ThreadPrimitive.Viewport>
</ThreadPrimitive.Root>
<ThreadContent header={header} />
</ThinkingStepsContext.Provider>
);
};
const ThreadContent: FC<{ header?: React.ReactNode }> = ({ header }) => {
const showGutter = useAtomValue(showCommentsGutterAtom);
return (
<ThreadPrimitive.Root
className="aui-root aui-thread-root @container flex h-full min-h-0 flex-col bg-background"
style={{
["--thread-max-width" as string]: "44rem",
}}
>
<ThreadPrimitive.Viewport
turnAnchor="top"
className={cn(
"aui-thread-viewport relative flex flex-1 min-h-0 flex-col overflow-y-auto px-4 pt-4 transition-[padding] duration-300 ease-out",
showGutter && "lg:pr-30"
)}
>
{header && <div className="sticky top-0 z-10 mb-4">{header}</div>}
<AssistantIf condition={({ thread }) => thread.isEmpty}>
<ThreadWelcome />
</AssistantIf>
<ThreadPrimitive.Messages
components={{
UserMessage,
EditComposer,
AssistantMessage,
}}
/>
<ThreadPrimitive.ViewportFooter className="aui-thread-viewport-footer sticky bottom-0 z-20 mx-auto mt-auto flex w-full max-w-(--thread-max-width) flex-col gap-4 overflow-visible rounded-t-3xl bg-background pb-4 md:pb-6">
<ThreadScrollToBottom />
<AssistantIf condition={({ thread }) => !thread.isEmpty}>
<div className="fade-in slide-in-from-bottom-4 animate-in duration-500 ease-out fill-mode-both">
<Composer />
</div>
</AssistantIf>
</ThreadPrimitive.ViewportFooter>
</ThreadPrimitive.Viewport>
</ThreadPrimitive.Root>
);
};
const ThreadScrollToBottom: FC = () => {
return (
<ThreadPrimitive.ScrollToBottom asChild>
@ -579,17 +587,6 @@ const AssistantMessageInner: FC = () => {
);
};
const AssistantMessage: FC = () => {
return (
<MessagePrimitive.Root
className="aui-assistant-message-root fade-in slide-in-from-bottom-1 relative mx-auto w-full max-w-(--thread-max-width) animate-in py-3 duration-150"
data-role="assistant"
>
<AssistantMessageInner />
</MessagePrimitive.Root>
);
};
const AssistantActionBar: FC = () => {
return (
<ActionBarPrimitive.Root

View file

@ -90,7 +90,7 @@ export const UserMessage: FC = () => {
</div>
{/* User avatar - only shown in shared chats */}
{author && (
<div className="shrink-0">
<div className="shrink-0 mb-1.5">
<UserAvatar displayName={author.displayName} avatarUrl={author.avatarUrl} />
</div>
)}

View file

@ -0,0 +1,303 @@
"use client";
import { Send, X } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import { Button } from "@/components/ui/button";
import { Popover, PopoverAnchor, PopoverContent } from "@/components/ui/popover";
import { Textarea } from "@/components/ui/textarea";
import { cn } from "@/lib/utils";
import { MemberMentionPicker } from "../member-mention-picker/member-mention-picker";
import type { MemberOption } from "../member-mention-picker/types";
import type { CommentComposerProps, InsertedMention, MentionState } from "./types";
function convertDisplayToData(displayContent: string, mentions: InsertedMention[]): string {
let result = displayContent;
const sortedMentions = [...mentions].sort((a, b) => b.displayName.length - a.displayName.length);
for (const mention of sortedMentions) {
const displayPattern = new RegExp(
`@${escapeRegExp(mention.displayName)}(?=\\s|$|[.,!?;:])`,
"g"
);
const dataFormat = `@[${mention.id}]`;
result = result.replace(displayPattern, dataFormat);
}
return result;
}
function escapeRegExp(string: string): string {
return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function findMentionTrigger(
text: string,
cursorPos: number,
insertedMentions: InsertedMention[]
): { isActive: boolean; query: string; startIndex: number } {
const textBeforeCursor = text.slice(0, cursorPos);
const mentionMatch = textBeforeCursor.match(/(?:^|[\s])@([^\s]*)$/);
if (!mentionMatch) {
return { isActive: false, query: "", startIndex: 0 };
}
const fullMatch = mentionMatch[0];
const query = mentionMatch[1];
const atIndex = cursorPos - query.length - 1;
if (atIndex > 0) {
const charBefore = text[atIndex - 1];
if (charBefore && !/[\s]/.test(charBefore)) {
return { isActive: false, query: "", startIndex: 0 };
}
}
const textFromAt = text.slice(atIndex);
for (const mention of insertedMentions) {
const mentionPattern = `@${mention.displayName}`;
if (textFromAt.startsWith(mentionPattern)) {
const charAfterMention = text[atIndex + mentionPattern.length];
if (!charAfterMention || /[\s.,!?;:]/.test(charAfterMention)) {
if (cursorPos <= atIndex + mentionPattern.length) {
return { isActive: false, query: "", startIndex: 0 };
}
}
}
}
if (query.length > 50) {
return { isActive: false, query: "", startIndex: 0 };
}
return { isActive: true, query, startIndex: atIndex };
}
export function CommentComposer({
members,
membersLoading = false,
placeholder = "Write a comment...",
submitLabel = "Send",
isSubmitting = false,
onSubmit,
onCancel,
autoFocus = false,
initialValue = "",
}: CommentComposerProps) {
const [displayContent, setDisplayContent] = useState(initialValue);
const [insertedMentions, setInsertedMentions] = useState<InsertedMention[]>([]);
const [mentionsInitialized, setMentionsInitialized] = useState(false);
const [mentionState, setMentionState] = useState<MentionState>({
isActive: false,
query: "",
startIndex: 0,
});
const [highlightedIndex, setHighlightedIndex] = useState(0);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const filteredMembers = mentionState.query
? members.filter(
(member) =>
member.displayName?.toLowerCase().includes(mentionState.query.toLowerCase()) ||
member.email.toLowerCase().includes(mentionState.query.toLowerCase())
)
: members;
const closeMentionPicker = useCallback(() => {
setMentionState({ isActive: false, query: "", startIndex: 0 });
setHighlightedIndex(0);
}, []);
const insertMention = useCallback(
(member: MemberOption) => {
const displayName = member.displayName || member.email.split("@")[0];
const before = displayContent.slice(0, mentionState.startIndex);
const cursorPos = textareaRef.current?.selectionStart ?? displayContent.length;
const after = displayContent.slice(cursorPos);
const mentionText = `@${displayName} `;
const newContent = before + mentionText + after;
setDisplayContent(newContent);
setInsertedMentions((prev) => {
const exists = prev.some((m) => m.id === member.id && m.displayName === displayName);
if (exists) return prev;
return [...prev, { id: member.id, displayName }];
});
closeMentionPicker();
requestAnimationFrame(() => {
if (textareaRef.current) {
const cursorPos = before.length + mentionText.length;
textareaRef.current.focus();
textareaRef.current.setSelectionRange(cursorPos, cursorPos);
}
});
},
[displayContent, mentionState.startIndex, closeMentionPicker]
);
const handleInputChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
const value = e.target.value;
const cursorPos = e.target.selectionStart;
setDisplayContent(value);
const triggerResult = findMentionTrigger(value, cursorPos, insertedMentions);
if (triggerResult.isActive) {
setMentionState(triggerResult);
setHighlightedIndex(0);
} else if (mentionState.isActive) {
closeMentionPicker();
}
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (!mentionState.isActive) {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSubmit();
}
return;
}
switch (e.key) {
case "ArrowDown":
case "Tab":
if (!e.shiftKey) {
e.preventDefault();
setHighlightedIndex((prev) => (prev < filteredMembers.length - 1 ? prev + 1 : 0));
} else if (e.key === "Tab") {
e.preventDefault();
setHighlightedIndex((prev) => (prev > 0 ? prev - 1 : filteredMembers.length - 1));
}
break;
case "ArrowUp":
e.preventDefault();
setHighlightedIndex((prev) => (prev > 0 ? prev - 1 : filteredMembers.length - 1));
break;
case "Enter":
e.preventDefault();
if (filteredMembers[highlightedIndex]) {
insertMention(filteredMembers[highlightedIndex]);
}
break;
case "Escape":
e.preventDefault();
closeMentionPicker();
break;
}
};
const handleSubmit = () => {
const trimmed = displayContent.trim();
if (!trimmed || isSubmitting) return;
const dataContent = convertDisplayToData(trimmed, insertedMentions);
onSubmit(dataContent);
setDisplayContent("");
setInsertedMentions([]);
};
// Pre-populate insertedMentions from initialValue when members are loaded
useEffect(() => {
if (mentionsInitialized || !initialValue || members.length === 0) return;
const mentionPattern = /@([^\s@]+(?:\s+[^\s@]+)*?)(?=\s|$|[.,!?;:]|@)/g;
const foundMentions: InsertedMention[] = [];
let match: RegExpExecArray | null;
while ((match = mentionPattern.exec(initialValue)) !== null) {
const displayName = match[1];
const member = members.find(
(m) => m.displayName === displayName || m.email.split("@")[0] === displayName
);
if (member) {
const exists = foundMentions.some((m) => m.id === member.id);
if (!exists) {
foundMentions.push({ id: member.id, displayName });
}
}
}
if (foundMentions.length > 0) {
setInsertedMentions(foundMentions);
}
setMentionsInitialized(true);
}, [initialValue, members, mentionsInitialized]);
useEffect(() => {
if (autoFocus && textareaRef.current) {
textareaRef.current.focus();
}
}, [autoFocus]);
const canSubmit = displayContent.trim().length > 0 && !isSubmitting;
return (
<div className="flex flex-col gap-2">
<Popover
open={mentionState.isActive}
onOpenChange={(open) => !open && closeMentionPicker()}
modal={false}
>
<PopoverAnchor asChild>
<Textarea
ref={textareaRef}
value={displayContent}
onChange={handleInputChange}
onKeyDown={handleKeyDown}
placeholder={placeholder}
className="min-h-[80px] resize-none"
disabled={isSubmitting}
/>
</PopoverAnchor>
<PopoverContent
side="top"
align="start"
sideOffset={4}
collisionPadding={8}
className="w-72 p-0"
onOpenAutoFocus={(e) => e.preventDefault()}
>
<MemberMentionPicker
members={members}
query={mentionState.query}
highlightedIndex={highlightedIndex}
isLoading={membersLoading}
onSelect={insertMention}
onHighlightChange={setHighlightedIndex}
/>
</PopoverContent>
</Popover>
<div className="flex items-center justify-end gap-2">
{onCancel && (
<Button
type="button"
variant="ghost"
size="sm"
onClick={onCancel}
disabled={isSubmitting}
>
<X className="mr-1 size-4" />
Cancel
</Button>
)}
<Button
type="button"
size="sm"
onClick={handleSubmit}
disabled={!canSubmit}
className={cn(!canSubmit && "opacity-50")}
>
<Send className="mr-1 size-4" />
{submitLabel}
</Button>
</div>
</div>
);
}

View file

@ -0,0 +1,24 @@
import type { MemberOption } from "../member-mention-picker/types";
export interface CommentComposerProps {
members: MemberOption[];
membersLoading?: boolean;
placeholder?: string;
submitLabel?: string;
isSubmitting?: boolean;
onSubmit: (content: string) => void;
onCancel?: () => void;
autoFocus?: boolean;
initialValue?: string;
}
export interface MentionState {
isActive: boolean;
query: string;
startIndex: number;
}
export interface InsertedMention {
id: string;
displayName: string;
}

View file

@ -0,0 +1,45 @@
"use client";
import { MoreHorizontal, Pencil, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import type { CommentActionsProps } from "./types";
export function CommentActions({ canEdit, canDelete, onEdit, onDelete }: CommentActionsProps) {
if (!canEdit && !canDelete) {
return null;
}
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="size-7 opacity-0 group-hover:opacity-100 transition-opacity"
>
<MoreHorizontal className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{canEdit && (
<DropdownMenuItem onClick={onEdit}>
<Pencil className="mr-2 size-4" />
Edit
</DropdownMenuItem>
)}
{canDelete && (
<DropdownMenuItem onClick={onDelete} className="text-destructive">
<Trash2 className="mr-2 size-4" />
Delete
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
);
}

View file

@ -0,0 +1,196 @@
"use client";
import { useAtom } from "jotai";
import { MessageSquare } from "lucide-react";
import { currentUserAtom } from "@/atoms/user/user-query.atoms";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { CommentComposer } from "../comment-composer/comment-composer";
import { CommentActions } from "./comment-actions";
import type { CommentItemProps } from "./types";
function getInitials(name: string | null, email: string): string {
if (name) {
return name
.split(" ")
.map((part) => part[0])
.join("")
.toUpperCase()
.slice(0, 2);
}
return email[0].toUpperCase();
}
function formatTimestamp(dateString: string): string {
const date = new Date(dateString);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMs / 3600000);
const diffDays = Math.floor(diffMs / 86400000);
const timeStr = date.toLocaleTimeString("en-US", {
hour: "numeric",
minute: "2-digit",
hour12: true,
});
if (diffMins < 1) {
return "Just now";
}
if (diffMins < 60) {
return `${diffMins}m ago`;
}
if (diffHours < 24 && date.getDate() === now.getDate()) {
return `Today at ${timeStr}`;
}
const yesterday = new Date(now);
yesterday.setDate(yesterday.getDate() - 1);
if (date.getDate() === yesterday.getDate() && diffDays < 2) {
return `Yesterday at ${timeStr}`;
}
if (diffDays < 7) {
const dayName = date.toLocaleDateString("en-US", { weekday: "long" });
return `${dayName} at ${timeStr}`;
}
return (
date.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: date.getFullYear() !== now.getFullYear() ? "numeric" : undefined,
}) + ` at ${timeStr}`
);
}
export function convertRenderedToDisplay(contentRendered: string): string {
// Convert @{DisplayName} format to @DisplayName for editing
return contentRendered.replace(/@\{([^}]+)\}/g, "@$1");
}
function renderMentions(content: string): React.ReactNode {
// Match @{DisplayName} format from backend
const mentionPattern = /@\{([^}]+)\}/g;
const parts: React.ReactNode[] = [];
let lastIndex = 0;
let match: RegExpExecArray | null;
while ((match = mentionPattern.exec(content)) !== null) {
if (match.index > lastIndex) {
parts.push(content.slice(lastIndex, match.index));
}
// Display as @DisplayName (without curly braces)
parts.push(
<span key={match.index} className="rounded bg-primary/10 px-1 font-medium text-primary">
@{match[1]}
</span>
);
lastIndex = match.index + match[0].length;
}
if (lastIndex < content.length) {
parts.push(content.slice(lastIndex));
}
return parts.length > 0 ? parts : content;
}
export function CommentItem({
comment,
onEdit,
onEditSubmit,
onEditCancel,
onDelete,
onReply,
isReply = false,
isEditing = false,
isSubmitting = false,
members = [],
membersLoading = false,
}: CommentItemProps) {
const [{ data: currentUser }] = useAtom(currentUserAtom);
const isCurrentUser = currentUser?.id === comment.author?.id;
const displayName = isCurrentUser
? "Me"
: comment.author?.displayName || comment.author?.email.split("@")[0] || "Unknown";
const email = comment.author?.email || "";
const handleEditSubmit = (content: string) => {
onEditSubmit?.(comment.id, content);
};
return (
<div className={cn("group flex gap-3")} data-comment-id={comment.id}>
<Avatar className="size-8 shrink-0">
{comment.author?.avatarUrl && (
<AvatarImage src={comment.author.avatarUrl} alt={displayName} />
)}
<AvatarFallback className="text-xs">
{getInitials(comment.author?.displayName ?? null, email || "U")}
</AvatarFallback>
</Avatar>
<div className="flex min-w-0 flex-1 flex-col">
<div className="flex items-center gap-2">
<span className="truncate text-sm font-medium">{displayName}</span>
<span className="shrink-0 text-xs text-muted-foreground">
{formatTimestamp(comment.createdAt)}
</span>
{comment.isEdited && (
<span className="shrink-0 text-xs text-muted-foreground">(edited)</span>
)}
{!isEditing && (
<div className="ml-auto">
<CommentActions
canEdit={comment.canEdit}
canDelete={comment.canDelete}
onEdit={() => onEdit?.(comment.id)}
onDelete={() => onDelete?.(comment.id)}
/>
</div>
)}
</div>
{isEditing ? (
<div className="mt-1">
<CommentComposer
members={members}
membersLoading={membersLoading}
placeholder="Edit your comment..."
submitLabel="Save"
isSubmitting={isSubmitting}
onSubmit={handleEditSubmit}
onCancel={onEditCancel}
initialValue={convertRenderedToDisplay(comment.contentRendered)}
autoFocus
/>
</div>
) : (
<div className="mt-1 text-sm text-foreground whitespace-pre-wrap wrap-break-word">
{renderMentions(comment.contentRendered)}
</div>
)}
{!isReply && onReply && !isEditing && (
<Button
variant="ghost"
size="sm"
className="mt-1 h-7 w-fit px-2 text-xs text-muted-foreground hover:text-foreground"
onClick={() => onReply(comment.id)}
>
<MessageSquare className="mr-1 size-3" />
Reply
</Button>
)}
</div>
</div>
);
}

View file

@ -0,0 +1,44 @@
export interface CommentAuthor {
id: string;
displayName: string | null;
email: string;
avatarUrl?: string | null;
}
export interface CommentData {
id: number;
content: string;
contentRendered: string;
author: CommentAuthor | null;
createdAt: string;
updatedAt: string;
isEdited: boolean;
canEdit: boolean;
canDelete: boolean;
}
export interface CommentItemProps {
comment: CommentData;
onEdit?: (commentId: number) => void;
onEditSubmit?: (commentId: number, content: string) => void;
onEditCancel?: () => void;
onDelete?: (commentId: number) => void;
onReply?: (commentId: number) => void;
isReply?: boolean;
isEditing?: boolean;
isSubmitting?: boolean;
members?: Array<{
id: string;
displayName: string | null;
email: string;
avatarUrl?: string | null;
}>;
membersLoading?: boolean;
}
export interface CommentActionsProps {
canEdit: boolean;
canDelete: boolean;
onEdit: () => void;
onDelete: () => void;
}

View file

@ -0,0 +1,87 @@
"use client";
import { useAtom } from "jotai";
import { useMemo } from "react";
import {
createCommentMutationAtom,
createReplyMutationAtom,
deleteCommentMutationAtom,
updateCommentMutationAtom,
} from "@/atoms/chat-comments/comments-mutation.atoms";
import { membersAtom } from "@/atoms/members/members-query.atoms";
import { currentUserAtom } from "@/atoms/user/user-query.atoms";
import { useComments } from "@/hooks/use-comments";
import { CommentPanel } from "../comment-panel/comment-panel";
import type { CommentPanelContainerProps } from "./types";
import { transformComment, transformMember } from "./utils";
export function CommentPanelContainer({
messageId,
isOpen,
maxHeight,
variant = "desktop",
}: CommentPanelContainerProps) {
const { data: commentsData, isLoading: isCommentsLoading } = useComments({
messageId,
enabled: isOpen,
});
const [{ data: membersData, isLoading: isMembersLoading }] = useAtom(membersAtom);
const [{ data: currentUser }] = useAtom(currentUserAtom);
const [{ mutate: createComment, isPending: isCreating }] = useAtom(createCommentMutationAtom);
const [{ mutate: createReply, isPending: isCreatingReply }] = useAtom(createReplyMutationAtom);
const [{ mutate: updateComment, isPending: isUpdating }] = useAtom(updateCommentMutationAtom);
const [{ mutate: deleteComment, isPending: isDeleting }] = useAtom(deleteCommentMutationAtom);
const commentThreads = useMemo(() => {
if (!commentsData?.comments) return [];
return commentsData.comments.map(transformComment);
}, [commentsData]);
const members = useMemo(() => {
if (!membersData) return [];
const allMembers = membersData.map(transformMember);
// Filter out current user from mention picker
if (currentUser?.id) {
return allMembers.filter((member) => member.id !== currentUser.id);
}
return allMembers;
}, [membersData, currentUser?.id]);
const isSubmitting = isCreating || isCreatingReply || isUpdating || isDeleting;
const handleCreateComment = (content: string) => {
createComment({ message_id: messageId, content });
};
const handleCreateReply = (commentId: number, content: string) => {
createReply({ comment_id: commentId, content, message_id: messageId });
};
const handleEditComment = (commentId: number, content: string) => {
updateComment({ comment_id: commentId, content, message_id: messageId });
};
const handleDeleteComment = (commentId: number) => {
deleteComment({ comment_id: commentId, message_id: messageId });
};
if (!isOpen) return null;
return (
<CommentPanel
threads={commentThreads}
members={members}
membersLoading={isMembersLoading}
isLoading={isCommentsLoading}
onCreateComment={handleCreateComment}
onCreateReply={handleCreateReply}
onEditComment={handleEditComment}
onDeleteComment={handleDeleteComment}
isSubmitting={isSubmitting}
maxHeight={maxHeight}
variant={variant}
/>
);
}

View file

@ -0,0 +1,7 @@
export interface CommentPanelContainerProps {
messageId: number;
isOpen: boolean;
maxHeight?: number;
/** Variant for responsive styling - desktop shows border/bg, mobile is plain */
variant?: "desktop" | "mobile";
}

View file

@ -0,0 +1,55 @@
import type { Comment, CommentReply } from "@/contracts/types/chat-comments.types";
import type { Membership } from "@/contracts/types/members.types";
import type { CommentData } from "../comment-item/types";
import type { CommentThreadData } from "../comment-thread/types";
import type { MemberOption } from "../member-mention-picker/types";
export function transformAuthor(author: Comment["author"]): CommentData["author"] {
if (!author) return null;
return {
id: author.id,
displayName: author.display_name,
email: author.email,
avatarUrl: author.avatar_url,
};
}
export function transformReply(reply: CommentReply): CommentData {
return {
id: reply.id,
content: reply.content,
contentRendered: reply.content_rendered,
author: transformAuthor(reply.author),
createdAt: reply.created_at,
updatedAt: reply.updated_at,
isEdited: reply.is_edited,
canEdit: reply.can_edit,
canDelete: reply.can_delete,
};
}
export function transformComment(comment: Comment): CommentThreadData {
return {
id: comment.id,
messageId: comment.message_id,
content: comment.content,
contentRendered: comment.content_rendered,
author: transformAuthor(comment.author),
createdAt: comment.created_at,
updatedAt: comment.updated_at,
isEdited: comment.is_edited,
canEdit: comment.can_edit,
canDelete: comment.can_delete,
replyCount: comment.reply_count,
replies: comment.replies.map(transformReply),
};
}
export function transformMember(membership: Membership): MemberOption {
return {
id: membership.user_id,
displayName: membership.user_display_name ?? null,
email: membership.user_email ?? "",
avatarUrl: membership.user_avatar_url ?? null,
};
}

View file

@ -0,0 +1,124 @@
"use client";
import { MessageSquarePlus } from "lucide-react";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { CommentComposer } from "../comment-composer/comment-composer";
import { CommentThread } from "../comment-thread/comment-thread";
import type { CommentPanelProps } from "./types";
export function CommentPanel({
threads,
members,
membersLoading = false,
isLoading = false,
onCreateComment,
onCreateReply,
onEditComment,
onDeleteComment,
isSubmitting = false,
maxHeight,
variant = "desktop",
}: CommentPanelProps) {
const [isComposerOpen, setIsComposerOpen] = useState(false);
const handleCommentSubmit = (content: string) => {
onCreateComment(content);
setIsComposerOpen(false);
};
const handleComposerCancel = () => {
setIsComposerOpen(false);
};
const isMobile = variant === "mobile";
if (isLoading) {
return (
<div className={cn(
"flex min-h-[120px] items-center justify-center p-4",
!isMobile && "w-96 rounded-lg border bg-card"
)}>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<div className="size-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
Loading comments...
</div>
</div>
);
}
const hasThreads = threads.length > 0;
const showEmptyState = !hasThreads && !isComposerOpen;
// Ensure minimum usable height for empty state + composer button
const minHeight = 180;
const effectiveMaxHeight = maxHeight ? Math.max(maxHeight, minHeight) : undefined;
return (
<div
className={cn(
"flex flex-col",
isMobile ? "w-full" : "w-85 rounded-lg border bg-card"
)}
style={!isMobile && effectiveMaxHeight ? { maxHeight: effectiveMaxHeight } : undefined}
>
{hasThreads && (
<div className="min-h-0 flex-1 overflow-y-auto scrollbar-thin">
<div className="space-y-4 p-4">
{threads.map((thread) => (
<CommentThread
key={thread.id}
thread={thread}
members={members}
membersLoading={membersLoading}
onCreateReply={onCreateReply}
onEditComment={onEditComment}
onDeleteComment={onDeleteComment}
isSubmitting={isSubmitting}
/>
))}
</div>
</div>
)}
{showEmptyState && (
<div className="flex min-h-[120px] flex-col items-center justify-center gap-2 p-4 text-center">
<MessageSquarePlus className="size-8 text-muted-foreground/50" />
<p className="text-sm text-muted-foreground">No comments yet</p>
<p className="text-xs text-muted-foreground/70">
Start a conversation about this response
</p>
</div>
)}
<div className={cn(
"p-3",
showEmptyState && !isMobile && "border-t",
isMobile && "border-t"
)}>
{isComposerOpen ? (
<CommentComposer
members={members}
membersLoading={membersLoading}
placeholder="Write a comment..."
submitLabel="Comment"
isSubmitting={isSubmitting}
onSubmit={handleCommentSubmit}
onCancel={handleComposerCancel}
autoFocus
/>
) : (
<Button
variant="ghost"
className="w-full justify-start text-muted-foreground hover:text-foreground"
onClick={() => setIsComposerOpen(true)}
>
<MessageSquarePlus className="mr-2 size-4" />
Add a comment...
</Button>
)}
</div>
</div>
);
}

View file

@ -0,0 +1,17 @@
import type { CommentThreadData } from "../comment-thread/types";
import type { MemberOption } from "../member-mention-picker/types";
export interface CommentPanelProps {
threads: CommentThreadData[];
members: MemberOption[];
membersLoading?: boolean;
isLoading?: boolean;
onCreateComment: (content: string) => void;
onCreateReply: (commentId: number, content: string) => void;
onEditComment: (commentId: number, content: string) => void;
onDeleteComment: (commentId: number) => void;
isSubmitting?: boolean;
maxHeight?: number;
/** Variant for responsive styling - desktop shows border/bg, mobile is plain */
variant?: "desktop" | "mobile";
}

View file

@ -0,0 +1,64 @@
"use client";
import { MessageSquare } from "lucide-react";
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import { cn } from "@/lib/utils";
import { CommentPanelContainer } from "../comment-panel-container/comment-panel-container";
import type { CommentSheetProps } from "./types";
export function CommentSheet({
messageId,
isOpen,
onOpenChange,
commentCount = 0,
side = "bottom",
}: CommentSheetProps) {
const isBottomSheet = side === "bottom";
return (
<Sheet open={isOpen} onOpenChange={onOpenChange}>
<SheetContent
side={side}
className={cn(
"flex flex-col p-0",
isBottomSheet
? "h-[85vh] max-h-[85vh] rounded-t-xl"
: "h-full w-full max-w-md"
)}
>
{/* Drag handle indicator - only for bottom sheet */}
{isBottomSheet && (
<div className="flex justify-center pt-3 pb-1">
<div className="h-1 w-10 rounded-full bg-muted-foreground/30" />
</div>
)}
<SheetHeader className={cn(
"flex-shrink-0 border-b px-4",
isBottomSheet ? "pb-3" : "py-4"
)}>
<SheetTitle className="flex items-center gap-2 text-base font-semibold">
<MessageSquare className="size-5" />
Comments
{commentCount > 0 && (
<span className="rounded-full bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary">
{commentCount}
</span>
)}
</SheetTitle>
</SheetHeader>
<div className="min-h-0 flex-1 overflow-y-auto">
<CommentPanelContainer
messageId={messageId}
isOpen={true}
variant="mobile"
/>
</div>
</SheetContent>
</Sheet>
);
}

View file

@ -0,0 +1,8 @@
export interface CommentSheetProps {
messageId: number;
isOpen: boolean;
onOpenChange: (open: boolean) => void;
commentCount?: number;
/** Side to open the sheet from - bottom for mobile, right for medium screens */
side?: "bottom" | "right";
}

View file

@ -0,0 +1,166 @@
"use client";
import { ChevronDown, ChevronRight, MessageSquare } from "lucide-react";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { CommentComposer } from "../comment-composer/comment-composer";
import { CommentItem } from "../comment-item/comment-item";
import type { CommentThreadProps } from "./types";
export function CommentThread({
thread,
members,
membersLoading = false,
onCreateReply,
onEditComment,
onDeleteComment,
isSubmitting = false,
}: CommentThreadProps) {
const [isRepliesExpanded, setIsRepliesExpanded] = useState(true);
const [isReplyComposerOpen, setIsReplyComposerOpen] = useState(false);
const [editingCommentId, setEditingCommentId] = useState<number | null>(null);
const parentComment = {
id: thread.id,
content: thread.content,
contentRendered: thread.contentRendered,
author: thread.author,
createdAt: thread.createdAt,
updatedAt: thread.updatedAt,
isEdited: thread.isEdited,
canEdit: thread.canEdit,
canDelete: thread.canDelete,
};
const handleReply = () => {
setIsReplyComposerOpen(true);
setIsRepliesExpanded(true);
};
const handleReplySubmit = (content: string) => {
onCreateReply(thread.id, content);
setIsReplyComposerOpen(false);
};
const handleReplyCancel = () => {
setIsReplyComposerOpen(false);
};
const handleEditStart = (commentId: number) => {
setEditingCommentId(commentId);
};
const handleEditSubmit = (commentId: number, content: string) => {
onEditComment(commentId, content);
setEditingCommentId(null);
};
const handleEditCancel = () => {
setEditingCommentId(null);
};
const hasReplies = thread.replies.length > 0;
const showReplies = thread.replies.length === 1 || isRepliesExpanded;
return (
<div>
{/* Parent comment */}
<CommentItem
comment={parentComment}
onEdit={handleEditStart}
onEditSubmit={handleEditSubmit}
onEditCancel={handleEditCancel}
onDelete={onDeleteComment}
isEditing={editingCommentId === parentComment.id}
isSubmitting={isSubmitting}
members={members}
membersLoading={membersLoading}
/>
{/* Replies and actions - using flex layout with connector */}
{(hasReplies || isReplyComposerOpen) && (
<div className="flex">
{/* Connector column - vertical line */}
<div className="flex w-7 flex-col items-center">
<div className="w-px flex-1 bg-border" />
</div>
{/* Content column */}
<div className="min-w-0 flex-1 space-y-2 pb-1">
{/* Expand/collapse for multiple replies */}
{thread.replies.length > 1 && (
<Button
variant="ghost"
size="sm"
className="h-6 px-2 text-xs text-muted-foreground hover:text-foreground"
onClick={() => setIsRepliesExpanded(!isRepliesExpanded)}
>
{isRepliesExpanded ? (
<ChevronDown className="mr-1 size-3" />
) : (
<ChevronRight className="mr-1 size-3" />
)}
{thread.replies.length} replies
</Button>
)}
{/* Reply items */}
{showReplies && hasReplies && (
<div className="space-y-3 pt-2">
{thread.replies.map((reply) => (
<CommentItem
key={reply.id}
comment={reply}
isReply
onEdit={handleEditStart}
onEditSubmit={handleEditSubmit}
onEditCancel={handleEditCancel}
onDelete={onDeleteComment}
isEditing={editingCommentId === reply.id}
isSubmitting={isSubmitting}
members={members}
membersLoading={membersLoading}
/>
))}
</div>
)}
{/* Reply composer or button */}
{isReplyComposerOpen ? (
<>
<div className="pt-3">
<CommentComposer
members={members}
membersLoading={membersLoading}
placeholder="Write a reply..."
submitLabel="Reply"
isSubmitting={isSubmitting}
onSubmit={handleReplySubmit}
onCancel={handleReplyCancel}
autoFocus
/>
</div>
</>
) : (
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs" onClick={handleReply}>
<MessageSquare className="mr-1.5 size-3" />
Reply
</Button>
)}
</div>
</div>
)}
{/* Reply button when no replies yet */}
{!hasReplies && !isReplyComposerOpen && (
<div className="ml-7 mt-1">
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs" onClick={handleReply}>
<MessageSquare className="mr-1.5 size-3" />
Reply
</Button>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,27 @@
import type { CommentData } from "../comment-item/types";
import type { MemberOption } from "../member-mention-picker/types";
export interface CommentThreadData {
id: number;
messageId: number;
content: string;
contentRendered: string;
author: CommentData["author"];
createdAt: string;
updatedAt: string;
isEdited: boolean;
canEdit: boolean;
canDelete: boolean;
replyCount: number;
replies: CommentData[];
}
export interface CommentThreadProps {
thread: CommentThreadData;
members: MemberOption[];
membersLoading?: boolean;
onCreateReply: (commentId: number, content: string) => void;
onEditComment: (commentId: number, content: string) => void;
onDeleteComment: (commentId: number) => void;
isSubmitting?: boolean;
}

View file

@ -0,0 +1,36 @@
"use client";
import { MessageSquare } from "lucide-react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import type { CommentTriggerProps } from "./types";
export function CommentTrigger({ commentCount, isOpen, onClick, disabled }: CommentTriggerProps) {
const hasComments = commentCount > 0;
return (
<Button
variant={hasComments ? "outline" : isOpen ? "secondary" : "ghost"}
size="icon"
disabled={disabled}
className={cn(
"relative size-10 rounded-full transition-all duration-200",
hasComments
? "border-primary/50 bg-primary/5 text-primary hover:bg-primary/10 hover:border-primary"
: isOpen
? "text-foreground"
: "text-muted-foreground hover:text-foreground",
!hasComments && !isOpen && "opacity-0 group-hover:opacity-100",
disabled && "cursor-not-allowed opacity-50"
)}
onClick={onClick}
>
<MessageSquare className={cn("size-5", (hasComments || isOpen) && "fill-current")} />
{hasComments && (
<span className="absolute -top-1 -right-1 flex size-5 items-center justify-center rounded-full bg-primary text-[10px] font-bold text-primary-foreground">
{commentCount > 9 ? "9+" : commentCount}
</span>
)}
</Button>
);
}

View file

@ -0,0 +1,6 @@
export interface CommentTriggerProps {
commentCount: number;
isOpen: boolean;
onClick: () => void;
disabled?: boolean;
}

View file

@ -0,0 +1,49 @@
"use client";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { cn } from "@/lib/utils";
import type { MemberMentionItemProps } from "./types";
function getInitials(name: string | null, email: string): string {
if (name) {
return name
.split(" ")
.map((part) => part[0])
.join("")
.toUpperCase()
.slice(0, 2);
}
return email[0]?.toUpperCase() ?? "?";
}
export function MemberMentionItem({
member,
isHighlighted,
onSelect,
onMouseEnter,
}: MemberMentionItemProps) {
const displayName = member.displayName || member.email.split("@")[0];
return (
<button
type="button"
className={cn(
"flex w-full items-center gap-3 px-3 py-2 text-left transition-colors",
isHighlighted ? "bg-accent" : "hover:bg-accent/50"
)}
onClick={() => onSelect(member)}
onMouseEnter={onMouseEnter}
>
<Avatar className="size-7">
{member.avatarUrl && <AvatarImage src={member.avatarUrl} alt={displayName} />}
<AvatarFallback className="text-xs">
{getInitials(member.displayName, member.email)}
</AvatarFallback>
</Avatar>
<div className="flex min-w-0 flex-1 flex-col">
<span className="truncate text-sm font-medium">{displayName}</span>
<span className="truncate text-xs text-muted-foreground">{member.email}</span>
</div>
</button>
);
}

View file

@ -0,0 +1,55 @@
"use client";
import { Loader2 } from "lucide-react";
import { ScrollArea } from "@/components/ui/scroll-area";
import { MemberMentionItem } from "./member-mention-item";
import type { MemberMentionPickerProps } from "./types";
export function MemberMentionPicker({
members,
query,
highlightedIndex,
isLoading = false,
onSelect,
onHighlightChange,
}: MemberMentionPickerProps) {
const filteredMembers = query
? members.filter(
(member) =>
member.displayName?.toLowerCase().includes(query.toLowerCase()) ||
member.email.toLowerCase().includes(query.toLowerCase())
)
: members;
if (isLoading) {
return (
<div className="flex items-center justify-center py-6">
<Loader2 className="size-5 animate-spin text-muted-foreground" />
</div>
);
}
if (filteredMembers.length === 0) {
return (
<div className="px-3 py-6 text-center text-sm text-muted-foreground">
{query ? "No members found" : "No members available"}
</div>
);
}
return (
<ScrollArea className="max-h-64">
<div className="py-1">
{filteredMembers.map((member, index) => (
<MemberMentionItem
key={member.id}
member={member}
isHighlighted={index === highlightedIndex}
onSelect={onSelect}
onMouseEnter={() => onHighlightChange(index)}
/>
))}
</div>
</ScrollArea>
);
}

View file

@ -0,0 +1,22 @@
export interface MemberOption {
id: string;
displayName: string | null;
email: string;
avatarUrl?: string | null;
}
export interface MemberMentionPickerProps {
members: MemberOption[];
query: string;
highlightedIndex: number;
isLoading?: boolean;
onSelect: (member: MemberOption) => void;
onHighlightChange: (index: number) => void;
}
export interface MemberMentionItemProps {
member: MemberOption;
isHighlighted: boolean;
onSelect: (member: MemberOption) => void;
onMouseEnter: () => void;
}

View file

@ -6,8 +6,75 @@ import { useEffect, useState } from "react";
import { Logo } from "@/components/Logo";
import { ThemeTogglerComponent } from "@/components/theme/theme-toggle";
import { useGithubStars } from "@/hooks/use-github-stars";
import { AUTH_TYPE, BACKEND_URL } from "@/lib/env-config";
import { trackLoginAttempt } from "@/lib/posthog/events";
import { cn } from "@/lib/utils";
// Official Google "G" logo with brand colors
const GoogleLogo = ({ className }: { className?: string }) => (
<svg className={className} viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
fill="#4285F4"
/>
<path
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
fill="#34A853"
/>
<path
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
fill="#FBBC05"
/>
<path
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
fill="#EA4335"
/>
</svg>
);
// Sign in button component that handles both Google OAuth and local auth
const SignInButton = ({ variant = "desktop" }: { variant?: "desktop" | "mobile" }) => {
const isGoogleAuth = AUTH_TYPE === "GOOGLE";
const handleGoogleLogin = () => {
trackLoginAttempt("google");
window.location.href = `${BACKEND_URL}/auth/google/authorize-redirect`;
};
if (isGoogleAuth) {
return (
<motion.button
type="button"
onClick={handleGoogleLogin}
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
className={cn(
"flex items-center justify-center gap-2 font-semibold transition-all duration-200",
variant === "desktop"
? "hidden rounded-full bg-white px-5 py-2 text-sm text-neutral-700 shadow-md ring-1 ring-neutral-200/50 hover:shadow-lg md:flex dark:bg-neutral-900 dark:text-neutral-200 dark:ring-neutral-700/50"
: "w-full rounded-lg bg-white px-8 py-2.5 text-neutral-700 shadow-md ring-1 ring-neutral-200/50 dark:bg-neutral-900 dark:text-neutral-200 dark:ring-neutral-700/50 touch-manipulation"
)}
>
<GoogleLogo className="h-4 w-4" />
<span>Sign In</span>
</motion.button>
);
}
return (
<Link
href="/login"
className={cn(
variant === "desktop"
? "hidden rounded-full bg-black px-8 py-2 text-sm font-bold text-white shadow-[0px_-2px_0px_0px_rgba(255,255,255,0.4)_inset] md:block dark:bg-white dark:text-black"
: "w-full rounded-lg bg-black px-8 py-2 font-medium text-white shadow-[0px_-2px_0px_0px_rgba(255,255,255,0.4)_inset] dark:bg-white dark:text-black text-center touch-manipulation"
)}
>
Sign In
</Link>
);
};
export const Navbar = () => {
const [isScrolled, setIsScrolled] = useState(false);
@ -102,12 +169,7 @@ const DesktopNav = ({ navItems, isScrolled }: any) => {
)}
</Link>
<ThemeTogglerComponent />
<Link
href="/login"
className="hidden rounded-full bg-black px-8 py-2 text-sm font-bold text-white shadow-[0px_-2px_0px_0px_rgba(255,255,255,0.4)_inset] md:block dark:bg-white dark:text-black"
>
Sign In
</Link>
<SignInButton variant="desktop" />
</div>
</motion.div>
);
@ -191,12 +253,7 @@ const MobileNav = ({ navItems, isScrolled }: any) => {
</Link>
<ThemeTogglerComponent />
</div>
<Link
href="/login"
className="w-full rounded-lg bg-black px-8 py-2 font-medium text-white shadow-[0px_-2px_0px_0px_rgba(255,255,255,0.4)_inset] dark:bg-white dark:text-black text-center touch-manipulation"
>
Sign In
</Link>
<SignInButton variant="mobile" />
</motion.div>
)}
</AnimatePresence>

View file

@ -1,5 +1,6 @@
"use client";
import { useState } from "react";
import { Bell } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
@ -12,6 +13,7 @@ import { cn } from "@/lib/utils";
import { useParams } from "next/navigation";
export function NotificationButton() {
const [open, setOpen] = useState(false);
const { data: user } = useAtomValue(currentUserAtom);
const params = useParams();
@ -25,7 +27,7 @@ export function NotificationButton() {
);
return (
<Popover>
<Popover open={open} onOpenChange={setOpen}>
<Tooltip>
<TooltipTrigger asChild>
<PopoverTrigger asChild>
@ -54,6 +56,7 @@ export function NotificationButton() {
loading={loading}
markAsRead={markAsRead}
markAllAsRead={markAllAsRead}
onClose={() => setOpen(false)}
/>
</PopoverContent>
</Popover>

View file

@ -1,12 +1,14 @@
"use client";
import { Bell, CheckCheck, Loader2, AlertCircle, CheckCircle2 } from "lucide-react";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Separator } from "@/components/ui/separator";
import type { Notification } from "@/hooks/use-notifications";
import { formatDistanceToNow } from "date-fns";
import { cn } from "@/lib/utils";
import { convertRenderedToDisplay } from "@/components/chat-comments/comment-item/comment-item";
interface NotificationPopupProps {
notifications: Notification[];
@ -14,6 +16,7 @@ interface NotificationPopupProps {
loading: boolean;
markAsRead: (id: number) => Promise<boolean>;
markAllAsRead: () => Promise<boolean>;
onClose?: () => void;
}
export function NotificationPopup({
@ -22,15 +25,38 @@ export function NotificationPopup({
loading,
markAsRead,
markAllAsRead,
onClose,
}: NotificationPopupProps) {
const handleMarkAsRead = async (id: number) => {
await markAsRead(id);
};
const router = useRouter();
const handleMarkAllAsRead = async () => {
await markAllAsRead();
};
const handleNotificationClick = async (notification: Notification) => {
if (!notification.read) {
await markAsRead(notification.id);
}
if (notification.type === "new_mention") {
const metadata = notification.metadata as {
thread_id?: number;
comment_id?: number;
};
const searchSpaceId = notification.search_space_id;
const threadId = metadata?.thread_id;
const commentId = metadata?.comment_id;
if (searchSpaceId && threadId) {
const url = commentId
? `/dashboard/${searchSpaceId}/new-chat/${threadId}?commentId=${commentId}`
: `/dashboard/${searchSpaceId}/new-chat/${threadId}`;
onClose?.();
router.push(url);
}
}
};
const formatTime = (dateString: string) => {
try {
return formatDistanceToNow(new Date(dateString), { addSuffix: true });
@ -86,7 +112,7 @@ export function NotificationPopup({
<div key={notification.id}>
<button
type="button"
onClick={() => !notification.read && handleMarkAsRead(notification.id)}
onClick={() => handleNotificationClick(notification)}
className={cn(
"w-full px-4 py-3 text-left hover:bg-accent transition-colors",
!notification.read && "bg-accent/50"
@ -106,7 +132,7 @@ export function NotificationPopup({
</p>
</div>
<p className="text-[11px] text-muted-foreground break-all line-clamp-2">
{notification.message}
{convertRenderedToDisplay(notification.message)}
</p>
<div className="flex items-center justify-between mt-2">
<span className="text-[10px] text-muted-foreground">

View file

@ -0,0 +1,142 @@
import { z } from "zod";
export const author = z.object({
id: z.string().uuid(),
display_name: z.string().nullable(),
avatar_url: z.string().nullable(),
email: z.string(),
});
export const commentReply = z.object({
id: z.number(),
content: z.string(),
content_rendered: z.string(),
author: author.nullable(),
created_at: z.string(),
updated_at: z.string(),
is_edited: z.boolean(),
can_edit: z.boolean(),
can_delete: z.boolean(),
});
export const comment = z.object({
id: z.number(),
message_id: z.number(),
content: z.string(),
content_rendered: z.string(),
author: author.nullable(),
created_at: z.string(),
updated_at: z.string(),
is_edited: z.boolean(),
can_edit: z.boolean(),
can_delete: z.boolean(),
reply_count: z.number(),
replies: z.array(commentReply),
});
export const mentionContext = z.object({
thread_id: z.number(),
thread_title: z.string(),
message_id: z.number(),
search_space_id: z.number(),
search_space_name: z.string(),
});
export const mentionComment = z.object({
id: z.number(),
content_preview: z.string(),
author: author.nullable(),
created_at: z.string(),
});
export const mention = z.object({
id: z.number(),
created_at: z.string(),
comment: mentionComment,
context: mentionContext,
});
/**
* Get comments for a message
*/
export const getCommentsRequest = z.object({
message_id: z.number(),
});
export const getCommentsResponse = z.object({
comments: z.array(comment),
total_count: z.number(),
});
/**
* Create comment
*/
export const createCommentRequest = z.object({
message_id: z.number(),
content: z.string().min(1).max(5000),
});
export const createCommentResponse = comment;
/**
* Create reply
*/
export const createReplyRequest = z.object({
comment_id: z.number(),
content: z.string().min(1).max(5000),
});
export const createReplyResponse = commentReply;
/**
* Update comment
*/
export const updateCommentRequest = z.object({
comment_id: z.number(),
content: z.string().min(1).max(5000),
});
export const updateCommentResponse = commentReply;
/**
* Delete comment
*/
export const deleteCommentRequest = z.object({
comment_id: z.number(),
});
export const deleteCommentResponse = z.object({
message: z.string(),
comment_id: z.number(),
});
/**
* Get mentions
*/
export const getMentionsRequest = z.object({
search_space_id: z.number().optional(),
});
export const getMentionsResponse = z.object({
mentions: z.array(mention),
total_count: z.number(),
});
export type Author = z.infer<typeof author>;
export type CommentReply = z.infer<typeof commentReply>;
export type Comment = z.infer<typeof comment>;
export type MentionContext = z.infer<typeof mentionContext>;
export type MentionComment = z.infer<typeof mentionComment>;
export type Mention = z.infer<typeof mention>;
export type GetCommentsRequest = z.infer<typeof getCommentsRequest>;
export type GetCommentsResponse = z.infer<typeof getCommentsResponse>;
export type CreateCommentRequest = z.infer<typeof createCommentRequest>;
export type CreateCommentResponse = z.infer<typeof createCommentResponse>;
export type CreateReplyRequest = z.infer<typeof createReplyRequest>;
export type CreateReplyResponse = z.infer<typeof createReplyResponse>;
export type UpdateCommentRequest = z.infer<typeof updateCommentRequest>;
export type UpdateCommentResponse = z.infer<typeof updateCommentResponse>;
export type DeleteCommentRequest = z.infer<typeof deleteCommentRequest>;
export type DeleteCommentResponse = z.infer<typeof deleteCommentResponse>;
export type GetMentionsRequest = z.infer<typeof getMentionsRequest>;
export type GetMentionsResponse = z.infer<typeof getMentionsResponse>;

View file

@ -1,15 +1,24 @@
import { z } from "zod";
/**
* MCP Server Configuration Schema (similar to Cursor's config)
* MCP Server Configuration Schema
* Supports both stdio (local process) and HTTP (remote server) transports
*/
export const mcpServerConfig = z.object({
const stdioConfigSchema = z.object({
command: z.string().min(1, "Command is required"),
args: z.array(z.string()).default([]),
env: z.record(z.string(), z.string()).default({}),
transport: z.enum(["stdio", "sse", "http"]).default("stdio"),
transport: z.enum(["stdio"]).default("stdio"),
});
const httpConfigSchema = z.object({
url: z.string().url("URL must be a valid URL"),
headers: z.record(z.string(), z.string()).default({}),
transport: z.enum(["streamable-http", "http", "sse"]),
});
export const mcpServerConfig = z.union([stdioConfigSchema, httpConfigSchema]);
/**
* MCP Connector Schemas
*/

View file

@ -11,6 +11,8 @@ export const membership = z.object({
created_at: z.string(),
role: role.nullable().optional(),
user_email: z.string().nullable().optional(),
user_display_name: z.string().nullable().optional(),
user_avatar_url: z.string().nullable().optional(),
user_is_active: z.boolean().nullable().optional(),
});

View file

@ -5,7 +5,11 @@ import { documentTypeEnum } from "./document.types";
/**
* Notification type enum - matches backend notification types
*/
export const notificationTypeEnum = z.enum(["connector_indexing", "document_processing"]);
export const notificationTypeEnum = z.enum([
"connector_indexing",
"document_processing",
"new_mention",
]);
/**
* Notification status enum - used in metadata
@ -68,6 +72,20 @@ export const documentProcessingMetadata = baseNotificationMetadata.extend({
error_message: z.string().nullable().optional(),
});
/**
* New mention metadata schema
*/
export const newMentionMetadata = z.object({
mention_id: z.number(),
comment_id: z.number(),
message_id: z.number(),
thread_id: z.number(),
thread_title: z.string(),
author_id: z.string(),
author_name: z.string(),
content_preview: z.string(),
});
/**
* Union of all notification metadata types
* Use this when the notification type is unknown
@ -75,6 +93,7 @@ export const documentProcessingMetadata = baseNotificationMetadata.extend({
export const notificationMetadata = z.union([
connectorIndexingMetadata,
documentProcessingMetadata,
newMentionMetadata,
baseNotificationMetadata,
]);
@ -107,6 +126,11 @@ export const documentProcessingNotification = notification.extend({
metadata: documentProcessingMetadata,
});
export const newMentionNotification = notification.extend({
type: z.literal("new_mention"),
metadata: newMentionMetadata,
});
// Inferred types
export type NotificationTypeEnum = z.infer<typeof notificationTypeEnum>;
export type NotificationStatusEnum = z.infer<typeof notificationStatusEnum>;
@ -114,7 +138,9 @@ export type DocumentProcessingStageEnum = z.infer<typeof documentProcessingStage
export type BaseNotificationMetadata = z.infer<typeof baseNotificationMetadata>;
export type ConnectorIndexingMetadata = z.infer<typeof connectorIndexingMetadata>;
export type DocumentProcessingMetadata = z.infer<typeof documentProcessingMetadata>;
export type NewMentionMetadata = z.infer<typeof newMentionMetadata>;
export type NotificationMetadata = z.infer<typeof notificationMetadata>;
export type Notification = z.infer<typeof notification>;
export type ConnectorIndexingNotification = z.infer<typeof connectorIndexingNotification>;
export type DocumentProcessingNotification = z.infer<typeof documentProcessingNotification>;
export type NewMentionNotification = z.infer<typeof newMentionNotification>;

View file

@ -0,0 +1,18 @@
import { useQuery } from "@tanstack/react-query";
import { chatCommentsApiService } from "@/lib/apis/chat-comments-api.service";
import { cacheKeys } from "@/lib/query-client/cache-keys";
interface UseCommentsOptions {
messageId: number;
enabled?: boolean;
}
export function useComments({ messageId, enabled = true }: UseCommentsOptions) {
return useQuery({
queryKey: cacheKeys.comments.byMessage(messageId),
queryFn: async () => {
return chatCommentsApiService.getComments({ message_id: messageId });
},
enabled: enabled && !!messageId,
});
}

View file

@ -0,0 +1,134 @@
import {
type CreateCommentRequest,
type CreateReplyRequest,
createCommentRequest,
createCommentResponse,
createReplyRequest,
createReplyResponse,
type DeleteCommentRequest,
deleteCommentRequest,
deleteCommentResponse,
type GetCommentsRequest,
type GetMentionsRequest,
getCommentsRequest,
getCommentsResponse,
getMentionsRequest,
getMentionsResponse,
type UpdateCommentRequest,
updateCommentRequest,
updateCommentResponse,
} from "@/contracts/types/chat-comments.types";
import { ValidationError } from "@/lib/error";
import { baseApiService } from "./base-api.service";
class ChatCommentsApiService {
/**
* Get comments for a message
*/
getComments = async (request: GetCommentsRequest) => {
const parsed = getCommentsRequest.safeParse(request);
if (!parsed.success) {
const errorMessage = parsed.error.issues.map((issue) => issue.message).join(", ");
throw new ValidationError(`Invalid request: ${errorMessage}`);
}
return baseApiService.get(
`/api/v1/messages/${parsed.data.message_id}/comments`,
getCommentsResponse
);
};
/**
* Create a top-level comment
*/
createComment = async (request: CreateCommentRequest) => {
const parsed = createCommentRequest.safeParse(request);
if (!parsed.success) {
const errorMessage = parsed.error.issues.map((issue) => issue.message).join(", ");
throw new ValidationError(`Invalid request: ${errorMessage}`);
}
return baseApiService.post(
`/api/v1/messages/${parsed.data.message_id}/comments`,
createCommentResponse,
{ body: { content: parsed.data.content } }
);
};
/**
* Create a reply to a comment
*/
createReply = async (request: CreateReplyRequest) => {
const parsed = createReplyRequest.safeParse(request);
if (!parsed.success) {
const errorMessage = parsed.error.issues.map((issue) => issue.message).join(", ");
throw new ValidationError(`Invalid request: ${errorMessage}`);
}
return baseApiService.post(
`/api/v1/comments/${parsed.data.comment_id}/replies`,
createReplyResponse,
{ body: { content: parsed.data.content } }
);
};
/**
* Update a comment
*/
updateComment = async (request: UpdateCommentRequest) => {
const parsed = updateCommentRequest.safeParse(request);
if (!parsed.success) {
const errorMessage = parsed.error.issues.map((issue) => issue.message).join(", ");
throw new ValidationError(`Invalid request: ${errorMessage}`);
}
return baseApiService.put(`/api/v1/comments/${parsed.data.comment_id}`, updateCommentResponse, {
body: { content: parsed.data.content },
});
};
/**
* Delete a comment
*/
deleteComment = async (request: DeleteCommentRequest) => {
const parsed = deleteCommentRequest.safeParse(request);
if (!parsed.success) {
const errorMessage = parsed.error.issues.map((issue) => issue.message).join(", ");
throw new ValidationError(`Invalid request: ${errorMessage}`);
}
return baseApiService.delete(
`/api/v1/comments/${parsed.data.comment_id}`,
deleteCommentResponse
);
};
/**
* Get mentions for current user
*/
getMentions = async (request?: GetMentionsRequest) => {
const parsed = getMentionsRequest.safeParse(request ?? {});
if (!parsed.success) {
const errorMessage = parsed.error.issues.map((issue) => issue.message).join(", ");
throw new ValidationError(`Invalid request: ${errorMessage}`);
}
const params = new URLSearchParams();
if (parsed.data.search_space_id !== undefined) {
params.set("search_space_id", String(parsed.data.search_space_id));
}
const queryString = params.toString();
const url = queryString ? `/api/v1/mentions?${queryString}` : "/api/v1/mentions";
return baseApiService.get(url, getMentionsResponse);
};
}
export const chatCommentsApiService = new ChatCommentsApiService();

View file

@ -23,6 +23,7 @@ export interface ThreadRecord {
search_space_id: number;
created_at: string;
updated_at: string;
has_comments?: boolean;
}
export interface MessageRecord {

View file

@ -48,6 +48,10 @@ let initPromise: Promise<ElectricClient> | null = null;
// Cache for sync handles to prevent duplicate subscriptions (memory optimization)
const activeSyncHandles = new Map<string, SyncHandle>();
// Track pending sync operations to prevent race conditions
// If a sync is in progress, subsequent calls will wait for it instead of starting a new one
const pendingSyncs = new Map<string, Promise<SyncHandle>>();
// Version for sync state - increment this to force fresh sync when Electric config changes
// Set to v2 for user-specific database architecture
const SYNC_VERSION = 2;
@ -224,6 +228,19 @@ export async function initElectric(userId: string): Promise<ElectricClient> {
CREATE INDEX IF NOT EXISTS idx_documents_search_space_type ON documents(search_space_id, document_type);
`);
// Create the chat_comment_mentions table schema in PGlite
await db.exec(`
CREATE TABLE IF NOT EXISTS chat_comment_mentions (
id INTEGER PRIMARY KEY,
comment_id INTEGER NOT NULL,
mentioned_user_id TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_chat_comment_mentions_user_id ON chat_comment_mentions(mentioned_user_id);
CREATE INDEX IF NOT EXISTS idx_chat_comment_mentions_comment_id ON chat_comment_mentions(comment_id);
`);
const electricUrl = getElectricUrl();
// STEP 4: Create the client wrapper
@ -243,7 +260,16 @@ export async function initElectric(userId: string): Promise<ElectricClient> {
return existingHandle;
}
// Build params for the shape request
// Check if there's already a pending sync for this shape (prevent race condition)
const pendingSync = pendingSyncs.get(cacheKey);
if (pendingSync) {
console.log(`[Electric] Waiting for pending sync to complete: ${cacheKey}`);
return pendingSync;
}
// Create and track the sync promise to prevent race conditions
const syncPromise = (async (): Promise<SyncHandle> => {
// Build params for the shape request
// Electric SQL expects params as URL query parameters
const params: Record<string, string> = { table };
@ -394,7 +420,55 @@ export async function initElectric(userId: string): Promise<ElectricClient> {
) => Promise<{ unsubscribe: () => void; isUpToDate: boolean; stream: unknown }>;
};
};
const shape = await pgWithElectric.electric.syncShapeToTable(shapeConfig);
let shape: { unsubscribe: () => void; isUpToDate: boolean; stream: unknown };
try {
shape = await pgWithElectric.electric.syncShapeToTable(shapeConfig);
} catch (syncError) {
// Handle "Already syncing" error - pglite-sync might not have fully cleaned up yet
const errorMessage = syncError instanceof Error ? syncError.message : String(syncError);
if (errorMessage.includes("Already syncing")) {
console.warn(`[Electric] Already syncing ${table}, waiting for existing sync to settle...`);
// Wait a short time for pglite-sync to settle
await new Promise(resolve => setTimeout(resolve, 100));
// Check if an active handle now exists (another sync might have completed)
const existingHandle = activeSyncHandles.get(cacheKey);
if (existingHandle) {
console.log(`[Electric] Found existing handle after waiting: ${cacheKey}`);
return existingHandle;
}
// Retry once after waiting
console.log(`[Electric] Retrying sync for ${table}...`);
try {
shape = await pgWithElectric.electric.syncShapeToTable(shapeConfig);
} catch (retryError) {
const retryMessage = retryError instanceof Error ? retryError.message : String(retryError);
if (retryMessage.includes("Already syncing")) {
// Still syncing - create a placeholder handle that indicates the table is being synced
console.warn(`[Electric] ${table} still syncing, creating placeholder handle`);
const placeholderHandle: SyncHandle = {
unsubscribe: () => {
console.log(`[Electric] Placeholder unsubscribe for: ${cacheKey}`);
activeSyncHandles.delete(cacheKey);
},
get isUpToDate() {
return false; // We don't know the real state
},
stream: undefined,
initialSyncPromise: Promise.resolve(), // Already syncing means data should be coming
};
activeSyncHandles.set(cacheKey, placeholderHandle);
return placeholderHandle;
}
throw retryError;
}
} else {
throw syncError;
}
}
if (!shape) {
throw new Error("syncShapeToTable returned undefined");
@ -555,6 +629,18 @@ export async function initElectric(userId: string): Promise<ElectricClient> {
}
throw error;
}
})();
// Track the sync promise to prevent concurrent syncs for the same shape
pendingSyncs.set(cacheKey, syncPromise);
// Clean up the pending sync when done (whether success or failure)
syncPromise.finally(() => {
pendingSyncs.delete(cacheKey);
console.log(`[Electric] Pending sync removed for: ${cacheKey}`);
});
return syncPromise;
},
};
@ -600,8 +686,9 @@ export async function cleanupElectric(): Promise<void> {
}
}
}
// Ensure cache is empty
// Ensure caches are empty
activeSyncHandles.clear();
pendingSyncs.clear();
try {
// Close the PGlite database connection

View file

@ -72,4 +72,7 @@ export const cacheKeys = {
["connectors", "google-drive", connectorId, "folders", parentId] as const,
},
},
comments: {
byMessage: (messageId: number) => ["comments", "message", messageId] as const,
},
};