Merge pull request #628 from AnishSarkar22/fix/chatpage-ux

more improvements
This commit is contained in:
Rohan Verma 2025-12-27 12:42:41 -08:00 committed by GitHub
commit 4a011af204
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
43 changed files with 1907 additions and 317 deletions

View file

@ -75,7 +75,6 @@ surfsense_backend/lib64/
# Logs
**/*.log
**/logs/
# Temporary files
**/tmp/

View file

@ -50,6 +50,9 @@ def create_surfsense_deep_agent(
- display_image: Display images in chat
- scrape_webpage: Extract content from webpages
The agent also includes TodoListMiddleware by default (via create_deep_agent) which provides:
- write_todos: Create and update planning/todo lists for complex tasks
The system prompt can be configured via agent_config:
- Custom system instructions (or use defaults)
- Citation toggle (enable/disable citation requirements)
@ -138,6 +141,7 @@ def create_surfsense_deep_agent(
system_prompt = build_surfsense_system_prompt()
# Create the deep agent with system prompt and checkpointer
# Note: TodoListMiddleware (write_todos) is included by default in create_deep_agent
agent = create_deep_agent(
model=llm,
tools=tools,

View file

@ -64,18 +64,23 @@ You have access to the following tools:
- The preview card will automatically be displayed in the chat.
4. display_image: Display an image in the chat with metadata.
- Use this tool when you want to show an image from a URL to the user.
- Use this tool ONLY when you have a valid public HTTP/HTTPS image URL to show.
- This displays the image with an optional title, description, and source attribution.
- Common use cases:
* Showing an image from a URL mentioned in the conversation
* Displaying a diagram, chart, or illustration you're referencing
* Showing visual examples when explaining concepts
- IMPORTANT: Do NOT use this tool for user-uploaded image attachments!
* User attachments are already visible in the chat UI - the user can see them
* This tool requires a valid HTTP/HTTPS URL, not a local file path
* When a user uploads an image, just analyze it and respond - don't try to display it again
- Valid use cases:
* Showing an image from a URL the user explicitly mentioned in their message
* Displaying images found in scraped webpage content (from scrape_webpage tool)
* Showing a publicly accessible diagram or chart from a known URL
CRITICAL - NEVER USE THIS TOOL FOR USER-UPLOADED ATTACHMENTS:
When a user uploads/attaches an image file to their message:
* The image is ALREADY VISIBLE in the chat UI as a thumbnail on their message
* You do NOT have a URL for their uploaded image - only extracted text/description
* Calling display_image will FAIL and show "Image not available" error
* Simply analyze the image content and respond with your analysis - DO NOT try to display it
* The user can already see their own uploaded image - they don't need you to show it again
- Args:
- src: The URL of the image to display (must be a valid HTTP/HTTPS image URL, not a local path)
- src: The URL of the image (MUST be a valid public HTTP/HTTPS URL that you know exists)
- alt: Alternative text describing the image (for accessibility)
- title: Optional title to display below the image
- description: Optional description providing context about the image
@ -104,6 +109,20 @@ 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 to break down complex tasks.
- IMPORTANT: Use this tool when the user asks you to create a plan, break down a task, or explain something in structured steps.
- This tool creates a visual plan with progress tracking that the user can see in the UI.
- When to use:
* User asks to "create a plan" or "break down" a task
* User asks for "steps" to do something
* User asks you to "explain" something in sections
* Any multi-step task that would benefit from structured planning
- Args:
- todos: List of todo items, each with:
* content: Description of the task (required)
* status: "pending", "in_progress", or "completed" (required)
- The tool automatically adds IDs and formats the output for the UI.
</tools>
<tool_call_examples>
- User: "Fetch all my notes and what's in them?"
@ -134,8 +153,15 @@ You have access to the following tools:
- User: "Show me this image: https://example.com/image.png"
- Call: `display_image(src="https://example.com/image.png", alt="User shared image")`
- User: "Can you display a diagram of a neural network?"
- Call: `display_image(src="https://example.com/neural-network.png", alt="Neural network diagram", title="Neural Network Architecture", description="A visual representation of a neural network with input, hidden, and output layers")`
- User uploads an image file and asks: "What is this image about?"
- DO NOT call display_image! The user's uploaded image is already visible in the chat.
- Simply analyze the image content (which you receive as extracted text/description) and respond.
- WRONG: `display_image(src="...", ...)` - This will fail with "Image not available"
- CORRECT: Just provide your analysis directly: "Based on the image you shared, this appears to be..."
- User uploads a screenshot and asks: "Can you explain what's in this image?"
- DO NOT call display_image! Just analyze and respond directly.
- 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: `scrape_webpage(url="https://example.com/blog/ai-trends")`
@ -154,6 +180,34 @@ You have access to the following tools:
- 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
- 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
- User: "Break down how to learn guitar"
- Call: `write_todos(todos=[{"content": "Learn basic chords and finger positioning", "status": "in_progress"}, {"content": "Practice strumming patterns", "status": "pending"}, {"content": "Learn to read tabs and sheet music", "status": "pending"}, {"content": "Master simple songs", "status": "pending"}])`
- Then provide learning milestones and tips
- User: "Plan my workout routine for the week"
- Call: `write_todos(todos=[{"content": "Monday: Upper body strength training", "status": "in_progress"}, {"content": "Tuesday: Cardio and core workout", "status": "pending"}, {"content": "Wednesday: Rest or light stretching", "status": "pending"}, {"content": "Thursday: Lower body strength training", "status": "pending"}, {"content": "Friday: Full body HIIT session", "status": "pending"}])`
- Then provide exercise details and tips
- User: "Help me organize my home renovation project"
- Call: `write_todos(todos=[{"content": "Define scope and create budget", "status": "in_progress"}, {"content": "Research and hire contractors", "status": "pending"}, {"content": "Obtain necessary permits", "status": "pending"}, {"content": "Order materials and fixtures", "status": "pending"}, {"content": "Execute renovation phases", "status": "pending"}])`
- Then provide detailed renovation guidance
- User: "What steps should I take to start a podcast?"
- Call: `write_todos(todos=[{"content": "Define podcast concept and target audience", "status": "in_progress"}, {"content": "Set up recording equipment and software", "status": "pending"}, {"content": "Plan episode structure and content", "status": "pending"}, {"content": "Record and edit first episodes", "status": "pending"}, {"content": "Choose hosting platform and publish", "status": "pending"}])`
- Then provide podcast launch guidance
</tool_call_examples>
"""

View file

@ -280,15 +280,18 @@ def create_link_preview_tool():
url = f"https://{url}"
try:
# Generate a random User-Agent to avoid bot detection
ua = UserAgent()
user_agent = ua.random
# Use a browser-like User-Agent to fetch Open Graph metadata.
# This is the same approach used by Slack, Discord, Twitter, etc. for link previews.
# We're only fetching publicly available metadata (title, description, thumbnail)
# that websites intentionally expose via OG tags for link preview purposes.
async with httpx.AsyncClient(
timeout=10.0,
follow_redirects=True,
headers={
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
"User-Agent": user_agent,
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8",
"Accept-Language": "en-US,en;q=0.9",
"Accept-Encoding": "gzip, deflate, br",

View file

@ -125,6 +125,7 @@ BUILTIN_TOOLS: list[ToolDefinition] = [
),
requires=[], # firecrawl_api_key is optional
),
# Note: write_todos is now provided by TodoListMiddleware from deepagents
# =========================================================================
# ADD YOUR CUSTOM TOOLS BELOW
# =========================================================================

View file

@ -25,7 +25,9 @@ class WebCrawlerConnector:
Initialize the WebCrawlerConnector class.
Args:
firecrawl_api_key: Firecrawl API key (optional, will use AsyncChromiumLoader if not provided)
firecrawl_api_key: Firecrawl API key (optional). If provided, Firecrawl will be tried first
and Chromium will be used as fallback if Firecrawl fails. If not provided,
Chromium will be used directly.
"""
self.firecrawl_api_key = firecrawl_api_key
self.use_firecrawl = bool(firecrawl_api_key)
@ -46,6 +48,9 @@ class WebCrawlerConnector:
"""
Crawl a single URL and extract its content.
If Firecrawl API key is provided, tries Firecrawl first and falls back to Chromium
if Firecrawl fails. If no Firecrawl API key is provided, uses Chromium directly.
Args:
url: URL to crawl
formats: List of formats to extract (e.g., ["markdown", "html"]) - only for Firecrawl
@ -56,19 +61,37 @@ class WebCrawlerConnector:
- content: Extracted content (markdown or HTML)
- metadata: Page metadata (title, description, etc.)
- source: Original URL
- crawler_type: Type of crawler used
- crawler_type: Type of crawler used ("firecrawl" or "chromium")
"""
try:
# Validate URL
if not validators.url(url):
return None, f"Invalid URL: {url}"
# Try Firecrawl first if API key is provided
if self.use_firecrawl:
result = await self._crawl_with_firecrawl(url, formats)
try:
logger.info(f"[webcrawler] Using Firecrawl for: {url}")
result = await self._crawl_with_firecrawl(url, formats)
return result, None
except Exception as firecrawl_error:
# Firecrawl failed, fallback to Chromium
logger.warning(
f"[webcrawler] Firecrawl failed, falling back to Chromium+Trafilatura for: {url}"
)
try:
result = await self._crawl_with_chromium(url)
return result, None
except Exception as chromium_error:
return (
None,
f"Both Firecrawl and Chromium failed. Firecrawl error: {firecrawl_error!s}, Chromium error: {chromium_error!s}",
)
else:
# No Firecrawl API key, use Chromium directly
logger.info(f"[webcrawler] Using Chromium+Trafilatura for: {url}")
result = await self._crawl_with_chromium(url)
return result, None
return result, None
except Exception as e:
return None, f"Error crawling URL {url}: {e!s}"
@ -162,10 +185,6 @@ class WebCrawlerConnector:
trafilatura_metadata = None
try:
logger.info(
f"Attempting to extract main content from {url} using Trafilatura"
)
# Extract main content as markdown
extracted_content = trafilatura.extract(
raw_html,
@ -179,23 +198,10 @@ class WebCrawlerConnector:
# Extract metadata using Trafilatura
trafilatura_metadata = trafilatura.extract_metadata(raw_html)
if extracted_content and len(extracted_content.strip()) > 0:
logger.info(
f"Successfully extracted main content from {url} using Trafilatura "
f"({len(extracted_content)} chars vs {len(raw_html)} chars raw HTML)"
)
else:
logger.warning(
f"Trafilatura extraction returned empty content for {url}, "
"falling back to raw HTML"
)
if not extracted_content or len(extracted_content.strip()) == 0:
extracted_content = None
except Exception as e:
logger.warning(
f"Trafilatura extraction failed for {url}: {e}. "
"Falling back to raw HTML"
)
except Exception:
extracted_content = None
# Build metadata, preferring Trafilatura metadata when available

View file

@ -319,6 +319,9 @@ async def get_logs_summary(
if log.log_metadata
else "Unknown"
)
document_id = (
log.log_metadata.get("document_id") if log.log_metadata else None
)
summary["active_tasks"].append(
{
"id": log.id,
@ -326,6 +329,7 @@ async def get_logs_summary(
"message": log.message,
"started_at": log.created_at,
"source": log.source,
"document_id": document_id,
}
)

View file

@ -69,6 +69,30 @@ def format_mentioned_documents_as_context(documents: list[Document]) -> str:
return "\n".join(context_parts)
def extract_todos_from_deepagents(command_output) -> dict:
"""
Extract todos from deepagents' TodoListMiddleware Command output.
deepagents returns a Command object with:
- Command.update['todos'] = [{'content': '...', 'status': '...'}]
Returns the todos directly (no transformation needed - UI matches deepagents format).
"""
todos_data = []
if hasattr(command_output, "update"):
# It's a Command object from deepagents
update = command_output.update
todos_data = update.get("todos", [])
elif isinstance(command_output, dict):
# Already a dict - check if it has todos directly or in update
if "todos" in command_output:
todos_data = command_output.get("todos", [])
elif "update" in command_output and isinstance(command_output["update"], dict):
todos_data = command_output["update"].get("todos", [])
return {"todos": todos_data}
async def stream_new_chat(
user_query: str,
search_space_id: int,
@ -146,6 +170,16 @@ async def stream_new_chat(
# Create connector service
connector_service = ConnectorService(session, search_space_id=search_space_id)
# Get Firecrawl API key from webcrawler connector if configured
from app.db import SearchSourceConnectorType
firecrawl_api_key = None
webcrawler_connector = await connector_service.get_connector_by_type(
SearchSourceConnectorType.WEBCRAWLER_CONNECTOR, search_space_id
)
if webcrawler_connector and webcrawler_connector.config:
firecrawl_api_key = webcrawler_connector.config.get("FIRECRAWL_API_KEY")
# Get the PostgreSQL checkpointer for persistent conversation memory
checkpointer = await get_checkpointer()
@ -157,6 +191,7 @@ async def stream_new_chat(
connector_service=connector_service,
checkpointer=checkpointer,
agent_config=agent_config, # Pass prompt configuration
firecrawl_api_key=firecrawl_api_key, # Pass Firecrawl API key if configured
)
# Build input with message history from frontend
@ -211,7 +246,8 @@ async def stream_new_chat(
config = {
"configurable": {
"thread_id": str(chat_id),
}
},
"recursion_limit": 80, # Increase from default 25 to allow more tool iterations
}
# Start the message stream
@ -233,6 +269,8 @@ async def stream_new_chat(
completed_step_ids: set[str] = set()
# Track if we just finished a tool (text flows silently after tools)
just_finished_tool: bool = False
# Track write_todos calls to show "Creating plan" vs "Updating plan"
write_todos_call_count: int = 0
def next_thinking_step_id() -> str:
nonlocal thinking_step_counter
@ -441,6 +479,60 @@ async def stream_new_chat(
status="in_progress",
items=last_active_step_items,
)
elif tool_name == "write_todos":
# Track write_todos calls for better messaging
write_todos_call_count += 1
todos = (
tool_input.get("todos", [])
if isinstance(tool_input, dict)
else []
)
todo_count = len(todos) if isinstance(todos, list) else 0
if write_todos_call_count == 1:
# First call - creating the plan
last_active_step_title = "Creating plan"
last_active_step_items = [f"Defining {todo_count} tasks..."]
else:
# Subsequent calls - updating the plan
# Try to provide context about what's being updated
in_progress_count = (
sum(
1
for t in todos
if isinstance(t, dict)
and t.get("status") == "in_progress"
)
if isinstance(todos, list)
else 0
)
completed_count = (
sum(
1
for t in todos
if isinstance(t, dict)
and t.get("status") == "completed"
)
if isinstance(todos, list)
else 0
)
last_active_step_title = "Updating progress"
last_active_step_items = (
[
f"Progress: {completed_count}/{todo_count} completed",
f"In progress: {in_progress_count} tasks",
]
if completed_count > 0
else [f"Working on {todo_count} tasks"]
)
yield streaming_service.format_thinking_step(
step_id=tool_step_id,
title=last_active_step_title,
status="in_progress",
items=last_active_step_items,
)
elif tool_name == "generate_podcast":
podcast_title = (
tool_input.get("podcast_title", "SurfSense Podcast")
@ -465,6 +557,15 @@ async def stream_new_chat(
status="in_progress",
items=last_active_step_items,
)
# elif tool_name == "ls":
# last_active_step_title = "Exploring files"
# last_active_step_items = []
# yield streaming_service.format_thinking_step(
# step_id=tool_step_id,
# title="Exploring files",
# status="in_progress",
# items=None,
# )
else:
last_active_step_title = f"Using {tool_name.replace('_', ' ')}"
last_active_step_items = []
@ -546,9 +647,11 @@ async def stream_new_chat(
tool_name = event.get("name", "unknown_tool")
raw_output = event.get("data", {}).get("output", "")
# Extract content from ToolMessage if needed
# LangGraph may return a ToolMessage object instead of raw dict
if hasattr(raw_output, "content"):
# Handle deepagents' write_todos Command object specially
if tool_name == "write_todos" and hasattr(raw_output, "update"):
# deepagents returns a Command object - extract todos directly
tool_output = extract_todos_from_deepagents(raw_output)
elif hasattr(raw_output, "content"):
# It's a ToolMessage object - extract the content
content = raw_output.content
# If content is a string that looks like JSON, try to parse it
@ -707,6 +810,104 @@ async def stream_new_chat(
status="completed",
items=completed_items,
)
elif tool_name == "write_todos":
# Build completion items for planning/updating
if isinstance(tool_output, dict):
todos = tool_output.get("todos", [])
todo_count = len(todos) if isinstance(todos, list) else 0
completed_count = (
sum(
1
for t in todos
if isinstance(t, dict)
and t.get("status") == "completed"
)
if isinstance(todos, list)
else 0
)
in_progress_count = (
sum(
1
for t in todos
if isinstance(t, dict)
and t.get("status") == "in_progress"
)
if isinstance(todos, list)
else 0
)
# Use context-aware completion message
if last_active_step_title == "Creating plan":
completed_items = [f"Created {todo_count} tasks"]
else:
# Updating progress - show stats
completed_items = [
f"Progress: {completed_count}/{todo_count} completed",
]
if in_progress_count > 0:
# Find the currently in-progress task name
in_progress_task = next(
(
t.get("content", "")[:40]
for t in todos
if isinstance(t, dict)
and t.get("status") == "in_progress"
),
None,
)
if in_progress_task:
completed_items.append(
f"Current: {in_progress_task}..."
)
else:
completed_items = ["Plan updated"]
yield streaming_service.format_thinking_step(
step_id=original_step_id,
title=last_active_step_title,
status="completed",
items=completed_items,
)
elif tool_name == "ls":
# Build completion items showing file names found
if isinstance(tool_output, dict):
result = tool_output.get("result", "")
elif isinstance(tool_output, str):
result = tool_output
else:
result = str(tool_output) if tool_output else ""
# Parse file paths and extract just the file names
file_names = []
if result:
# The ls tool returns paths, extract just the file/folder names
for line in result.strip().split("\n"):
line = line.strip()
if line:
# Get just the filename from the path
name = line.rstrip("/").split("/")[-1]
if name and len(name) <= 40:
file_names.append(name)
elif name:
file_names.append(name[:37] + "...")
# Build display items - wrap file names in brackets for icon rendering
if file_names:
if len(file_names) <= 5:
# Wrap each file name in brackets for styled tile rendering
completed_items = [f"[{name}]" for name in file_names]
else:
# Show first few with brackets and count
completed_items = [f"[{name}]" for name in file_names[:4]]
completed_items.append(f"(+{len(file_names) - 4} more)")
else:
completed_items = ["No files found"]
yield streaming_service.format_thinking_step(
step_id=original_step_id,
title="Exploring files",
status="completed",
items=completed_items,
)
else:
yield streaming_service.format_thinking_step(
step_id=original_step_id,
@ -843,6 +1044,27 @@ async def stream_new_chat(
yield streaming_service.format_terminal_info(
"Knowledge base search completed", "success"
)
elif tool_name == "write_todos":
# Stream the full write_todos result so frontend can render the Plan component
yield streaming_service.format_tool_output_available(
tool_call_id,
tool_output
if isinstance(tool_output, dict)
else {"result": tool_output},
)
# Send terminal message with plan info
if isinstance(tool_output, dict):
todos = tool_output.get("todos", [])
todo_count = len(todos) if isinstance(todos, list) else 0
yield streaming_service.format_terminal_info(
f"Plan created ({todo_count} tasks)",
"success",
)
else:
yield streaming_service.format_terminal_info(
"Plan created",
"success",
)
else:
# Default handling for other tools
yield streaming_service.format_tool_output_available(

View file

@ -30,7 +30,7 @@ export default function AirtableConnectorPage() {
const [isConnecting, setIsConnecting] = useState(false);
const [doesConnectorExist, setDoesConnectorExist] = useState(false);
const { refetch : fetchConnectors } = useAtomValue(connectorsAtom);
const { refetch: fetchConnectors } = useAtomValue(connectorsAtom);
useEffect(() => {
fetchConnectors().then((data) => {

View file

@ -32,13 +32,14 @@ export default function GoogleCalendarConnectorPage() {
const [isConnecting, setIsConnecting] = useState(false);
const [doesConnectorExist, setDoesConnectorExist] = useState(false);
const { refetch : fetchConnectors } = useAtomValue(connectorsAtom);
const { refetch: fetchConnectors } = useAtomValue(connectorsAtom);
useEffect(() => {
fetchConnectors().then((data) => {
const connectors = data.data || [];
const connector = connectors.find(
(c: SearchSourceConnector) => c.connector_type === EnumConnectorName.GOOGLE_CALENDAR_CONNECTOR
(c: SearchSourceConnector) =>
c.connector_type === EnumConnectorName.GOOGLE_CALENDAR_CONNECTOR
);
if (connector) {
setDoesConnectorExist(true);

View file

@ -32,7 +32,7 @@ export default function GoogleGmailConnectorPage() {
const [isConnecting, setIsConnecting] = useState(false);
const [doesConnectorExist, setDoesConnectorExist] = useState(false);
const { refetch : fetchConnectors } = useAtomValue(connectorsAtom);
const { refetch: fetchConnectors } = useAtomValue(connectorsAtom);
useEffect(() => {
fetchConnectors().then((data) => {

View file

@ -67,7 +67,7 @@ export default function LumaConnectorPage() {
},
});
const { refetch : fetchConnectors } = useAtomValue(connectorsAtom);
const { refetch: fetchConnectors } = useAtomValue(connectorsAtom);
useEffect(() => {
fetchConnectors().then((data) => {

View file

@ -55,7 +55,7 @@ export default function WebcrawlerConnectorPage() {
const [isSubmitting, setIsSubmitting] = useState(false);
const [doesConnectorExist, setDoesConnectorExist] = useState(false);
const { refetch : fetchConnectors } = useAtomValue(connectorsAtom);
const { refetch: fetchConnectors } = useAtomValue(connectorsAtom);
const { mutateAsync: createConnector } = useAtomValue(createConnectorMutationAtom);
// Initialize the form

View file

@ -0,0 +1,43 @@
"use client";
import { Loader2 } from "lucide-react";
import { motion, AnimatePresence } from "motion/react";
import { useTranslations } from "next-intl";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
interface ProcessingIndicatorProps {
activeTasksCount: number;
}
export function ProcessingIndicator({ activeTasksCount }: ProcessingIndicatorProps) {
const t = useTranslations("documents");
if (activeTasksCount === 0) return null;
return (
<AnimatePresence>
<motion.div
initial={{ opacity: 0, height: 0, marginBottom: 0 }}
animate={{ opacity: 1, height: "auto", marginBottom: 24 }}
exit={{ opacity: 0, height: 0, marginBottom: 0 }}
transition={{ duration: 0.3 }}
>
<Alert className="border-border bg-primary/5">
<div className="flex items-center gap-4">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-primary/10">
<Loader2 className="h-5 w-5 animate-spin text-primary" />
</div>
<div className="flex-1">
<AlertTitle className="text-primary font-semibold">
{t("processing_documents")}
</AlertTitle>
<AlertDescription className="text-muted-foreground">
{t("active_tasks_count", { count: activeTasksCount })}
</AlertDescription>
</div>
</div>
</Alert>
</motion.div>
</AnimatePresence>
);
}

View file

@ -2,11 +2,14 @@
import { useQuery } from "@tanstack/react-query";
import { useAtomValue } from "jotai";
import { RefreshCw } from "lucide-react";
import { motion } from "motion/react";
import { useParams } from "next/navigation";
import { useTranslations } from "next-intl";
import { useCallback, useEffect, useId, useMemo, useState } from "react";
import { useCallback, useEffect, useId, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
import { useLogsSummary } from "@/hooks/use-logs";
import { Button } from "@/components/ui/button";
import { deleteDocumentMutationAtom } from "@/atoms/documents/document-mutation.atoms";
import { documentTypeCountsAtom } from "@/atoms/documents/document-query.atoms";
import type { DocumentTypeEnum } from "@/contracts/types/document.types";
@ -15,6 +18,7 @@ import { cacheKeys } from "@/lib/query-client/cache-keys";
import { DocumentsFilters } from "./components/DocumentsFilters";
import { DocumentsTableShell, type SortKey } from "./components/DocumentsTableShell";
import { PaginationControls } from "./components/PaginationControls";
import { ProcessingIndicator } from "./components/ProcessingIndicator";
import type { ColumnVisibility } from "./components/types";
function useDebounced<T>(value: T, delay = 250) {
@ -127,7 +131,22 @@ export default function DocumentsTable() {
} else {
await refetchDocuments();
}
}, [debouncedSearch, refetchSearch, refetchDocuments]);
toast.success(t("refresh_success") || "Documents refreshed");
}, [debouncedSearch, refetchSearch, refetchDocuments, t]);
// Set up polling for active tasks
const { summary } = useLogsSummary(searchSpaceId, 24, { refetchInterval: 5000 });
const activeTasksCount = summary?.active_tasks.length || 0;
const prevActiveTasksCount = useRef(activeTasksCount);
// Auto-refresh when a task finishes
useEffect(() => {
if (prevActiveTasksCount.current > activeTasksCount) {
// A task has finished!
refreshCurrentView();
}
prevActiveTasksCount.current = activeTasksCount;
}, [activeTasksCount, refreshCurrentView]);
// Create a delete function for single document deletion
const deleteDocument = useCallback(
@ -189,8 +208,26 @@ export default function DocumentsTable() {
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
className="w-full px-6 py-4 min-h-[calc(100vh-64px)]"
className="w-full px-6 py-4 space-y-6 min-h-[calc(100vh-64px)]"
>
<motion.div
className="flex items-center justify-between"
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 }}
>
<div>
<h2 className="text-2xl font-bold tracking-tight">{t("title")}</h2>
<p className="text-muted-foreground">{t("subtitle")}</p>
</div>
<Button onClick={refreshCurrentView} variant="outline" size="sm">
<RefreshCw className="w-4 h-4 mr-2" />
{t("refresh")}
</Button>
</motion.div>
<ProcessingIndicator activeTasksCount={activeTasksCount} />
<DocumentsFilters
typeCounts={typeCounts ?? {}}
selectedIds={selectedIds}

View file

@ -6,6 +6,7 @@ import {
type ThreadMessageLike,
useExternalStoreRuntime,
} from "@assistant-ui/react";
import { useQueryClient } from "@tanstack/react-query";
import { useAtomValue, useSetAtom } from "jotai";
import { useParams } from "next/navigation";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
@ -17,6 +18,11 @@ import {
mentionedDocumentsAtom,
messageDocumentsMapAtom,
} from "@/atoms/chat/mentioned-documents.atom";
import {
clearPlanOwnerRegistry,
extractWriteTodosFromContent,
hydratePlanStateAtom,
} from "@/atoms/chat/plan-state.atom";
import { Thread } from "@/components/assistant-ui/thread";
import { ChatHeader } from "@/components/new-chat/chat-header";
import type { ThinkingStep } from "@/components/tool-ui/deepagent-thinking";
@ -24,6 +30,7 @@ import { DisplayImageToolUI } from "@/components/tool-ui/display-image";
import { GeneratePodcastToolUI } from "@/components/tool-ui/generate-podcast";
import { LinkPreviewToolUI } from "@/components/tool-ui/link-preview";
import { ScrapeWebpageToolUI } from "@/components/tool-ui/scrape-webpage";
import { WriteTodosToolUI } from "@/components/tool-ui/write-todos";
import { getBearerToken } from "@/lib/auth-utils";
import { createAttachmentAdapter, extractAttachmentContent } from "@/lib/chat/attachment-adapter";
import {
@ -91,9 +98,45 @@ function extractMentionedDocuments(content: unknown): MentionedDocumentInfo[] {
return [];
}
/**
* Zod schema for persisted attachment info
*/
const PersistedAttachmentSchema = z.object({
id: z.string(),
name: z.string(),
type: z.string(),
contentType: z.string().optional(),
imageDataUrl: z.string().optional(),
extractedContent: z.string().optional(),
});
const AttachmentsPartSchema = z.object({
type: z.literal("attachments"),
items: z.array(PersistedAttachmentSchema),
});
type PersistedAttachment = z.infer<typeof PersistedAttachmentSchema>;
/**
* Extract persisted attachments from message content (type-safe with Zod)
*/
function extractPersistedAttachments(content: unknown): PersistedAttachment[] {
if (!Array.isArray(content)) return [];
for (const part of content) {
const result = AttachmentsPartSchema.safeParse(part);
if (result.success) {
return result.data.items;
}
}
return [];
}
/**
* Convert backend message to assistant-ui ThreadMessageLike format
* Filters out 'thinking-steps' part as it's handled separately via messageThinkingSteps
* Restores attachments for user messages from persisted data
*/
function convertToThreadMessage(msg: MessageRecord): ThreadMessageLike {
let content: ThreadMessageLike["content"];
@ -105,8 +148,12 @@ function convertToThreadMessage(msg: MessageRecord): ThreadMessageLike {
const filteredContent = msg.content.filter((part: unknown) => {
if (typeof part !== "object" || part === null || !("type" in part)) return true;
const partType = (part as { type: string }).type;
// Filter out thinking-steps and mentioned-documents
return partType !== "thinking-steps" && partType !== "mentioned-documents";
// Filter out thinking-steps, mentioned-documents, and attachments
return (
partType !== "thinking-steps" &&
partType !== "mentioned-documents" &&
partType !== "attachments"
);
});
content =
filteredContent.length > 0
@ -116,11 +163,31 @@ function convertToThreadMessage(msg: MessageRecord): ThreadMessageLike {
content = [{ type: "text", text: String(msg.content) }];
}
// Restore attachments for user messages
let attachments: ThreadMessageLike["attachments"];
if (msg.role === "user") {
const persistedAttachments = extractPersistedAttachments(msg.content);
if (persistedAttachments.length > 0) {
attachments = persistedAttachments.map((att) => ({
id: att.id,
name: att.name,
type: att.type as "document" | "image" | "file",
contentType: att.contentType || "application/octet-stream",
status: { type: "complete" as const },
content: [],
// Custom fields for our ChatAttachment interface
imageDataUrl: att.imageDataUrl,
extractedContent: att.extractedContent,
}));
}
}
return {
id: `msg-${msg.id}`,
role: msg.role,
content,
createdAt: new Date(msg.created_at),
attachments,
};
}
@ -132,6 +199,7 @@ const TOOLS_WITH_UI = new Set([
"link_preview",
"display_image",
"scrape_webpage",
"write_todos",
]);
/**
@ -146,6 +214,7 @@ interface ThinkingStepData {
export default function NewChatPage() {
const params = useParams();
const queryClient = useQueryClient();
const [isInitializing, setIsInitializing] = useState(true);
const [threadId, setThreadId] = useState<number | null>(null);
const [messages, setMessages] = useState<ThreadMessageLike[]>([]);
@ -163,6 +232,7 @@ export default function NewChatPage() {
const setMentionedDocumentIds = useSetAtom(mentionedDocumentIdsAtom);
const setMentionedDocuments = useSetAtom(mentionedDocumentsAtom);
const setMessageDocumentsMap = useSetAtom(messageDocumentsMapAtom);
const hydratePlanState = useSetAtom(hydratePlanStateAtom);
// Create the attachment adapter for file processing
const attachmentAdapter = useMemo(() => createAttachmentAdapter(), []);
@ -198,6 +268,7 @@ export default function NewChatPage() {
setMentionedDocumentIds([]);
setMentionedDocuments([]);
setMessageDocumentsMap({});
clearPlanOwnerRegistry(); // Reset plan ownership for new chat
try {
if (urlChatId > 0) {
@ -219,6 +290,11 @@ export default function NewChatPage() {
if (steps.length > 0) {
restoredThinkingSteps.set(`msg-${msg.id}`, steps);
}
// Hydrate write_todos plan state from persisted tool calls
const writeTodosCalls = extractWriteTodosFromContent(msg.content);
for (const todoData of writeTodosCalls) {
hydratePlanState(todoData);
}
}
if (msg.role === "user") {
const docs = extractMentionedDocuments(msg.content);
@ -247,7 +323,13 @@ export default function NewChatPage() {
} finally {
setIsInitializing(false);
}
}, [urlChatId, setMessageDocumentsMap, setMentionedDocumentIds, setMentionedDocuments]);
}, [
urlChatId,
setMessageDocumentsMap,
setMentionedDocumentIds,
setMentionedDocuments,
hydratePlanState,
]);
// Initialize on mount
useEffect(() => {
@ -306,6 +388,7 @@ export default function NewChatPage() {
// Lazy thread creation: create thread on first message if it doesn't exist
let currentThreadId = threadId;
let isNewThread = false;
if (!currentThreadId) {
try {
const newThread = await createThread(searchSpaceId, "New Chat");
@ -315,6 +398,7 @@ export default function NewChatPage() {
// Track chat creation
trackChatCreated(searchSpaceId, currentThreadId);
isNewThread = true;
// Update URL silently using browser API (not router.replace) to avoid
// interrupting the ongoing fetch/streaming with React navigation
window.history.replaceState(
@ -361,25 +445,50 @@ export default function NewChatPage() {
}));
}
// Persist user message with mentioned documents (don't await, fire and forget)
const persistContent =
mentionedDocuments.length > 0
? [
...message.content,
{
type: "mentioned-documents",
documents: mentionedDocuments.map((doc) => ({
id: doc.id,
title: doc.title,
document_type: doc.document_type,
})),
},
]
: message.content;
// Persist user message with mentioned documents and attachments (don't await, fire and forget)
const persistContent: unknown[] = [...message.content];
// Add mentioned documents for persistence
if (mentionedDocuments.length > 0) {
persistContent.push({
type: "mentioned-documents",
documents: mentionedDocuments.map((doc) => ({
id: doc.id,
title: doc.title,
document_type: doc.document_type,
})),
});
}
// Add attachments for persistence (so they survive page reload)
if (message.attachments && message.attachments.length > 0) {
persistContent.push({
type: "attachments",
items: message.attachments.map((att) => ({
id: att.id,
name: att.name,
type: att.type,
contentType: (att as { contentType?: string }).contentType,
// Include imageDataUrl for images so they can be displayed after reload
imageDataUrl: (att as { imageDataUrl?: string }).imageDataUrl,
// Include extractedContent for context (already extracted, no re-processing needed)
extractedContent: (att as { extractedContent?: string }).extractedContent,
})),
});
}
appendMessage(currentThreadId, {
role: "user",
content: persistContent,
}).catch((err) => console.error("Failed to persist user message:", err));
})
.then(() => {
// For new threads, the backend updates the title from the first user message
// Invalidate threads query so sidebar shows the updated title in real-time
if (isNewThread) {
queryClient.invalidateQueries({ queryKey: ["threads", String(searchSpaceId)] });
}
})
.catch((err) => console.error("Failed to persist user message:", err));
// Start streaming response
setIsRunning(true);
@ -676,7 +785,19 @@ export default function NewChatPage() {
}
} catch (error) {
if (error instanceof Error && error.name === "AbortError") {
// Request was cancelled
// Request was cancelled by user - persist partial response if any content was received
const hasContent = contentParts.some(
(part) =>
(part.type === "text" && part.text.length > 0) ||
(part.type === "tool-call" && TOOLS_WITH_UI.has(part.toolName))
);
if (hasContent && currentThreadId) {
const partialContent = buildContentForPersistence();
appendMessage(currentThreadId, {
role: "assistant",
content: partialContent,
}).catch((err) => console.error("Failed to persist partial assistant message:", err));
}
return;
}
console.error("[NewChatPage] Chat error:", error);
@ -720,6 +841,7 @@ export default function NewChatPage() {
setMentionedDocumentIds,
setMentionedDocuments,
setMessageDocumentsMap,
queryClient,
]
);
@ -789,6 +911,7 @@ export default function NewChatPage() {
<LinkPreviewToolUI />
<DisplayImageToolUI />
<ScrapeWebpageToolUI />
<WriteTodosToolUI />
<div className="flex flex-col h-[calc(100vh-64px)] overflow-hidden">
<Thread
messageThinkingSteps={messageThinkingSteps}

View file

@ -8,8 +8,8 @@ export default function SearchSpaceDashboardPage() {
const { search_space_id } = useParams();
useEffect(() => {
router.push(`/dashboard/${search_space_id}/chats`);
}, []);
router.push(`/dashboard/${search_space_id}/new-chat`);
}, [router, search_space_id]);
return <></>;
}

View file

@ -0,0 +1,224 @@
/**
* Plan State Atom
*
* Tracks the latest state of each plan by title.
* When write_todos is called multiple times with the same title,
* only the FIRST component renders (stays fixed in position),
* subsequent calls just update the shared state.
*/
import { atom } from "jotai";
export interface PlanTodo {
id: string;
content: string;
status: "pending" | "in_progress" | "completed" | "cancelled";
}
export interface PlanState {
id: string;
title: string;
todos: PlanTodo[];
lastUpdated: number;
/** The toolCallId of the first component that rendered this plan */
ownerToolCallId: string;
}
/**
* SYNCHRONOUS ownership registry - prevents race conditions
* Only ONE plan allowed per conversation - first plan wins
*/
let firstPlanOwner: { toolCallId: string; title: string } | null = null;
/**
* Register as owner of a plan SYNCHRONOUSLY
* ONE PLAN PER CONVERSATION: Only the first write_todos call renders.
* All subsequent calls update the state but don't render their own card.
*/
export function registerPlanOwner(title: string, toolCallId: string): boolean {
if (!firstPlanOwner) {
// First plan in this conversation - claim ownership
firstPlanOwner = { toolCallId, title };
return true;
}
// Check if we're the owner
return firstPlanOwner.toolCallId === toolCallId;
}
/**
* Get the canonical title for a plan
* Returns the first plan's title if one exists, otherwise the provided title
*/
export function getCanonicalPlanTitle(title: string): string {
return firstPlanOwner?.title || title;
}
/**
* Check if a plan already exists in this conversation
*/
export function hasPlan(): boolean {
return firstPlanOwner !== null;
}
/**
* Get the first plan's info
*/
export function getFirstPlanInfo(): { toolCallId: string; title: string } | null {
return firstPlanOwner;
}
/**
* Check if a toolCallId is the owner of the plan SYNCHRONOUSLY
*/
export function isPlanOwner(toolCallId: string): boolean {
return !firstPlanOwner || firstPlanOwner.toolCallId === toolCallId;
}
/**
* Clear ownership registry (call when starting a new chat)
*/
export function clearPlanOwnerRegistry(): void {
firstPlanOwner = null;
}
/**
* Map of plan title -> latest plan state
* Using title as key since it stays constant across updates
*/
export const planStatesAtom = atom<Map<string, PlanState>>(new Map());
/**
* Input type for updating plan state
*/
export interface UpdatePlanInput {
id: string;
title: string;
todos: PlanTodo[];
toolCallId: string;
}
/**
* Helper atom to update a plan state
*/
export const updatePlanStateAtom = atom(null, (get, set, plan: UpdatePlanInput) => {
const states = new Map(get(planStatesAtom));
// Register ownership synchronously if not already done
registerPlanOwner(plan.title, plan.toolCallId);
// Get the actual owner from the first plan
const ownerToolCallId = firstPlanOwner?.toolCallId || plan.toolCallId;
// Always use the canonical (first) title for the plan key
const canonicalTitle = getCanonicalPlanTitle(plan.title);
states.set(canonicalTitle, {
id: plan.id,
title: canonicalTitle,
todos: plan.todos,
lastUpdated: Date.now(),
ownerToolCallId,
});
set(planStatesAtom, states);
});
/**
* Helper atom to get the latest plan state by title
*/
export const getPlanStateAtom = atom((get) => {
const states = get(planStatesAtom);
return (title: string) => states.get(title);
});
/**
* Helper atom to clear all plan states (useful when starting a new chat)
*/
export const clearPlanStatesAtom = atom(null, (get, set) => {
clearPlanOwnerRegistry();
set(planStatesAtom, new Map());
});
/**
* Hydrate plan state from persisted message content
* Call this when loading messages from the database to restore plan state
*/
export interface HydratePlanInput {
toolCallId: string;
result: {
id?: string;
title?: string;
todos?: Array<{
id?: string;
content: string;
status: "pending" | "in_progress" | "completed" | "cancelled";
}>;
};
}
export const hydratePlanStateAtom = atom(null, (get, set, plan: HydratePlanInput) => {
if (!plan.result?.todos || plan.result.todos.length === 0) return;
const states = new Map(get(planStatesAtom));
const title = plan.result.title || "Plan";
// Register this as the owner if no plan exists yet
registerPlanOwner(title, plan.toolCallId);
// Get the canonical title
const canonicalTitle = getCanonicalPlanTitle(title);
const ownerToolCallId = firstPlanOwner?.toolCallId || plan.toolCallId;
// Only set if this is newer or doesn't exist
const existing = states.get(canonicalTitle);
if (!existing) {
states.set(canonicalTitle, {
id: plan.result.id || `plan-${Date.now()}`,
title: canonicalTitle,
todos: plan.result.todos.map((t, i) => ({
id: t.id || `todo-${i}`,
content: t.content,
status: t.status,
})),
lastUpdated: Date.now(),
ownerToolCallId,
});
set(planStatesAtom, states);
}
});
/**
* Extract write_todos tool call data from message content
* Returns an array of { toolCallId, result } for each write_todos call found
*/
export function extractWriteTodosFromContent(content: unknown): HydratePlanInput[] {
if (!Array.isArray(content)) return [];
const results: HydratePlanInput[] = [];
for (const part of content) {
if (
typeof part === "object" &&
part !== null &&
"type" in part &&
(part as { type: string }).type === "tool-call" &&
"toolName" in part &&
(part as { toolName: string }).toolName === "write_todos" &&
"toolCallId" in part &&
"result" in part
) {
const toolCall = part as {
toolCallId: string;
result: HydratePlanInput["result"];
};
if (toolCall.result) {
results.push({
toolCallId: toolCall.toolCallId,
result: toolCall.result,
});
}
}
}
return results;
}

View file

@ -15,8 +15,6 @@ import {
AlertCircle,
ArrowDownIcon,
ArrowUpIcon,
Brain,
CheckCircle2,
CheckIcon,
ChevronLeftIcon,
ChevronRightIcon,
@ -28,8 +26,6 @@ import {
Plug2,
Plus,
RefreshCwIcon,
Search,
Sparkles,
SquareIcon,
} from "lucide-react";
import Link from "next/link";
@ -75,13 +71,8 @@ import {
DocumentMentionPicker,
type DocumentMentionPickerRef,
} from "@/components/new-chat/document-mention-picker";
import {
ChainOfThought,
ChainOfThoughtContent,
ChainOfThoughtItem,
ChainOfThoughtStep,
ChainOfThoughtTrigger,
} from "@/components/prompt-kit/chain-of-thought";
import { ChainOfThoughtItem } from "@/components/prompt-kit/chain-of-thought";
import { TextShimmerLoader } from "@/components/prompt-kit/loader";
import type { ThinkingStep } from "@/components/tool-ui/deepagent-thinking";
import { Button } from "@/components/ui/button";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
@ -103,124 +94,146 @@ interface ThreadProps {
const ThinkingStepsContext = createContext<Map<string, ThinkingStep[]>>(new Map());
/**
* Get icon based on step status and title
*/
function getStepIcon(status: "pending" | "in_progress" | "completed", title: string) {
const titleLower = title.toLowerCase();
if (status === "in_progress") {
return <Loader2 className="size-4 animate-spin text-primary" />;
}
if (status === "completed") {
return <CheckCircle2 className="size-4 text-emerald-500" />;
}
if (titleLower.includes("search") || titleLower.includes("knowledge")) {
return <Search className="size-4 text-muted-foreground" />;
}
if (titleLower.includes("analy") || titleLower.includes("understand")) {
return <Brain className="size-4 text-muted-foreground" />;
}
return <Sparkles className="size-4 text-muted-foreground" />;
}
/**
* Chain of thought display component with smart expand/collapse behavior
* Chain of thought display component - single collapsible dropdown design
*/
const ThinkingStepsDisplay: FC<{ steps: ThinkingStep[]; isThreadRunning?: boolean }> = ({
steps,
isThreadRunning = true,
}) => {
// Track which steps the user has manually toggled (overrides auto behavior)
const [manualOverrides, setManualOverrides] = useState<Record<string, boolean>>({});
// Track previous step statuses to detect changes
const prevStatusesRef = useRef<Record<string, string>>({});
const [isOpen, setIsOpen] = useState(true);
// Derive effective status: if thread stopped and step is in_progress, treat as completed
const getEffectiveStatus = (step: ThinkingStep): "pending" | "in_progress" | "completed" => {
if (step.status === "in_progress" && !isThreadRunning) {
return "completed"; // Thread was stopped, so mark as completed
}
return step.status;
};
// Clear manual overrides when a step's status changes
useEffect(() => {
const currentStatuses: Record<string, string> = {};
steps.forEach((step) => {
currentStatuses[step.id] = step.status;
// If status changed, clear any manual override for this step
if (prevStatusesRef.current[step.id] && prevStatusesRef.current[step.id] !== step.status) {
setManualOverrides((prev) => {
const next = { ...prev };
delete next[step.id];
return next;
});
// Derive effective status for each step
const getEffectiveStatus = useCallback(
(step: ThinkingStep): "pending" | "in_progress" | "completed" => {
if (step.status === "in_progress" && !isThreadRunning) {
return "completed";
}
});
prevStatusesRef.current = currentStatuses;
}, [steps]);
return step.status;
},
[isThreadRunning]
);
// Calculate summary info
const completedSteps = steps.filter((s) => getEffectiveStatus(s) === "completed").length;
const inProgressStep = steps.find((s) => getEffectiveStatus(s) === "in_progress");
const allCompleted = completedSteps === steps.length && steps.length > 0 && !isThreadRunning;
const isProcessing = isThreadRunning && !allCompleted;
// Auto-collapse when all tasks are completed
useEffect(() => {
if (allCompleted) {
setIsOpen(false);
}
}, [allCompleted]);
if (steps.length === 0) return null;
const getStepOpenState = (step: ThinkingStep): boolean => {
const effectiveStatus = getEffectiveStatus(step);
// If user has manually toggled, respect that
if (manualOverrides[step.id] !== undefined) {
return manualOverrides[step.id];
// Generate header text
const getHeaderText = () => {
if (allCompleted) {
return `Reviewed ${completedSteps} ${completedSteps === 1 ? "step" : "steps"}`;
}
// Auto behavior: open if in progress
if (effectiveStatus === "in_progress") {
return true;
if (inProgressStep) {
return inProgressStep.title;
}
// Default: collapsed (all steps collapse when processing is done)
return false;
};
const handleToggle = (stepId: string, currentOpen: boolean) => {
setManualOverrides((prev) => ({
...prev,
[stepId]: !currentOpen,
}));
if (isProcessing) {
return `Processing ${completedSteps}/${steps.length} steps`;
}
return `Reviewed ${completedSteps} ${completedSteps === 1 ? "step" : "steps"}`;
};
return (
<div className="mx-auto w-full max-w-(--thread-max-width) px-2 py-2">
<ChainOfThought>
{steps.map((step) => {
const effectiveStatus = getEffectiveStatus(step);
const icon = getStepIcon(effectiveStatus, step.title);
const isOpen = getStepOpenState(step);
return (
<ChainOfThoughtStep
key={step.id}
open={isOpen}
onOpenChange={() => handleToggle(step.id, isOpen)}
>
<ChainOfThoughtTrigger
leftIcon={icon}
swapIconOnHover={effectiveStatus !== "in_progress"}
className={cn(
effectiveStatus === "in_progress" && "text-foreground font-medium",
effectiveStatus === "completed" && "text-muted-foreground"
)}
>
{step.title}
</ChainOfThoughtTrigger>
{step.items && step.items.length > 0 && (
<ChainOfThoughtContent>
{step.items.map((item, idx) => (
<ChainOfThoughtItem key={`${step.id}-item-${idx}`}>{item}</ChainOfThoughtItem>
))}
</ChainOfThoughtContent>
)}
</ChainOfThoughtStep>
);
})}
</ChainOfThought>
<div className="rounded-lg">
{/* Main collapsible header */}
<button
type="button"
onClick={() => setIsOpen(!isOpen)}
className={cn(
"flex w-full items-center gap-1.5 text-left text-sm transition-colors",
"text-muted-foreground hover:text-foreground"
)}
>
{/* Header text with shimmer if processing or has in-progress step */}
{isProcessing || inProgressStep ? (
<TextShimmerLoader text={getHeaderText()} size="sm" />
) : (
<span>{getHeaderText()}</span>
)}
{/* Chevron */}
<ChevronRightIcon
className={cn("size-4 transition-transform duration-200", isOpen && "rotate-90")}
/>
</button>
{/* Collapsible content with CSS grid animation */}
<div
className={cn(
"grid transition-[grid-template-rows] duration-300 ease-out",
isOpen ? "grid-rows-[1fr]" : "grid-rows-[0fr]"
)}
>
<div className="overflow-hidden">
<div className="mt-3 pl-1">
{steps.map((step, index) => {
const effectiveStatus = getEffectiveStatus(step);
const isLast = index === steps.length - 1;
return (
<div key={step.id} className="relative flex gap-3">
{/* Dot and line column */}
<div className="relative flex flex-col items-center w-2">
{/* Vertical connection line - extends to next dot */}
{!isLast && (
<div className="absolute left-1/2 top-[15px] -bottom-[7px] w-px -translate-x-1/2 bg-muted-foreground/30" />
)}
{/* 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="size-2 rounded-full bg-muted-foreground/30" />
)}
</div>
</div>
{/* Step content */}
<div className="flex-1 min-w-0 pb-4">
{/* Step title */}
<div
className={cn(
"text-sm leading-5",
effectiveStatus === "in_progress" && "text-foreground font-medium",
effectiveStatus === "completed" && "text-muted-foreground",
effectiveStatus === "pending" && "text-muted-foreground/60"
)}
>
{effectiveStatus === "in_progress" ? (
<TextShimmerLoader text={step.title} size="sm" />
) : (
step.title
)}
</div>
{/* Step items (sub-content) */}
{step.items && step.items.length > 0 && (
<div className="mt-1 space-y-0.5">
{step.items.map((item, idx) => (
<ChainOfThoughtItem key={`${step.id}-item-${idx}`} className="text-xs">
{item}
</ChainOfThoughtItem>
))}
</div>
)}
</div>
</div>
);
})}
</div>
</div>
</div>
</div>
</div>
);
};
@ -428,13 +441,6 @@ const Composer: FC = () => {
}
}, [isThreadEmpty]);
// Reset auto-focus flag when thread becomes non-empty (user sent a message)
useEffect(() => {
if (!isThreadEmpty) {
hasAutoFocusedRef.current = false;
}
}, [isThreadEmpty]);
// Sync mentioned document IDs to atom for use in chat request
useEffect(() => {
setMentionedDocumentIds(mentionedDocuments.map((doc) => doc.id));
@ -561,7 +567,7 @@ const Composer: FC = () => {
<div ref={editorContainerRef} className="aui-composer-input-wrapper px-3 pt-3 pb-6">
<InlineMentionEditor
ref={editorRef}
placeholder="Ask SurfSense (type @ to mention docs)"
placeholder="Ask SurfSense or @mention docs"
onMentionTrigger={handleMentionTrigger}
onMentionClose={handleMentionClose}
onChange={handleEditorChange}
@ -683,14 +689,10 @@ const ConnectorIndicator: FC = () => {
) : (
<>
<Plug2 className="size-4" />
{totalSourceCount > 0 ? (
{totalSourceCount > 0 && (
<span className="absolute -top-0.5 -right-0.5 flex items-center justify-center min-w-[16px] h-4 px-1 text-[10px] font-medium rounded-full bg-primary text-primary-foreground shadow-sm">
{totalSourceCount > 99 ? "99+" : totalSourceCount}
</span>
) : (
<span className="absolute -top-0.5 -right-0.5 flex items-center justify-center size-3 rounded-full bg-muted-foreground/30 border border-background">
<span className="size-1.5 rounded-full bg-muted-foreground/60" />
</span>
)}
</>
)}
@ -917,7 +919,7 @@ const AssistantMessageInner: FC = () => {
<MessageError />
</div>
<div className="aui-assistant-message-footer mt-1 ml-2 flex">
<div className="aui-assistant-message-footer mt-1 mb-5 ml-2 flex">
<BranchPicker />
<AssistantActionBar />
</div>

View file

@ -0,0 +1,66 @@
"use client";
import { cn } from "@/lib/utils";
export interface LoaderProps {
variant?: "text-shimmer";
size?: "sm" | "md" | "lg";
text?: string;
className?: string;
}
const textSizes = {
sm: "text-xs",
md: "text-sm",
lg: "text-base",
} as const;
/**
* TextShimmerLoader - A text loader with a shimmer gradient animation
* Used for in-progress states in write_todos and chain-of-thought
*/
export function TextShimmerLoader({
text = "Thinking",
className,
size = "md",
}: {
text?: string;
className?: string;
size?: "sm" | "md" | "lg";
}) {
return (
<>
<style>
{`
@keyframes shimmer {
0% { background-position: 200% 50%; }
100% { background-position: -200% 50%; }
}
`}
</style>
<span
className={cn(
"bg-[linear-gradient(to_right,var(--muted-foreground)_40%,var(--foreground)_60%,var(--muted-foreground)_80%)]",
"bg-[length:200%_auto] bg-clip-text font-medium text-transparent",
"animate-[shimmer_4s_infinite_linear]",
textSizes[size],
className
)}
>
{text}
</span>
</>
);
}
/**
* Loader component - currently only supports text-shimmer variant
* Can be extended with more variants if needed in the future
*/
export function Loader({ variant = "text-shimmer", size = "md", text, className }: LoaderProps) {
switch (variant) {
case "text-shimmer":
default:
return <TextShimmerLoader text={text} size={size} className={className} />;
}
}

View file

@ -3,7 +3,7 @@
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useAtomValue, useSetAtom } from "jotai";
import { Trash2 } from "lucide-react";
import { useRouter } from "next/navigation";
import { useParams, useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { useCallback, useMemo, useState } from "react";
import { hasUnsavedEditorChangesAtom, pendingEditorNavigationAtom } from "@/atoms/editor/ui.atoms";
@ -50,7 +50,13 @@ export function AppSidebarProvider({
const t = useTranslations("dashboard");
const tCommon = useTranslations("common");
const router = useRouter();
const params = useParams();
const queryClient = useQueryClient();
// Get current chat ID from URL params
const currentChatId = params?.chat_id
? Number(Array.isArray(params.chat_id) ? params.chat_id[0] : params.chat_id)
: null;
const [isDeletingThread, setIsDeletingThread] = useState(false);
// Editor state for handling unsaved changes
@ -61,7 +67,6 @@ export function AppSidebarProvider({
const {
data: threadsData,
error: threadError,
isLoading: isLoadingThreads,
refetch: refetchThreads,
} = useQuery({
queryKey: ["threads", searchSpaceId],
@ -73,7 +78,6 @@ export function AppSidebarProvider({
data: searchSpace,
isLoading: isLoadingSearchSpace,
error: searchSpaceError,
refetch: fetchSearchSpace,
} = useQuery({
queryKey: cacheKeys.searchSpaces.detail(searchSpaceId),
queryFn: () => searchSpacesApiService.getSearchSpace({ id: Number(searchSpaceId) }),
@ -83,12 +87,7 @@ export function AppSidebarProvider({
const { data: user } = useAtomValue(currentUserAtom);
// Fetch notes
const {
data: notesData,
error: notesError,
isLoading: isLoadingNotes,
refetch: refetchNotes,
} = useQuery({
const { data: notesData, refetch: refetchNotes } = useQuery({
queryKey: ["notes", searchSpaceId],
queryFn: () =>
notesApiService.getNotes({
@ -108,11 +107,6 @@ export function AppSidebarProvider({
} | null>(null);
const [isDeletingNote, setIsDeletingNote] = useState(false);
// Retry function
const retryFetch = useCallback(() => {
fetchSearchSpace();
}, [fetchSearchSpace]);
// Transform threads to the format expected by AppSidebar
const recentChats = useMemo(() => {
if (!threadsData?.threads) return [];
@ -149,6 +143,10 @@ export function AppSidebarProvider({
await deleteThread(threadToDelete.id);
// Invalidate threads query to refresh the list
queryClient.invalidateQueries({ queryKey: ["threads", searchSpaceId] });
// Only navigate to new-chat if the deleted chat is currently open
if (currentChatId === threadToDelete.id) {
router.push(`/dashboard/${searchSpaceId}/new-chat`);
}
} catch (error) {
console.error("Error deleting thread:", error);
} finally {
@ -156,7 +154,7 @@ export function AppSidebarProvider({
setShowDeleteDialog(false);
setThreadToDelete(null);
}
}, [threadToDelete, queryClient, searchSpaceId]);
}, [threadToDelete, queryClient, searchSpaceId, router, currentChatId]);
// Handle delete note with confirmation
const handleDeleteNote = useCallback(async () => {

View file

@ -13,7 +13,7 @@ import {
X,
} from "lucide-react";
import { AnimatePresence, motion } from "motion/react";
import { useRouter } from "next/navigation";
import { useParams, useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { useCallback, useEffect, useState } from "react";
import { createPortal } from "react-dom";
@ -47,7 +47,15 @@ interface AllChatsSidebarProps {
export function AllChatsSidebar({ open, onOpenChange, searchSpaceId }: AllChatsSidebarProps) {
const t = useTranslations("sidebar");
const router = useRouter();
const params = useParams();
const queryClient = useQueryClient();
// Get the current chat ID from URL to check if user is deleting the currently open chat
const currentChatId = Array.isArray(params.chat_id)
? Number(params.chat_id[0])
: params.chat_id
? Number(params.chat_id)
: null;
const [deletingThreadId, setDeletingThreadId] = useState<number | null>(null);
const [archivingThreadId, setArchivingThreadId] = useState<number | null>(null);
const [searchQuery, setSearchQuery] = useState("");
@ -126,6 +134,15 @@ export function AllChatsSidebar({ open, onOpenChange, searchSpaceId }: AllChatsS
queryClient.invalidateQueries({ queryKey: ["all-threads", searchSpaceId] });
queryClient.invalidateQueries({ queryKey: ["search-threads", searchSpaceId] });
queryClient.invalidateQueries({ queryKey: ["threads", searchSpaceId] });
// If the deleted chat is currently open, close sidebar first then redirect
if (currentChatId === threadId) {
onOpenChange(false);
// Wait for sidebar close animation to complete before navigating
setTimeout(() => {
router.push(`/dashboard/${searchSpaceId}/new-chat`);
}, 250);
}
} catch (error) {
console.error("Error deleting thread:", error);
toast.error(t("error_deleting_chat") || "Failed to delete chat");
@ -133,7 +150,7 @@ export function AllChatsSidebar({ open, onOpenChange, searchSpaceId }: AllChatsS
setDeletingThreadId(null);
}
},
[queryClient, searchSpaceId, t]
[queryClient, searchSpaceId, t, currentChatId, router, onOpenChange]
);
// Handle thread archive/unarchive
@ -293,6 +310,7 @@ export function AllChatsSidebar({ open, onOpenChange, searchSpaceId }: AllChatsS
const isDeleting = deletingThreadId === thread.id;
const isArchiving = archivingThreadId === thread.id;
const isBusy = isDeleting || isArchiving;
const isActive = currentChatId === thread.id;
return (
<div
@ -301,6 +319,7 @@ export function AllChatsSidebar({ open, onOpenChange, searchSpaceId }: AllChatsS
"group flex items-center gap-2 rounded-md px-2 py-1.5 text-sm",
"hover:bg-accent hover:text-accent-foreground",
"transition-colors cursor-pointer",
isActive && "bg-accent text-accent-foreground",
isBusy && "opacity-50 pointer-events-none"
)}
>

View file

@ -4,7 +4,7 @@ import { useQuery, useQueryClient } from "@tanstack/react-query";
import { format } from "date-fns";
import { FileText, Loader2, MoreHorizontal, Plus, Search, Trash2, X } from "lucide-react";
import { AnimatePresence, motion } from "motion/react";
import { useRouter } from "next/navigation";
import { useParams, useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { useCallback, useEffect, useMemo, useState } from "react";
import { createPortal } from "react-dom";
@ -37,7 +37,11 @@ export function AllNotesSidebar({
}: AllNotesSidebarProps) {
const t = useTranslations("sidebar");
const router = useRouter();
const params = useParams();
const queryClient = useQueryClient();
// Get the current note ID from URL to highlight the open note
const currentNoteId = params.note_id ? Number(params.note_id) : null;
const [deletingNoteId, setDeletingNoteId] = useState<number | null>(null);
const [searchQuery, setSearchQuery] = useState("");
const [mounted, setMounted] = useState(false);
@ -208,7 +212,7 @@ export function AllNotesSidebar({
aria-label={t("all_notes") || "All Notes"}
>
{/* Header */}
<div className="flex-shrink-0 p-4 pb-3 space-y-3 border-b">
<div className="flex-shrink-0 p-4 pb-3 space-y-3">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold">{t("all_notes") || "All Notes"}</h2>
<Button
@ -260,6 +264,7 @@ export function AllNotesSidebar({
<div className="space-y-1">
{notes.map((note) => {
const isDeleting = deletingNoteId === note.id;
const isActive = currentNoteId === note.id;
return (
<div
@ -268,6 +273,7 @@ export function AllNotesSidebar({
"group flex items-center gap-2 rounded-md px-2 py-1.5 text-sm",
"hover:bg-accent hover:text-accent-foreground",
"transition-colors cursor-pointer",
isActive && "bg-accent text-accent-foreground",
isDeleting && "opacity-50 pointer-events-none"
)}
>
@ -370,7 +376,7 @@ export function AllNotesSidebar({
{/* Footer with Add Note button */}
{onAddNote && notes.length > 0 && (
<div className="flex-shrink-0 p-3 border-t">
<div className="flex-shrink-0 p-3">
<Button
onClick={() => {
onAddNote();

View file

@ -10,7 +10,7 @@ import {
RefreshCw,
Trash2,
} from "lucide-react";
import { useRouter } from "next/navigation";
import { usePathname, useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { useCallback, useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
@ -71,6 +71,7 @@ export function NavChats({
}: NavChatsProps) {
const t = useTranslations("sidebar");
const router = useRouter();
const pathname = usePathname();
const isMobile = useIsMobile();
const [isDeleting, setIsDeleting] = useState<number | null>(null);
const [isOpen, setIsOpen] = useState(defaultOpen);
@ -142,6 +143,7 @@ export function NavChats({
<SidebarMenu>
{chats.map((chat) => {
const isDeletingChat = isDeleting === chat.id;
const isActive = pathname === chat.url;
return (
<SidebarMenuItem key={chat.id || chat.name} className="group/chat">
@ -151,6 +153,7 @@ export function NavChats({
disabled={isDeletingChat}
className={cn(
"pr-8", // Make room for the action button
isActive && "bg-sidebar-accent text-sidebar-accent-foreground",
isDeletingChat && "opacity-50"
)}
>

View file

@ -1,6 +1,7 @@
"use client";
import { ChevronRight, type LucideIcon } from "lucide-react";
import { usePathname } from "next/navigation";
import { useTranslations } from "next-intl";
import { useCallback, useMemo, useState } from "react";
@ -35,6 +36,7 @@ interface NavMainProps {
export function NavMain({ items, onSourcesExpandedChange }: NavMainProps) {
const t = useTranslations("nav_menu");
const pathname = usePathname();
// Translation function that handles both exact matches and fallback to original
const translateTitle = (title: string): string => {
@ -55,6 +57,35 @@ export function NavMain({ items, onSourcesExpandedChange }: NavMainProps) {
return key ? t(key) : title;
};
// Check if an item is active based on pathname
const isItemActive = useCallback(
(item: NavItem): boolean => {
if (!pathname) return false;
// For items without sub-items, check if pathname matches or starts with the URL
if (!item.items?.length) {
// Chat item: active ONLY when on new-chat page without a specific chat ID
// (i.e., exactly /dashboard/{id}/new-chat, not /dashboard/{id}/new-chat/123)
if (item.url.includes("/new-chat")) {
// Match exactly the new-chat base URL (ends with /new-chat)
return pathname.endsWith("/new-chat");
}
// Logs item: active when on logs page
if (item.url.includes("/logs")) {
return pathname.includes("/logs");
}
// Check exact match or prefix match
return pathname === item.url || pathname.startsWith(`${item.url}/`);
}
// For items with sub-items (like Sources), check if any sub-item URL matches
return item.items.some(
(subItem) => pathname === subItem.url || pathname.startsWith(subItem.url)
);
},
[pathname]
);
// Memoize items to prevent unnecessary re-renders
const memoizedItems = useMemo(() => items, [items]);
@ -88,14 +119,15 @@ export function NavMain({ items, onSourcesExpandedChange }: NavMainProps) {
{memoizedItems.map((item, index) => {
const translatedTitle = translateTitle(item.title);
const hasSub = !!item.items?.length;
const isItemOpen = expandedItems[item.title] ?? item.isActive ?? false;
const isActive = isItemActive(item);
const isItemOpen = expandedItems[item.title] ?? isActive ?? false;
return (
<Collapsible
key={`${item.title}-${index}`}
asChild
open={hasSub ? isItemOpen : undefined}
onOpenChange={hasSub ? (open) => handleOpenChange(item.title, open) : undefined}
defaultOpen={!hasSub ? item.isActive : undefined}
defaultOpen={!hasSub ? isActive : undefined}
>
<SidebarMenuItem>
{hasSub ? (
@ -105,7 +137,7 @@ export function NavMain({ items, onSourcesExpandedChange }: NavMainProps) {
<SidebarMenuButton
asChild
tooltip={translatedTitle}
isActive={item.isActive}
isActive={isActive}
aria-label={`${translatedTitle} with submenu`}
>
<button type="button" className="flex items-center gap-2 w-full text-left">
@ -147,7 +179,7 @@ export function NavMain({ items, onSourcesExpandedChange }: NavMainProps) {
<SidebarMenuButton
asChild
tooltip={translatedTitle}
isActive={item.isActive}
isActive={isActive}
aria-label={translatedTitle}
>
<a href={item.url}>

View file

@ -10,9 +10,10 @@ import {
Plus,
Trash2,
} from "lucide-react";
import { useRouter } from "next/navigation";
import { usePathname, useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { useCallback, useEffect, useState } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useLogsSummary } from "@/hooks/use-logs";
import { Button } from "@/components/ui/button";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import {
@ -72,11 +73,27 @@ export function NavNotes({
}: NavNotesProps) {
const t = useTranslations("sidebar");
const router = useRouter();
const pathname = usePathname();
const isMobile = useIsMobile();
const [isDeleting, setIsDeleting] = useState<number | null>(null);
const [isOpen, setIsOpen] = useState(defaultOpen);
const [isAllNotesSidebarOpen, setIsAllNotesSidebarOpen] = useState(false);
// Poll for active reindexing tasks to show inline loading indicators
const { summary } = useLogsSummary(searchSpaceId ? Number(searchSpaceId) : 0, 24, {
refetchInterval: 2000,
});
// Create a Set of document IDs that are currently being reindexed
const reindexingDocumentIds = useMemo(() => {
if (!summary?.active_tasks) return new Set<number>();
return new Set(
summary.active_tasks
.filter((task) => task.document_id != null)
.map((task) => task.document_id as number)
);
}, [summary?.active_tasks]);
// Auto-collapse on smaller screens when Sources is expanded
useEffect(() => {
if (isSourcesExpanded && isMobile) {
@ -157,6 +174,8 @@ export function NavNotes({
{notes.length > 0 ? (
notes.map((note) => {
const isDeletingNote = isDeleting === note.id;
const isActive = pathname === note.url;
const isReindexing = note.id ? reindexingDocumentIds.has(note.id) : false;
return (
<SidebarMenuItem key={note.id || note.name} className="group/note">
@ -166,10 +185,15 @@ export function NavNotes({
disabled={isDeletingNote}
className={cn(
"pr-8", // Make room for the action button
isActive && "bg-sidebar-accent text-sidebar-accent-foreground",
isDeletingNote && "opacity-50"
)}
>
<note.icon className="h-4 w-4 shrink-0" />
{isReindexing ? (
<Loader2 className="h-4 w-4 shrink-0 animate-spin text-primary" />
) : (
<note.icon className="h-4 w-4 shrink-0" />
)}
<span className="truncate">{note.name}</span>
</SidebarMenuButton>

View file

@ -36,12 +36,21 @@ export function NavSecondary({
<SidebarMenu>
{memoizedItems.map((item, index) => (
<SidebarMenuItem key={`${item.title}-${index}`}>
<SidebarMenuButton asChild size="sm" aria-label={item.title}>
<a href={item.url}>
<item.icon />
<span>{item.title}</span>
</a>
</SidebarMenuButton>
{item.url === "#" ? (
// Non-interactive display item (e.g., search space name)
<div className="flex h-7 w-full items-center gap-2 rounded-md px-2 text-xs text-sidebar-foreground">
<item.icon className="h-4 w-4 shrink-0" />
<span className="truncate">{item.title}</span>
</div>
) : (
// Interactive link item
<SidebarMenuButton asChild size="sm" aria-label={item.title}>
<a href={item.url}>
<item.icon />
<span>{item.title}</span>
</a>
</SidebarMenuButton>
)}
</SidebarMenuItem>
))}
</SidebarMenu>

View file

@ -2,6 +2,7 @@
import { makeAssistantToolUI } from "@assistant-ui/react";
import { AlertCircleIcon, ImageIcon } from "lucide-react";
import { z } from "zod";
import {
Image,
ImageErrorBoundary,
@ -9,27 +10,41 @@ import {
parseSerializableImage,
} from "@/components/tool-ui/image";
/**
* Type definitions for the display_image tool
*/
interface DisplayImageArgs {
src: string;
alt?: string;
title?: string;
description?: string;
}
// ============================================================================
// Zod Schemas
// ============================================================================
interface DisplayImageResult {
id: string;
assetId: string;
src: string;
alt?: string; // Made optional - parseSerializableImage provides fallback
title?: string;
description?: string;
domain?: string;
ratio?: string;
error?: string;
}
/**
* Schema for display_image tool arguments
*/
const DisplayImageArgsSchema = z.object({
src: z.string(),
alt: z.string().nullish(),
title: z.string().nullish(),
description: z.string().nullish(),
});
/**
* Schema for display_image tool result
*/
const DisplayImageResultSchema = z.object({
id: z.string(),
assetId: z.string(),
src: z.string(),
alt: z.string().nullish(),
title: z.string().nullish(),
description: z.string().nullish(),
domain: z.string().nullish(),
ratio: z.string().nullish(),
error: z.string().nullish(),
});
// ============================================================================
// Types
// ============================================================================
type DisplayImageArgs = z.infer<typeof DisplayImageArgsSchema>;
type DisplayImageResult = z.infer<typeof DisplayImageResultSchema>;
/**
* Error state component shown when image display fails
@ -142,4 +157,9 @@ export const DisplayImageToolUI = makeAssistantToolUI<DisplayImageArgs, DisplayI
},
});
export type { DisplayImageArgs, DisplayImageResult };
export {
DisplayImageArgsSchema,
DisplayImageResultSchema,
type DisplayImageArgs,
type DisplayImageResult,
};

View file

@ -24,6 +24,8 @@ export {
type ThinkingStep,
} from "./deepagent-thinking";
export {
DisplayImageArgsSchema,
DisplayImageResultSchema,
type DisplayImageArgs,
type DisplayImageResult,
DisplayImageToolUI,
@ -39,9 +41,13 @@ export {
type SerializableImage,
} from "./image";
export {
LinkPreviewArgsSchema,
LinkPreviewResultSchema,
type LinkPreviewArgs,
type LinkPreviewResult,
LinkPreviewToolUI,
MultiLinkPreviewArgsSchema,
MultiLinkPreviewResultSchema,
type MultiLinkPreviewArgs,
type MultiLinkPreviewResult,
MultiLinkPreviewToolUI,
@ -56,7 +62,19 @@ export {
type SerializableMediaCard,
} from "./media-card";
export {
ScrapeWebpageArgsSchema,
ScrapeWebpageResultSchema,
type ScrapeWebpageArgs,
type ScrapeWebpageResult,
ScrapeWebpageToolUI,
} from "./scrape-webpage";
export {
Plan,
PlanErrorBoundary,
type PlanProps,
parseSerializablePlan,
type SerializablePlan,
type PlanTodo,
type TodoStatus,
} from "./plan";
export { WriteTodosToolUI, WriteTodosSchema, type WriteTodosData } from "./write-todos";

View file

@ -2,6 +2,7 @@
import { makeAssistantToolUI } from "@assistant-ui/react";
import { AlertCircleIcon, ExternalLinkIcon, LinkIcon } from "lucide-react";
import { z } from "zod";
import {
MediaCard,
MediaCardErrorBoundary,
@ -10,25 +11,39 @@ import {
type SerializableMediaCard,
} from "@/components/tool-ui/media-card";
/**
* Type definitions for the link_preview tool
*/
interface LinkPreviewArgs {
url: string;
title?: string;
}
// ============================================================================
// Zod Schemas
// ============================================================================
interface LinkPreviewResult {
id: string;
assetId: string;
kind: "link";
href: string;
title: string;
description?: string;
thumb?: string;
domain?: string;
error?: string;
}
/**
* Schema for link_preview tool arguments
*/
const LinkPreviewArgsSchema = z.object({
url: z.string(),
title: z.string().nullish(),
});
/**
* Schema for link_preview tool result
*/
const LinkPreviewResultSchema = z.object({
id: z.string(),
assetId: z.string(),
kind: z.literal("link"),
href: z.string(),
title: z.string(),
description: z.string().nullish(),
thumb: z.string().nullish(),
domain: z.string().nullish(),
error: z.string().nullish(),
});
// ============================================================================
// Types
// ============================================================================
type LinkPreviewArgs = z.infer<typeof LinkPreviewArgsSchema>;
type LinkPreviewResult = z.infer<typeof LinkPreviewResultSchema>;
/**
* Error state component shown when link preview fails
@ -150,20 +165,35 @@ export const LinkPreviewToolUI = makeAssistantToolUI<LinkPreviewArgs, LinkPrevie
},
});
/**
* Multiple Link Previews Tool UI Component
*
* This component handles cases where multiple links need to be previewed.
* It renders a grid of link preview cards.
*/
interface MultiLinkPreviewArgs {
urls: string[];
}
// ============================================================================
// Multi Link Preview Schemas
// ============================================================================
interface MultiLinkPreviewResult {
previews: LinkPreviewResult[];
errors?: { url: string; error: string }[];
}
/**
* Schema for multi_link_preview tool arguments
*/
const MultiLinkPreviewArgsSchema = z.object({
urls: z.array(z.string()),
});
/**
* Schema for error items in multi_link_preview result
*/
const MultiLinkPreviewErrorSchema = z.object({
url: z.string(),
error: z.string(),
});
/**
* Schema for multi_link_preview tool result
*/
const MultiLinkPreviewResultSchema = z.object({
previews: z.array(LinkPreviewResultSchema),
errors: z.array(MultiLinkPreviewErrorSchema).nullish(),
});
type MultiLinkPreviewArgs = z.infer<typeof MultiLinkPreviewArgsSchema>;
type MultiLinkPreviewResult = z.infer<typeof MultiLinkPreviewResultSchema>;
export const MultiLinkPreviewToolUI = makeAssistantToolUI<
MultiLinkPreviewArgs,
@ -217,4 +247,13 @@ export const MultiLinkPreviewToolUI = makeAssistantToolUI<
},
});
export type { LinkPreviewArgs, LinkPreviewResult, MultiLinkPreviewArgs, MultiLinkPreviewResult };
export {
LinkPreviewArgsSchema,
LinkPreviewResultSchema,
MultiLinkPreviewArgsSchema,
MultiLinkPreviewResultSchema,
type LinkPreviewArgs,
type LinkPreviewResult,
type MultiLinkPreviewArgs,
type MultiLinkPreviewResult,
};

View file

@ -0,0 +1,52 @@
"use client";
import { Component, type ReactNode } from "react";
import { Card, CardContent } from "@/components/ui/card";
export * from "./plan";
export * from "./schema";
// ============================================================================
// Error Boundary
// ============================================================================
interface PlanErrorBoundaryProps {
children: ReactNode;
fallback?: ReactNode;
}
interface PlanErrorBoundaryState {
hasError: boolean;
error?: Error;
}
export class PlanErrorBoundary extends Component<PlanErrorBoundaryProps, PlanErrorBoundaryState> {
constructor(props: PlanErrorBoundaryProps) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError(error: Error): PlanErrorBoundaryState {
return { hasError: true, error };
}
render() {
if (this.state.hasError) {
if (this.props.fallback) {
return this.props.fallback;
}
return (
<Card className="w-full max-w-xl border-destructive/50">
<CardContent className="pt-6">
<div className="flex items-center gap-2 text-destructive">
<span className="text-sm">Failed to render plan</span>
</div>
</CardContent>
</Card>
);
}
return this.props.children;
}
}

View file

@ -0,0 +1,229 @@
"use client";
import { CheckCircle2, Circle, CircleDashed, ListTodo, PartyPopper, XCircle } from "lucide-react";
import type { FC } from "react";
import { useMemo, useState } from "react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { TextShimmerLoader } from "@/components/prompt-kit/loader";
import { Progress } from "@/components/ui/progress";
import { cn } from "@/lib/utils";
import type { Action, ActionsConfig } from "../shared/schema";
import type { TodoStatus } from "./schema";
// ============================================================================
// Status Icon Component
// ============================================================================
interface StatusIconProps {
status: TodoStatus;
className?: string;
/** When false, in_progress items show as static (no spinner) */
isStreaming?: boolean;
}
const StatusIcon: FC<StatusIconProps> = ({ status, className, isStreaming = true }) => {
const baseClass = cn("size-4 shrink-0", className);
switch (status) {
case "completed":
return <CheckCircle2 className={cn(baseClass, "text-emerald-500")} />;
case "in_progress":
// Only animate the spinner if we're actively streaming
// When streaming is stopped, show as a static dashed circle
return (
<CircleDashed
className={cn(baseClass, "text-primary", isStreaming && "animate-spin")}
style={isStreaming ? { animationDuration: "3s" } : undefined}
/>
);
case "cancelled":
return <XCircle className={cn(baseClass, "text-destructive")} />;
case "pending":
default:
return <Circle className={cn(baseClass, "text-muted-foreground")} />;
}
};
// ============================================================================
// Todo Item Component
// ============================================================================
interface TodoItemProps {
todo: { id: string; content: string; status: TodoStatus };
/** When false, in_progress items show as static (no spinner/pulse) */
isStreaming?: boolean;
}
const TodoItem: FC<TodoItemProps> = ({ todo, isStreaming = true }) => {
const isStrikethrough = todo.status === "completed" || todo.status === "cancelled";
// Only show shimmer animation if streaming and in progress
const isShimmer = todo.status === "in_progress" && isStreaming;
// Render the content with optional shimmer effect
const renderContent = () => {
if (isShimmer) {
return <TextShimmerLoader text={todo.content} size="md" />;
}
return (
<span className={cn("text-sm text-muted-foreground", isStrikethrough && "line-through")}>
{todo.content}
</span>
);
};
return (
<div className="flex items-center gap-2 py-2">
<StatusIcon status={todo.status} isStreaming={isStreaming} />
{renderContent()}
</div>
);
};
// ============================================================================
// Plan Component
// ============================================================================
export interface PlanProps {
id: string;
title: string;
todos: Array<{ id: string; content: string; status: TodoStatus }>;
maxVisibleTodos?: number;
showProgress?: boolean;
/** When false, in_progress items show as static (no spinner/pulse animations) */
isStreaming?: boolean;
responseActions?: Action[] | ActionsConfig;
className?: string;
onResponseAction?: (actionId: string) => void;
onBeforeResponseAction?: (actionId: string) => boolean;
}
export const Plan: FC<PlanProps> = ({
id,
title,
todos,
maxVisibleTodos = 4,
showProgress = true,
isStreaming = true,
responseActions,
className,
onResponseAction,
onBeforeResponseAction,
}) => {
const [isExpanded, setIsExpanded] = useState(false);
// Calculate progress
const progress = useMemo(() => {
const completed = todos.filter((t) => t.status === "completed").length;
const total = todos.filter((t) => t.status !== "cancelled").length;
return { completed, total, percentage: total > 0 ? (completed / total) * 100 : 0 };
}, [todos]);
const isAllComplete = progress.completed === progress.total && progress.total > 0;
// Split todos for collapsible display
const visibleTodos = todos.slice(0, maxVisibleTodos);
const hiddenTodos = todos.slice(maxVisibleTodos);
const hasHiddenTodos = hiddenTodos.length > 0;
// Handle action click
const handleAction = (actionId: string) => {
if (onBeforeResponseAction && !onBeforeResponseAction(actionId)) {
return;
}
onResponseAction?.(actionId);
};
// Normalize actions to array
const actionArray: Action[] = useMemo(() => {
if (!responseActions) return [];
if (Array.isArray(responseActions)) return responseActions;
return [
responseActions.confirm && { ...responseActions.confirm, id: "confirm" },
responseActions.cancel && { ...responseActions.cancel, id: "cancel" },
].filter(Boolean) as Action[];
}, [responseActions]);
const TodoList: FC<{ items: typeof todos }> = ({ items }) => {
return (
<div className="space-y-0">
{items.map((todo) => (
<TodoItem key={todo.id} todo={todo} isStreaming={isStreaming} />
))}
</div>
);
};
return (
<Card id={id} className={cn("w-full max-w-xl", className)}>
<CardHeader className="pb-3">
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0 flex items-center gap-2">
<ListTodo className="size-5 text-muted-foreground shrink-0" />
<CardTitle className="text-base font-semibold text-muted-foreground">{title}</CardTitle>
</div>
{isAllComplete && (
<div className="flex items-center gap-1 text-emerald-500">
<PartyPopper className="size-5" />
</div>
)}
</div>
{showProgress && (
<div className="mt-3 space-y-1.5">
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span>
{progress.completed} of {progress.total} complete
</span>
<span>{Math.round(progress.percentage)}%</span>
</div>
<Progress
value={progress.percentage}
className="h-1.5 bg-muted [&>div]:bg-muted-foreground"
/>
</div>
)}
</CardHeader>
<CardContent className="pt-0">
<TodoList items={visibleTodos} />
{hasHiddenTodos && (
<Collapsible open={isExpanded} onOpenChange={setIsExpanded}>
<CollapsibleTrigger asChild>
<Button
variant="ghost"
size="sm"
className="w-full mt-2 text-xs text-muted-foreground hover:text-foreground"
>
{isExpanded
? "Show less"
: `Show ${hiddenTodos.length} more ${hiddenTodos.length === 1 ? "task" : "tasks"}`}
</Button>
</CollapsibleTrigger>
<CollapsibleContent>
<TodoList items={hiddenTodos} />
</CollapsibleContent>
</Collapsible>
)}
{actionArray.length > 0 && (
<div className="flex flex-wrap gap-2 pt-4 mt-2 border-t">
{actionArray.map((action) => (
<Button
key={action.id}
variant={action.variant || "default"}
size="sm"
disabled={action.disabled}
onClick={() => handleAction(action.id)}
>
{action.label}
</Button>
))}
</div>
)}
</CardContent>
</Card>
);
};

View file

@ -0,0 +1,91 @@
import { z } from "zod";
/**
* Todo item status
*/
export const TodoStatusSchema = z.enum(["pending", "in_progress", "completed", "cancelled"]);
export type TodoStatus = z.infer<typeof TodoStatusSchema>;
/**
* Single todo item in a plan
* Matches deepagents TodoListMiddleware output: { content, status }
* id is auto-generated if not provided
*/
export const PlanTodoSchema = z.object({
id: z.string().optional(),
content: z.string(),
status: TodoStatusSchema,
});
export type PlanTodo = z.infer<typeof PlanTodoSchema>;
/**
* Serializable plan schema for tool results
* Matches deepagents TodoListMiddleware output format
* id/title are auto-generated if not provided
*/
export const SerializablePlanSchema = z.object({
id: z.string().optional(),
title: z.string().optional(),
todos: z.array(PlanTodoSchema).min(1),
maxVisibleTodos: z.number().optional(),
showProgress: z.boolean().optional(),
});
export type SerializablePlan = z.infer<typeof SerializablePlanSchema>;
/**
* Normalized plan with required fields (after auto-generation)
*/
export interface NormalizedPlan {
id: string;
title: string;
todos: Array<{ id: string; content: string; status: TodoStatus }>;
maxVisibleTodos?: number;
showProgress?: boolean;
}
/**
* Parse and normalize a plan from tool result
* Auto-generates id/title if not provided (for deepagents compatibility)
*/
export function parseSerializablePlan(data: unknown): NormalizedPlan {
const result = SerializablePlanSchema.safeParse(data);
if (!result.success) {
console.warn("Invalid plan data:", result.error.issues);
// Try to extract basic info for fallback
const obj = (data && typeof data === "object" ? data : {}) as Record<string, unknown>;
return {
id: typeof obj.id === "string" ? obj.id : `plan-${Date.now()}`,
title: typeof obj.title === "string" ? obj.title : "Plan",
todos: Array.isArray(obj.todos)
? obj.todos.map((t: unknown, i: number) => {
const todo = t as Record<string, unknown>;
return {
id: typeof todo?.id === "string" ? todo.id : `todo-${i}`,
content: typeof todo?.content === "string" ? todo.content : "Task",
status: TodoStatusSchema.safeParse(todo?.status).success
? (todo.status as TodoStatus)
: ("pending" as const),
};
})
: [{ id: "1", content: "No tasks", status: "pending" as const }],
};
}
// Normalize: add id/title if missing
return {
id: result.data.id || `plan-${Date.now()}`,
title: result.data.title || "Plan",
todos: result.data.todos.map((t, i) => ({
id: t.id || `todo-${i}`,
content: t.content,
status: t.status,
})),
maxVisibleTodos: result.data.maxVisibleTodos,
showProgress: result.data.showProgress,
};
}

View file

@ -2,6 +2,7 @@
import { makeAssistantToolUI } from "@assistant-ui/react";
import { AlertCircleIcon, FileTextIcon } from "lucide-react";
import { z } from "zod";
import {
Article,
ArticleErrorBoundary,
@ -9,30 +10,44 @@ import {
parseSerializableArticle,
} from "@/components/tool-ui/article";
/**
* Type definitions for the scrape_webpage tool
*/
interface ScrapeWebpageArgs {
url: string;
max_length?: number;
}
// ============================================================================
// Zod Schemas
// ============================================================================
interface ScrapeWebpageResult {
id: string;
assetId: string;
kind: "article";
href: string;
title: string;
description?: string;
content?: string;
domain?: string;
author?: string;
date?: string;
word_count?: number;
was_truncated?: boolean;
crawler_type?: string;
error?: string;
}
/**
* Schema for scrape_webpage tool arguments
*/
const ScrapeWebpageArgsSchema = z.object({
url: z.string(),
max_length: z.number().nullish(),
});
/**
* Schema for scrape_webpage tool result
*/
const ScrapeWebpageResultSchema = z.object({
id: z.string(),
assetId: z.string(),
kind: z.literal("article"),
href: z.string(),
title: z.string(),
description: z.string().nullish(),
content: z.string().nullish(),
domain: z.string().nullish(),
author: z.string().nullish(),
date: z.string().nullish(),
word_count: z.number().nullish(),
was_truncated: z.boolean().nullish(),
crawler_type: z.string().nullish(),
error: z.string().nullish(),
});
// ============================================================================
// Types
// ============================================================================
type ScrapeWebpageArgs = z.infer<typeof ScrapeWebpageArgsSchema>;
type ScrapeWebpageResult = z.infer<typeof ScrapeWebpageResultSchema>;
/**
* Error state component shown when webpage scraping fails
@ -154,4 +169,9 @@ export const ScrapeWebpageToolUI = makeAssistantToolUI<ScrapeWebpageArgs, Scrape
},
});
export type { ScrapeWebpageArgs, ScrapeWebpageResult };
export {
ScrapeWebpageArgsSchema,
ScrapeWebpageResultSchema,
type ScrapeWebpageArgs,
type ScrapeWebpageResult,
};

View file

@ -0,0 +1,41 @@
"use client";
import type { FC } from "react";
import { Button } from "@/components/ui/button";
import type { Action, ActionsConfig } from "./schema";
interface ActionButtonsProps {
actions?: Action[] | ActionsConfig;
onAction?: (actionId: string) => void;
disabled?: boolean;
}
export const ActionButtons: FC<ActionButtonsProps> = ({ actions, onAction, disabled }) => {
if (!actions) return null;
// Normalize actions to array format
const actionArray: Action[] = Array.isArray(actions)
? actions
: ([
actions.confirm && { ...actions.confirm, id: "confirm" },
actions.cancel && { ...actions.cancel, id: "cancel" },
].filter(Boolean) as Action[]);
if (actionArray.length === 0) return null;
return (
<div className="flex flex-wrap gap-2 pt-3">
{actionArray.map((action) => (
<Button
key={action.id}
variant={action.variant || "default"}
size="sm"
disabled={disabled || action.disabled}
onClick={() => onAction?.(action.id)}
>
{action.label}
</Button>
))}
</div>
);
};

View file

@ -0,0 +1,2 @@
export * from "./schema";
export * from "./action-buttons";

View file

@ -0,0 +1,23 @@
import { z } from "zod";
/**
* Shared action schema for tool UI components
*/
export const ActionSchema = z.object({
id: z.string(),
label: z.string(),
variant: z.enum(["default", "secondary", "destructive", "outline", "ghost", "link"]).optional(),
disabled: z.boolean().optional(),
});
export type Action = z.infer<typeof ActionSchema>;
/**
* Actions configuration schema
*/
export const ActionsConfigSchema = z.object({
confirm: ActionSchema.optional(),
cancel: ActionSchema.optional(),
});
export type ActionsConfig = z.infer<typeof ActionsConfigSchema>;

View file

@ -0,0 +1,158 @@
"use client";
import { makeAssistantToolUI, useAssistantState } from "@assistant-ui/react";
import { useAtomValue, useSetAtom } from "jotai";
import { Loader2 } from "lucide-react";
import { useEffect, useMemo } from "react";
import { z } from "zod";
import {
getCanonicalPlanTitle,
planStatesAtom,
registerPlanOwner,
updatePlanStateAtom,
} from "@/atoms/chat/plan-state.atom";
import { Plan, PlanErrorBoundary, parseSerializablePlan, TodoStatusSchema } from "./plan";
// ============================================================================
// Zod Schemas - Matching deepagents TodoListMiddleware output
// ============================================================================
/**
* Schema for a single todo item (matches deepagents output)
*/
const TodoItemSchema = z.object({
content: z.string(),
status: TodoStatusSchema,
});
/**
* Schema for write_todos tool args/result (matches deepagents output)
* deepagents provides: { todos: [{ content, status }] }
*/
const WriteTodosSchema = z.object({
todos: z.array(TodoItemSchema).nullish(),
});
// ============================================================================
// Types
// ============================================================================
type WriteTodosData = z.infer<typeof WriteTodosSchema>;
/**
* Loading state component
*/
function WriteTodosLoading() {
return (
<div className="my-4 w-full max-w-xl rounded-2xl border bg-card/60 px-5 py-4 shadow-sm">
<div className="flex items-center gap-3">
<Loader2 className="size-5 animate-spin text-primary" />
<span className="text-sm text-muted-foreground">Creating plan...</span>
</div>
</div>
);
}
/**
* WriteTodos Tool UI Component
*
* Displays the agent's planning/todo list with a beautiful UI.
* Uses deepagents TodoListMiddleware output directly: { todos: [{ content, status }] }
*
* FIXED POSITION: When multiple write_todos calls happen in a conversation,
* only the FIRST component renders. Subsequent updates just update the
* shared state, and the first component reads from it.
*/
export const WriteTodosToolUI = makeAssistantToolUI<WriteTodosData, WriteTodosData>({
toolName: "write_todos",
render: function WriteTodosUI({ args, result, status, toolCallId }) {
const updatePlanState = useSetAtom(updatePlanStateAtom);
const planStates = useAtomValue(planStatesAtom);
// Check if the THREAD is running
const isThreadRunning = useAssistantState(({ thread }) => thread.isRunning);
// Use result if available, otherwise args (for streaming)
const data = result || args;
const hasTodos = data?.todos && data.todos.length > 0;
// Fixed title for all plans in conversation
const planTitle = "Plan";
// SYNCHRONOUS ownership check
const isOwner = useMemo(() => {
return registerPlanOwner(planTitle, toolCallId);
}, [planTitle, toolCallId]);
// Get canonical title
const canonicalTitle = useMemo(() => getCanonicalPlanTitle(planTitle), [planTitle]);
// Register/update the plan state
useEffect(() => {
if (hasTodos) {
const normalizedPlan = parseSerializablePlan({ todos: data.todos });
updatePlanState({
id: normalizedPlan.id,
title: canonicalTitle,
todos: normalizedPlan.todos,
toolCallId,
});
}
}, [data, hasTodos, canonicalTitle, updatePlanState, toolCallId]);
// Get the current plan state
const currentPlanState = planStates.get(canonicalTitle);
// If we're NOT the owner, render nothing
if (!isOwner) {
return null;
}
// Loading state
if (status.type === "running" || status.type === "requires-action") {
if (hasTodos) {
const plan = parseSerializablePlan({ todos: data.todos });
return (
<div className="my-4">
<PlanErrorBoundary>
<Plan {...plan} showProgress={true} isStreaming={isThreadRunning} />
</PlanErrorBoundary>
</div>
);
}
return <WriteTodosLoading />;
}
// Incomplete/cancelled state
if (status.type === "incomplete") {
if (currentPlanState || hasTodos) {
const plan = currentPlanState || parseSerializablePlan({ todos: data?.todos || [] });
return (
<div className="my-4">
<PlanErrorBoundary>
<Plan {...plan} showProgress={true} isStreaming={isThreadRunning} />
</PlanErrorBoundary>
</div>
);
}
return null;
}
// Success - render the plan
const planToRender =
currentPlanState || (hasTodos ? parseSerializablePlan({ todos: data.todos }) : null);
if (!planToRender) {
return <WriteTodosLoading />;
}
return (
<div className="my-4">
<PlanErrorBoundary>
<Plan {...planToRender} showProgress={true} isStreaming={isThreadRunning} />
</PlanErrorBoundary>
</div>
);
},
});
export { WriteTodosSchema, type WriteTodosData };

View file

@ -449,7 +449,7 @@ function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
}
const sidebarMenuButtonVariants = cva(
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-hidden ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-data-[sidebar=menu-action]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-hidden ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-data-[sidebar=menu-action]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
{
variants: {
variant: {

View file

@ -85,6 +85,7 @@ export const logActiveTask = z.object({
message: z.string(),
started_at: z.string(),
source: z.string().nullable().optional(),
document_id: z.number().nullable().optional(),
});
export const logFailure = z.object({
id: z.number(),

View file

@ -39,6 +39,7 @@ export interface LogSummary {
message: string;
started_at: string;
source?: string;
document_id?: number;
}>;
recent_failures: Array<{
id: number;
@ -117,8 +118,12 @@ export function useLogs(searchSpaceId?: number, filters: LogFilters = {}) {
};
}
// Separate hook for log summary
export function useLogsSummary(searchSpaceId: number, hours: number = 24) {
// Separate hook for log summary with optional polling support for document processing indicator UI
export function useLogsSummary(
searchSpaceId: number,
hours: number = 24,
options: { refetchInterval?: number } = {}
) {
const {
data: summary,
isLoading: loading,
@ -133,6 +138,9 @@ export function useLogsSummary(searchSpaceId: number, hours: number = 24) {
}),
enabled: !!searchSpaceId,
staleTime: 3 * 60 * 1000,
// Enable refetch interval for document processing indicator polling
refetchInterval:
options.refetchInterval && options.refetchInterval > 0 ? options.refetchInterval : undefined,
});
return { summary, loading, error, refreshSummary: refetch };

View file

@ -267,7 +267,11 @@
"content_summary": "Content Summary",
"view_full": "View Full Content",
"filter_placeholder": "Filter by title...",
"rows_per_page": "Rows per page"
"rows_per_page": "Rows per page",
"refresh": "Refresh",
"refresh_success": "Documents refreshed",
"processing_documents": "Processing documents...",
"active_tasks_count": "{count} active task(s)"
},
"add_connector": {
"title": "Connect Your Tools",

View file

@ -267,7 +267,11 @@
"content_summary": "内容摘要",
"view_full": "查看完整内容",
"filter_placeholder": "按标题筛选...",
"rows_per_page": "每页行数"
"rows_per_page": "每页行数",
"refresh": "刷新",
"refresh_success": "文档已刷新",
"processing_documents": "正在处理文档...",
"active_tasks_count": "{count} 个正在进行的工作项"
},
"add_connector": {
"title": "连接您的工具",