Merge pull request #884 from AnishSarkar22/feat/web-search

feat: add web search (SearXNG) & UI changes
This commit is contained in:
Rohan Verma 2026-03-17 14:43:20 -07:00 committed by GitHub
commit 9d4945c8a5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
104 changed files with 3831 additions and 2801 deletions

View file

@ -0,0 +1,136 @@
---
name: system-architecture
description: Design systems with appropriate complexity - no more, no less. Use when the user asks to architect applications, design system boundaries, plan service decomposition, evaluate monolith vs microservices, make scaling decisions, or review structural trade-offs. Applies to new system design, refactoring, and migration planning.
---
# System Architecture
Design real structures with clear boundaries, explicit trade-offs, and appropriate complexity. Match architecture to actual requirements, not imagined future needs.
## Workflow
When the user requests an architecture, follow these steps:
```
Task Progress:
- [ ] Step 1: Clarify constraints
- [ ] Step 2: Identify domains
- [ ] Step 3: Map data flow
- [ ] Step 4: Draw boundaries with rationale
- [ ] Step 5: Run complexity checklist
- [ ] Step 6: Present architecture with trade-offs
```
**Step 1 - Clarify constraints.** Ask about:
| Constraint | Question | Why it matters |
|------------|----------|----------------|
| Scale | What's the real load? (users, requests/sec, data size) | Design for 10x current, not 1000x |
| Team | How many developers? How many teams? | Deployable units ≤ number of teams |
| Lifespan | Prototype? MVP? Long-term product? | Temporary systems need temporary solutions |
| Change vectors | What actually varies? | Abstract only where you have evidence of variation |
**Step 2 - Identify domains.** Group by business capability, not technical layer. Look for things that change for different reasons and at different rates.
**Step 3 - Map data flow.** Trace: where does data enter → how does it transform → where does it exit? Make the flow obvious.
**Step 4 - Draw boundaries.** Every boundary needs a reason: different team, different change rate, different compliance requirement, or different scaling need.
**Step 5 - Run complexity checklist.** Before adding any non-trivial pattern:
```
[ ] Have I tried the simple solution?
[ ] Do I have evidence it's insufficient?
[ ] Can my team operate this?
[ ] Will this still make sense in 6 months?
[ ] Can I explain why this complexity is necessary?
```
If any answer is "no", keep it simple.
**Step 6 - Present the architecture** using the output template below.
## Output Template
```markdown
### System: [Name]
**Constraints**:
- Scale: [current and expected load]
- Team: [size and structure]
- Lifespan: [prototype / MVP / long-term]
**Architecture**:
[Component diagram or description of components and their relationships]
**Data Flow**:
[How data enters → transforms → exits]
**Key Boundaries**:
| Boundary | Reason | Change Rate |
|----------|--------|-------------|
| ... | ... | ... |
**Trade-offs**:
- Chose X over Y because [reason]
- Accepted [limitation] to gain [benefit]
**Complexity Justification**:
- [Each non-trivial pattern] → [why it's needed, with evidence]
```
## Core Principles
1. **Boundaries at real differences.** Separate concerns that change for different reasons and at different rates.
2. **Dependencies flow inward.** Core logic depends on nothing. Infrastructure depends on core.
3. **Follow the data.** Architecture should make data flow obvious.
4. **Design for failure.** Network fails. Databases timeout. Build compensation into the structure.
5. **Design for operations.** You will debug this at 3am. Every request needs a trace. Every error needs context for replay.
For concrete good/bad examples of each principle, see [examples.md](examples.md).
## Anti-Patterns
| Don't | Do Instead |
|-------|------------|
| Microservices for a 3-person team | Well-structured monolith |
| Event sourcing for CRUD | Simple state storage |
| Message queues within the same process | Just call the function |
| Distributed transactions | Redesign to avoid, or accept eventual consistency |
| Repository wrapping an ORM | Use the ORM directly |
| Interfaces with one implementation | Mock at boundaries only |
| AbstractFactoryFactoryBean | Just instantiate the thing |
| DI containers for simple graphs | Constructor injection is enough |
| Clean Architecture for a TODO app | Match layers to actual complexity |
| DDD tactics without strategic design | Aggregates need bounded contexts |
| Hexagonal ports with one adapter | Just call the database |
| CQRS when reads = writes | Add when they diverge |
| "We might swap databases" | You won't; rewrite if you do |
| "Multi-tenant someday" | Build it when you have tenant #2 |
| "Microservices for team scale" | Helps at 50+ engineers, not 4 |
## Success Criteria
Your architecture is right-sized when:
1. **You can draw it** - dependency graph fits on a whiteboard
2. **You can explain it** - new team member understands data flow in 30 minutes
3. **You can change it** - adding a feature touches 1-3 modules, not 10
4. **You can delete it** - removing a component needs no archaeology
5. **You can debug it** - tracing a request takes minutes, not hours
6. **It matches your team** - deployable units ≤ number of teams
## When the Simple Solution Isn't Enough
If the complexity checklist says "yes, scale is real", see [scaling-checklist.md](scaling-checklist.md) for concrete techniques covering caching, async processing, partitioning, horizontal scaling, and multi-region.
## Iterative Architecture
Architecture is discovered, not designed upfront:
1. **Start obvious** - group by domain, not by technical layer
2. **Let hotspots emerge** - monitor which modules change together
3. **Extract when painful** - split only when the current form causes measurable problems
4. **Document decisions** - record why boundaries exist so future you knows what's load-bearing
Every senior engineer has a graveyard of over-engineered systems they regret. Learn from their pain. Build boring systems that work.

View file

@ -0,0 +1,120 @@
# Architecture Examples
Concrete good/bad examples for each core principle in SKILL.md.
---
## Boundaries at Real Differences
**Good** - Meaningful boundary:
```
# Users and Billing are separate bounded contexts
# - Different teams own them
# - Different change cadences (users: weekly, billing: quarterly)
# - Different compliance requirements
src/
users/ # User management domain
models.py
services.py
api.py
billing/ # Billing domain
models.py
services.py
api.py
shared/ # Truly shared utilities
auth.py
```
**Bad** - Ceremony without purpose:
```
# UserService → UserRepository → UserRepositoryImpl
# ...when you'll never swap the database
src/
interfaces/
IUserRepository.py # One implementation exists
repositories/
UserRepositoryImpl.py # Wraps SQLAlchemy, which is already a repository
services/
UserService.py # Just calls the repository
```
---
## Dependencies Flow Inward
**Good** - Clear dependency direction:
```
# Dependency flows inward: infrastructure → application → domain
domain/ # Pure business logic, no imports from outer layers
order.py # Order entity with business rules
application/ # Use cases, orchestrates domain
place_order.py # Imports from domain/, not infrastructure/
infrastructure/ # External concerns
postgres.py # Implements persistence, imports from application/
stripe.py # Implements payments
```
---
## Follow the Data
**Good** - Obvious data flow:
```
Request → Validate → Transform → Store → Respond
# Each step is a clear function/module:
api/routes.py # Request enters
validators.py # Validation
transformers.py # Business logic transformation
repositories.py # Storage
serializers.py # Response shaping
```
---
## Design for Failure
**Good** - Failure-aware design with compensation:
```python
class OrderService:
def place_order(self, order: Order) -> Result:
inventory = self.inventory.reserve(order.items)
if inventory.failed:
return Result.failure("Items unavailable", retry=False)
payment = self.payments.charge(order.total)
if payment.failed:
self.inventory.release(inventory.reservation_id) # Compensate
return Result.failure("Payment failed", retry=True)
return Result.success(order)
```
---
## Design for Operations
**Good** - Observable architecture:
```python
@trace
def handle_request(request):
log.info("Processing", request_id=request.id, user=request.user_id)
try:
result = process(request)
log.info("Completed", request_id=request.id, result=result.status)
return result
except Exception as e:
log.error("Failed", request_id=request.id, error=str(e),
context=request.to_dict()) # Full context for replay
raise
```
Key elements:
- Every request gets a correlation ID
- Every service logs with that ID
- Every error includes full context for reproduction

View file

@ -0,0 +1,76 @@
# Scaling Checklist
Concrete techniques for when the complexity checklist in SKILL.md confirms scale is a real problem. Apply in order - each level solves the previous level's bottleneck.
---
## Level 0: Optimize First
Before adding infrastructure, exhaust these:
- [ ] Database queries have proper indexes
- [ ] N+1 queries eliminated
- [ ] Connection pooling configured
- [ ] Slow endpoints profiled and optimized
- [ ] Static assets served via CDN
## Level 1: Read-Heavy
**Symptom**: Database reads are the bottleneck.
| Technique | When | Trade-off |
|-----------|------|-----------|
| Application cache (in-memory) | Small, frequently accessed data | Stale data, memory pressure |
| Redis/Memcached | Shared cache across instances | Network hop, cache invalidation complexity |
| Read replicas | High read volume, slight staleness OK | Replication lag, eventual consistency |
| CDN | Static or semi-static content | Cache invalidation delay |
## Level 2: Write-Heavy
**Symptom**: Database writes or processing are the bottleneck.
| Technique | When | Trade-off |
|-----------|------|-----------|
| Async task queue (Celery, SQS) | Work can be deferred | Eventual consistency, failure handling |
| Write-behind cache | Batch frequent writes | Data loss risk on crash |
| Event streaming (Kafka) | Multiple consumers of same data | Operational complexity, ordering guarantees |
| CQRS | Reads and writes have diverged significantly | Two models to maintain |
## Level 3: Traffic Spikes
**Symptom**: Individual instances can't handle peak load.
| Technique | When | Trade-off |
|-----------|------|-----------|
| Horizontal scaling + load balancer | Stateless services | Session management, deploy complexity |
| Auto-scaling | Unpredictable traffic patterns | Cold start latency, cost spikes |
| Rate limiting | Protect against abuse/spikes | Legitimate users may be throttled |
| Circuit breakers | Downstream services degrade | Partial functionality during failures |
## Level 4: Data Growth
**Symptom**: Single database can't hold or query all the data efficiently.
| Technique | When | Trade-off |
|-----------|------|-----------|
| Table partitioning | Time-series or naturally partitioned data | Query complexity, partition management |
| Archival / cold storage | Old data rarely accessed | Access latency for archived data |
| Database sharding | Partitioning insufficient, clear shard key exists | Cross-shard queries, operational burden |
| Search index (Elasticsearch) | Full-text or complex queries on large datasets | Index lag, another system to operate |
## Level 5: Multi-Region
**Symptom**: Users are geographically distributed, latency matters.
| Technique | When | Trade-off |
|-----------|------|-----------|
| CDN + edge caching | Static/semi-static content | Cache invalidation |
| Read replicas per region | Read-heavy, slight staleness OK | Replication lag |
| Active-passive failover | Disaster recovery | Failover time, cost of standby |
| Active-active multi-region | True global low-latency required | Conflict resolution, extreme complexity |
---
## Decision Rule
Always start at Level 0. Move to the next level only when you have **measured evidence** that the current level is insufficient. Skipping levels is how you end up with Kafka for a TODO app.

View file

@ -36,6 +36,7 @@ EMBEDDING_MODEL=sentence-transformers/all-MiniLM-L6-v2
# BACKEND_PORT=8929 # BACKEND_PORT=8929
# FRONTEND_PORT=3929 # FRONTEND_PORT=3929
# ELECTRIC_PORT=5929 # ELECTRIC_PORT=5929
# SEARXNG_PORT=8888
# FLOWER_PORT=5555 # FLOWER_PORT=5555
# ============================================================================== # ==============================================================================
@ -199,6 +200,16 @@ STT_SERVICE=local/base
# COMPOSIO_ENABLED=TRUE # COMPOSIO_ENABLED=TRUE
# COMPOSIO_REDIRECT_URI=http://localhost:8000/api/v1/auth/composio/connector/callback # COMPOSIO_REDIRECT_URI=http://localhost:8000/api/v1/auth/composio/connector/callback
# ------------------------------------------------------------------------------
# SearXNG (bundled web search — works out of the box, no config needed)
# ------------------------------------------------------------------------------
# SearXNG provides web search to all search spaces automatically.
# To access the SearXNG UI directly: http://localhost:8888
# To disable the service entirely: docker compose up --scale searxng=0
# To point at your own SearXNG instance instead of the bundled one:
# SEARXNG_DEFAULT_HOST=http://your-searxng:8080
# SEARXNG_SECRET=surfsense-searxng-secret
# ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------
# Daytona Sandbox (optional — cloud code execution for the deep agent) # Daytona Sandbox (optional — cloud code execution for the deep agent)
# ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------

View file

@ -57,6 +57,20 @@ services:
timeout: 5s timeout: 5s
retries: 5 retries: 5
searxng:
image: searxng/searxng:2026.3.13-3c1f68c59
ports:
- "${SEARXNG_PORT:-8888}:8080"
volumes:
- ./searxng:/etc/searxng
environment:
- SEARXNG_SECRET=${SEARXNG_SECRET:-surfsense-searxng-secret}
healthcheck:
test: ["CMD", "wget", "--spider", "-q", "http://localhost:8080/healthz"]
interval: 10s
timeout: 5s
retries: 5
backend: backend:
build: ../surfsense_backend build: ../surfsense_backend
ports: ports:
@ -81,6 +95,7 @@ services:
- ELECTRIC_DB_PASSWORD=${ELECTRIC_DB_PASSWORD:-electric_password} - ELECTRIC_DB_PASSWORD=${ELECTRIC_DB_PASSWORD:-electric_password}
- AUTH_TYPE=${AUTH_TYPE:-LOCAL} - AUTH_TYPE=${AUTH_TYPE:-LOCAL}
- NEXT_FRONTEND_URL=${NEXT_FRONTEND_URL:-http://localhost:3000} - NEXT_FRONTEND_URL=${NEXT_FRONTEND_URL:-http://localhost:3000}
- SEARXNG_DEFAULT_HOST=${SEARXNG_DEFAULT_HOST:-http://searxng:8080}
# Daytona Sandbox uncomment and set credentials to enable cloud code execution # Daytona Sandbox uncomment and set credentials to enable cloud code execution
# - DAYTONA_SANDBOX_ENABLED=TRUE # - DAYTONA_SANDBOX_ENABLED=TRUE
# - DAYTONA_API_KEY=${DAYTONA_API_KEY:-} # - DAYTONA_API_KEY=${DAYTONA_API_KEY:-}
@ -92,6 +107,8 @@ services:
condition: service_healthy condition: service_healthy
redis: redis:
condition: service_healthy condition: service_healthy
searxng:
condition: service_healthy
healthcheck: healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/health"] test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
interval: 15s interval: 15s
@ -115,6 +132,7 @@ services:
- PYTHONPATH=/app - PYTHONPATH=/app
- ELECTRIC_DB_USER=${ELECTRIC_DB_USER:-electric} - ELECTRIC_DB_USER=${ELECTRIC_DB_USER:-electric}
- ELECTRIC_DB_PASSWORD=${ELECTRIC_DB_PASSWORD:-electric_password} - ELECTRIC_DB_PASSWORD=${ELECTRIC_DB_PASSWORD:-electric_password}
- SEARXNG_DEFAULT_HOST=${SEARXNG_DEFAULT_HOST:-http://searxng:8080}
- SERVICE_ROLE=worker - SERVICE_ROLE=worker
depends_on: depends_on:
db: db:

View file

@ -42,6 +42,19 @@ services:
timeout: 5s timeout: 5s
retries: 5 retries: 5
searxng:
image: searxng/searxng:2026.3.13-3c1f68c59
volumes:
- ./searxng:/etc/searxng
environment:
SEARXNG_SECRET: ${SEARXNG_SECRET:-surfsense-searxng-secret}
restart: unless-stopped
healthcheck:
test: ["CMD", "wget", "--spider", "-q", "http://localhost:8080/healthz"]
interval: 10s
timeout: 5s
retries: 5
backend: backend:
image: ghcr.io/modsetter/surfsense-backend:${SURFSENSE_VERSION:-latest} image: ghcr.io/modsetter/surfsense-backend:${SURFSENSE_VERSION:-latest}
ports: ports:
@ -62,6 +75,7 @@ services:
ELECTRIC_DB_USER: ${ELECTRIC_DB_USER:-electric} ELECTRIC_DB_USER: ${ELECTRIC_DB_USER:-electric}
ELECTRIC_DB_PASSWORD: ${ELECTRIC_DB_PASSWORD:-electric_password} ELECTRIC_DB_PASSWORD: ${ELECTRIC_DB_PASSWORD:-electric_password}
NEXT_FRONTEND_URL: ${NEXT_FRONTEND_URL:-http://localhost:${FRONTEND_PORT:-3929}} NEXT_FRONTEND_URL: ${NEXT_FRONTEND_URL:-http://localhost:${FRONTEND_PORT:-3929}}
SEARXNG_DEFAULT_HOST: ${SEARXNG_DEFAULT_HOST:-http://searxng:8080}
# Daytona Sandbox uncomment and set credentials to enable cloud code execution # Daytona Sandbox uncomment and set credentials to enable cloud code execution
# DAYTONA_SANDBOX_ENABLED: "TRUE" # DAYTONA_SANDBOX_ENABLED: "TRUE"
# DAYTONA_API_KEY: ${DAYTONA_API_KEY:-} # DAYTONA_API_KEY: ${DAYTONA_API_KEY:-}
@ -75,6 +89,8 @@ services:
condition: service_healthy condition: service_healthy
redis: redis:
condition: service_healthy condition: service_healthy
searxng:
condition: service_healthy
restart: unless-stopped restart: unless-stopped
healthcheck: healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/health"] test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
@ -98,6 +114,7 @@ services:
PYTHONPATH: /app PYTHONPATH: /app
ELECTRIC_DB_USER: ${ELECTRIC_DB_USER:-electric} ELECTRIC_DB_USER: ${ELECTRIC_DB_USER:-electric}
ELECTRIC_DB_PASSWORD: ${ELECTRIC_DB_PASSWORD:-electric_password} ELECTRIC_DB_PASSWORD: ${ELECTRIC_DB_PASSWORD:-electric_password}
SEARXNG_DEFAULT_HOST: ${SEARXNG_DEFAULT_HOST:-http://searxng:8080}
SERVICE_ROLE: worker SERVICE_ROLE: worker
depends_on: depends_on:
db: db:

View file

@ -103,6 +103,7 @@ Write-Step "Downloading SurfSense files"
Write-Info "Installation directory: $InstallDir" Write-Info "Installation directory: $InstallDir"
New-Item -ItemType Directory -Path "$InstallDir\scripts" -Force | Out-Null New-Item -ItemType Directory -Path "$InstallDir\scripts" -Force | Out-Null
New-Item -ItemType Directory -Path "$InstallDir\searxng" -Force | Out-Null
$Files = @( $Files = @(
@{ Src = "docker/docker-compose.yml"; Dest = "docker-compose.yml" } @{ Src = "docker/docker-compose.yml"; Dest = "docker-compose.yml" }
@ -110,6 +111,8 @@ $Files = @(
@{ Src = "docker/postgresql.conf"; Dest = "postgresql.conf" } @{ Src = "docker/postgresql.conf"; Dest = "postgresql.conf" }
@{ Src = "docker/scripts/init-electric-user.sh"; Dest = "scripts/init-electric-user.sh" } @{ Src = "docker/scripts/init-electric-user.sh"; Dest = "scripts/init-electric-user.sh" }
@{ Src = "docker/scripts/migrate-database.ps1"; Dest = "scripts/migrate-database.ps1" } @{ Src = "docker/scripts/migrate-database.ps1"; Dest = "scripts/migrate-database.ps1" }
@{ Src = "docker/searxng/settings.yml"; Dest = "searxng/settings.yml" }
@{ Src = "docker/searxng/limiter.toml"; Dest = "searxng/limiter.toml" }
) )
foreach ($f in $Files) { foreach ($f in $Files) {

View file

@ -102,6 +102,7 @@ wait_for_pg() {
step "Downloading SurfSense files" step "Downloading SurfSense files"
info "Installation directory: ${INSTALL_DIR}" info "Installation directory: ${INSTALL_DIR}"
mkdir -p "${INSTALL_DIR}/scripts" mkdir -p "${INSTALL_DIR}/scripts"
mkdir -p "${INSTALL_DIR}/searxng"
FILES=( FILES=(
"docker/docker-compose.yml:docker-compose.yml" "docker/docker-compose.yml:docker-compose.yml"
@ -109,6 +110,8 @@ FILES=(
"docker/postgresql.conf:postgresql.conf" "docker/postgresql.conf:postgresql.conf"
"docker/scripts/init-electric-user.sh:scripts/init-electric-user.sh" "docker/scripts/init-electric-user.sh:scripts/init-electric-user.sh"
"docker/scripts/migrate-database.sh:scripts/migrate-database.sh" "docker/scripts/migrate-database.sh:scripts/migrate-database.sh"
"docker/searxng/settings.yml:searxng/settings.yml"
"docker/searxng/limiter.toml:searxng/limiter.toml"
) )
for entry in "${FILES[@]}"; do for entry in "${FILES[@]}"; do

View file

@ -0,0 +1,5 @@
[botdetection.ip_limit]
link_token = false
[botdetection.ip_lists]
pass_ip = ["0.0.0.0/0"]

View file

@ -0,0 +1,90 @@
use_default_settings:
engines:
remove:
- ahmia
- torch
- qwant
- qwant news
- qwant images
- qwant videos
- mojeek
- mojeek images
- mojeek news
server:
secret_key: "override-me-via-env"
limiter: false
image_proxy: false
method: "GET"
default_http_headers:
X-Robots-Tag: "noindex, nofollow"
search:
formats:
- html
- json
default_lang: "auto"
autocomplete: ""
safe_search: 0
ban_time_on_fail: 5
max_ban_time_on_fail: 120
suspended_times:
SearxEngineAccessDenied: 3600
SearxEngineCaptcha: 3600
SearxEngineTooManyRequests: 600
cf_SearxEngineCaptcha: 7200
cf_SearxEngineAccessDenied: 3600
recaptcha_SearxEngineCaptcha: 7200
ui:
static_use_hash: true
outgoing:
request_timeout: 12.0
max_request_timeout: 20.0
pool_connections: 100
pool_maxsize: 20
enable_http2: true
extra_proxy_timeout: 10
retries: 1
# Uncomment and set your residential proxy URL to route search engine requests through it.
# Format: http://<username>:<base64_password>@<hostname>:<port>/
#
# proxies:
# all://:
# - http://user:pass@proxy-host:port/
engines:
- name: google
disabled: false
weight: 1.2
retry_on_http_error: [429, 503]
- name: duckduckgo
disabled: false
weight: 1.1
retry_on_http_error: [429, 503]
- name: brave
disabled: false
weight: 1.0
retry_on_http_error: [429, 503]
- name: bing
disabled: false
weight: 0.9
retry_on_http_error: [429, 503]
- name: wikipedia
disabled: false
weight: 0.8
- name: stackoverflow
disabled: false
weight: 0.7
- name: yahoo
disabled: false
weight: 0.7
retry_on_http_error: [429, 503]
- name: wikidata
disabled: false
weight: 0.6
- name: currency
disabled: false
- name: ddg definitions
disabled: false

View file

@ -12,6 +12,11 @@ REDIS_APP_URL=redis://localhost:6379/0
# Optional: TTL in seconds for connector indexing lock key # Optional: TTL in seconds for connector indexing lock key
# CONNECTOR_INDEXING_LOCK_TTL_SECONDS=28800 # CONNECTOR_INDEXING_LOCK_TTL_SECONDS=28800
# Platform Web Search (SearXNG)
# Set this to enable built-in web search. Docker Compose sets it automatically.
# Only uncomment if running the backend outside Docker (e.g. uvicorn on host).
# SEARXNG_DEFAULT_HOST=http://localhost:8888
#Electric(for migrations only) #Electric(for migrations only)
ELECTRIC_DB_USER=electric ELECTRIC_DB_USER=electric
ELECTRIC_DB_PASSWORD=electric_password ELECTRIC_DB_PASSWORD=electric_password

View file

@ -37,13 +37,15 @@ _perf_log = get_perf_logger()
# ============================================================================= # =============================================================================
# Maps SearchSourceConnectorType enum values to the searchable document/connector types # Maps SearchSourceConnectorType enum values to the searchable document/connector types
# used by the knowledge_base tool. Some connectors map to different document types. # used by the knowledge_base and web_search tools.
# Live search connectors (TAVILY_API, LINKUP_API, BAIDU_SEARCH_API) are routed to
# the web_search tool; all others go to search_knowledge_base.
_CONNECTOR_TYPE_TO_SEARCHABLE: dict[str, str] = { _CONNECTOR_TYPE_TO_SEARCHABLE: dict[str, str] = {
# Direct mappings (connector type == searchable type) # Live search connectors (handled by web_search tool)
"TAVILY_API": "TAVILY_API", "TAVILY_API": "TAVILY_API",
"SEARXNG_API": "SEARXNG_API",
"LINKUP_API": "LINKUP_API", "LINKUP_API": "LINKUP_API",
"BAIDU_SEARCH_API": "BAIDU_SEARCH_API", "BAIDU_SEARCH_API": "BAIDU_SEARCH_API",
# Local/indexed connectors (handled by search_knowledge_base tool)
"SLACK_CONNECTOR": "SLACK_CONNECTOR", "SLACK_CONNECTOR": "SLACK_CONNECTOR",
"TEAMS_CONNECTOR": "TEAMS_CONNECTOR", "TEAMS_CONNECTOR": "TEAMS_CONNECTOR",
"NOTION_CONNECTOR": "NOTION_CONNECTOR", "NOTION_CONNECTOR": "NOTION_CONNECTOR",
@ -233,6 +235,7 @@ async def create_surfsense_deep_agent(
available_document_types = await connector_service.get_available_document_types( available_document_types = await connector_service.get_available_document_types(
search_space_id search_space_id
) )
except Exception as e: except Exception as e:
logging.warning(f"Failed to discover available connectors/document types: {e}") logging.warning(f"Failed to discover available connectors/document types: {e}")
_perf_log.info( _perf_log.info(

View file

@ -99,14 +99,8 @@ _TOOL_INSTRUCTIONS["search_knowledge_base"] = """
- IMPORTANT: When searching for information (meetings, schedules, notes, tasks, etc.), ALWAYS search broadly - IMPORTANT: When searching for information (meetings, schedules, notes, tasks, etc.), ALWAYS search broadly
across ALL sources first by omitting connectors_to_search. The user may store information in various places across ALL sources first by omitting connectors_to_search. The user may store information in various places
including calendar apps, note-taking apps (Obsidian, Notion), chat apps (Slack, Discord), and more. including calendar apps, note-taking apps (Obsidian, Notion), chat apps (Slack, Discord), and more.
- IMPORTANT (REAL-TIME / PUBLIC WEB QUERIES): For questions that require current public web data - This tool searches ONLY local/indexed data (uploaded files, Notion, Slack, browser extension captures, etc.).
(e.g., live exchange rates, stock prices, breaking news, weather, current events), you MUST call For real-time web search (current events, news, live data), use the `web_search` tool instead.
`search_knowledge_base` using live web connectors via `connectors_to_search`:
["LINKUP_API", "TAVILY_API", "SEARXNG_API", "BAIDU_SEARCH_API"].
- For these real-time/public web queries, DO NOT answer from memory and DO NOT say you lack internet
access before attempting a live connector search.
- If the live connectors return no relevant results, explain that live web sources did not return enough
data and ask the user if they want you to retry with a refined query.
- FALLBACK BEHAVIOR: If the search returns no relevant results, you MAY then answer using your own - FALLBACK BEHAVIOR: If the search returns no relevant results, you MAY then answer using your own
general knowledge, but clearly indicate that no matching information was found in the knowledge base. general knowledge, but clearly indicate that no matching information was found in the knowledge base.
- Only narrow to specific connectors if the user explicitly asks (e.g., "check my Slack" or "in my calendar"). - Only narrow to specific connectors if the user explicitly asks (e.g., "check my Slack" or "in my calendar").
@ -271,6 +265,24 @@ _TOOL_INSTRUCTIONS["scrape_webpage"] = """
* Don't show every image - just the most relevant 1-3 images that enhance understanding. * Don't show every image - just the most relevant 1-3 images that enhance understanding.
""" """
_TOOL_INSTRUCTIONS["web_search"] = """
- web_search: Search the web for real-time information using all configured search engines.
- Use this for current events, news, prices, weather, public facts, or any question requiring
up-to-date information from the internet.
- This tool dispatches to all configured search engines (SearXNG, Tavily, Linkup, Baidu) in
parallel and merges the results.
- IMPORTANT (REAL-TIME / PUBLIC WEB QUERIES): For questions that require current public web data
(e.g., live exchange rates, stock prices, breaking news, weather, current events), you MUST call
`web_search` instead of answering from memory.
- For these real-time/public web queries, DO NOT answer from memory and DO NOT say you lack internet
access before attempting a web search.
- If the search returns no relevant results, explain that web sources did not return enough
data and ask the user if they want you to retry with a refined query.
- Args:
- query: The search query - use specific, descriptive terms
- top_k: Number of results to retrieve (default: 10, max: 50)
"""
# Memory tool instructions have private and shared variants. # Memory tool instructions have private and shared variants.
# We store them keyed as "save_memory" / "recall_memory" with sub-keys. # We store them keyed as "save_memory" / "recall_memory" with sub-keys.
_MEMORY_TOOL_INSTRUCTIONS: dict[str, dict[str, str]] = { _MEMORY_TOOL_INSTRUCTIONS: dict[str, dict[str, str]] = {
@ -401,7 +413,7 @@ _TOOL_EXAMPLES["search_knowledge_base"] = """
- User: "Check my Obsidian notes for meeting notes" - User: "Check my Obsidian notes for meeting notes"
- Call: `search_knowledge_base(query="meeting notes", connectors_to_search=["OBSIDIAN_CONNECTOR"])` - Call: `search_knowledge_base(query="meeting notes", connectors_to_search=["OBSIDIAN_CONNECTOR"])`
- User: "search me current usd to inr rate" - User: "search me current usd to inr rate"
- Call: `search_knowledge_base(query="current USD to INR exchange rate", connectors_to_search=["LINKUP_API", "TAVILY_API", "SEARXNG_API", "BAIDU_SEARCH_API"])` - Call: `web_search(query="current USD to INR exchange rate")`
- Then answer using the returned live web results with citations. - Then answer using the returned live web results with citations.
""" """
@ -471,10 +483,21 @@ _TOOL_EXAMPLES["generate_image"] = """
- Step 2: `display_image(src="<returned_url>", alt="Bean Dream coffee shop logo", title="Generated Image")` - Step 2: `display_image(src="<returned_url>", alt="Bean Dream coffee shop logo", title="Generated Image")`
""" """
_TOOL_EXAMPLES["web_search"] = """
- User: "What's the current USD to INR exchange rate?"
- Call: `web_search(query="current USD to INR exchange rate")`
- Then answer using the returned web results with citations.
- User: "What's the latest news about AI?"
- Call: `web_search(query="latest AI news today")`
- User: "What's the weather in New York?"
- Call: `web_search(query="weather New York today")`
"""
# All tool names that have prompt instructions (order matters for prompt readability) # All tool names that have prompt instructions (order matters for prompt readability)
_ALL_TOOL_NAMES_ORDERED = [ _ALL_TOOL_NAMES_ORDERED = [
"search_surfsense_docs", "search_surfsense_docs",
"search_knowledge_base", "search_knowledge_base",
"web_search",
"generate_podcast", "generate_podcast",
"generate_report", "generate_report",
"link_preview", "link_preview",
@ -543,7 +566,7 @@ DISABLED TOOLS (by user):
The following tools are available in SurfSense but have been disabled by the user for this session: {disabled_list}. The following tools are available in SurfSense but have been disabled by the user for this session: {disabled_list}.
You do NOT have access to these tools and MUST NOT claim you can use them. You do NOT have access to these tools and MUST NOT claim you can use them.
If the user asks about a capability provided by a disabled tool, let them know the relevant tool If the user asks about a capability provided by a disabled tool, let them know the relevant tool
is currently disabled and they can re-enable it from the tools menu (wrench icon) in the composer toolbar. is currently disabled and they can re-enable it.
""") """)
parts.append("\n</tools>\n") parts.append("\n</tools>\n")
@ -595,11 +618,10 @@ The documents you receive are structured like this:
</document_content> </document_content>
</document> </document>
**Live web search results (URL chunk IDs):** **Web search results (URL chunk IDs):**
<document> <document>
<document_metadata> <document_metadata>
<document_id>TAVILY_API::Some Title::https://example.com/article</document_id> <document_type>WEB_SEARCH</document_type>
<document_type>TAVILY_API</document_type>
<title><![CDATA[Some web search result]]></title> <title><![CDATA[Some web search result]]></title>
<url><![CDATA[https://example.com/article]]></url> <url><![CDATA[https://example.com/article]]></url>
</document_metadata> </document_metadata>

View file

@ -23,11 +23,10 @@ from app.db import shielded_async_session
from app.services.connector_service import ConnectorService from app.services.connector_service import ConnectorService
from app.utils.perf import get_perf_logger from app.utils.perf import get_perf_logger
# Connectors that call external live-search APIs (no local DB / embedding needed). # Connectors that call external live-search APIs. These are handled by the
# These are never filtered by available_document_types. # ``web_search`` tool and must be excluded from knowledge-base searches.
_LIVE_SEARCH_CONNECTORS: set[str] = { _LIVE_SEARCH_CONNECTORS: set[str] = {
"TAVILY_API", "TAVILY_API",
"SEARXNG_API",
"LINKUP_API", "LINKUP_API",
"BAIDU_SEARCH_API", "BAIDU_SEARCH_API",
} }
@ -190,10 +189,6 @@ _ALL_CONNECTORS: list[str] = [
"GOOGLE_DRIVE_FILE", "GOOGLE_DRIVE_FILE",
"DISCORD_CONNECTOR", "DISCORD_CONNECTOR",
"AIRTABLE_CONNECTOR", "AIRTABLE_CONNECTOR",
"TAVILY_API",
"SEARXNG_API",
"LINKUP_API",
"BAIDU_SEARCH_API",
"LUMA_CONNECTOR", "LUMA_CONNECTOR",
"NOTE", "NOTE",
"BOOKSTACK_CONNECTOR", "BOOKSTACK_CONNECTOR",
@ -227,10 +222,6 @@ CONNECTOR_DESCRIPTIONS: dict[str, str] = {
"GOOGLE_DRIVE_FILE": "Google Drive files and documents (personal cloud storage)", "GOOGLE_DRIVE_FILE": "Google Drive files and documents (personal cloud storage)",
"DISCORD_CONNECTOR": "Discord server conversations and shared content (personal community)", "DISCORD_CONNECTOR": "Discord server conversations and shared content (personal community)",
"AIRTABLE_CONNECTOR": "Airtable records, tables, and database content (personal data)", "AIRTABLE_CONNECTOR": "Airtable records, tables, and database content (personal data)",
"TAVILY_API": "Tavily web search API results (real-time web search)",
"SEARXNG_API": "SearxNG search API results (privacy-focused web search)",
"LINKUP_API": "Linkup search API results (web search)",
"BAIDU_SEARCH_API": "Baidu search API results (Chinese web search)",
"LUMA_CONNECTOR": "Luma events and meetings", "LUMA_CONNECTOR": "Luma events and meetings",
"WEBCRAWLER_CONNECTOR": "Webpages indexed by SurfSense (personally selected websites)", "WEBCRAWLER_CONNECTOR": "Webpages indexed by SurfSense (personally selected websites)",
"CRAWLED_URL": "Webpages indexed by SurfSense (personally selected websites)", "CRAWLED_URL": "Webpages indexed by SurfSense (personally selected websites)",
@ -268,14 +259,15 @@ def _normalize_connectors(
valid_set = ( valid_set = (
set(available_connectors) if available_connectors else set(_ALL_CONNECTORS) set(available_connectors) if available_connectors else set(_ALL_CONNECTORS)
) )
valid_set -= _LIVE_SEARCH_CONNECTORS
if not connectors_to_search: if not connectors_to_search:
# Search all available connectors if none specified base = (
return (
list(available_connectors) list(available_connectors)
if available_connectors if available_connectors
else list(_ALL_CONNECTORS) else list(_ALL_CONNECTORS)
) )
return [c for c in base if c not in _LIVE_SEARCH_CONNECTORS]
normalized: list[str] = [] normalized: list[str] = []
for raw in connectors_to_search: for raw in connectors_to_search:
@ -302,15 +294,14 @@ def _normalize_connectors(
out.append(c) out.append(c)
# Fallback to all available if nothing matched # Fallback to all available if nothing matched
return ( if not out:
out base = (
if out
else (
list(available_connectors) list(available_connectors)
if available_connectors if available_connectors
else list(_ALL_CONNECTORS) else list(_ALL_CONNECTORS)
) )
) return [c for c in base if c not in _LIVE_SEARCH_CONNECTORS]
return out
# ============================================================================= # =============================================================================
@ -479,7 +470,6 @@ def format_documents_for_context(
# a numeric chunk_id (the numeric IDs are meaningless auto-incremented counters). # a numeric chunk_id (the numeric IDs are meaningless auto-incremented counters).
live_search_connectors = { live_search_connectors = {
"TAVILY_API", "TAVILY_API",
"SEARXNG_API",
"LINKUP_API", "LINKUP_API",
"BAIDU_SEARCH_API", "BAIDU_SEARCH_API",
} }
@ -623,13 +613,11 @@ async def search_knowledge_base_async(
connectors = _normalize_connectors(connectors_to_search, available_connectors) connectors = _normalize_connectors(connectors_to_search, available_connectors)
# --- Optimization 1: skip local connectors that have zero indexed documents --- # --- Optimization 1: skip connectors that have zero indexed documents ---
if available_document_types: if available_document_types:
doc_types_set = set(available_document_types) doc_types_set = set(available_document_types)
before_count = len(connectors) before_count = len(connectors)
connectors = [ connectors = [c for c in connectors if c in doc_types_set]
c for c in connectors if c in _LIVE_SEARCH_CONNECTORS or c in doc_types_set
]
skipped = before_count - len(connectors) skipped = before_count - len(connectors)
if skipped: if skipped:
perf.info( perf.info(
@ -664,9 +652,7 @@ async def search_knowledge_base_async(
"[kb_search] degenerate query %r detected - falling back to recency browse", "[kb_search] degenerate query %r detected - falling back to recency browse",
query, query,
) )
local_connectors = [c for c in connectors if c not in _LIVE_SEARCH_CONNECTORS] browse_connectors = connectors if connectors else [None] # type: ignore[list-item]
if not local_connectors:
local_connectors = [None] # type: ignore[list-item]
browse_results = await asyncio.gather( browse_results = await asyncio.gather(
*[ *[
@ -677,7 +663,7 @@ async def search_knowledge_base_async(
start_date=resolved_start_date, start_date=resolved_start_date,
end_date=resolved_end_date, end_date=resolved_end_date,
) )
for c in local_connectors for c in browse_connectors
] ]
) )
for docs in browse_results: for docs in browse_results:
@ -702,18 +688,7 @@ async def search_knowledge_base_async(
) )
return result return result
# Specs for live-search connectors (external APIs, no local DB/embedding).
live_connector_specs: dict[str, tuple[str, bool, bool, dict[str, Any]]] = {
"TAVILY_API": ("search_tavily", False, True, {}),
"SEARXNG_API": ("search_searxng", False, True, {}),
"LINKUP_API": ("search_linkup", False, False, {"mode": "standard"}),
"BAIDU_SEARCH_API": ("search_baidu", False, True, {}),
}
# --- Optimization 2: compute the query embedding once, share across all local searches --- # --- Optimization 2: compute the query embedding once, share across all local searches ---
precomputed_embedding: list[float] | None = None
has_local_connectors = any(c not in _LIVE_SEARCH_CONNECTORS for c in connectors)
if has_local_connectors:
from app.config import config as app_config from app.config import config as app_config
t_embed = time.perf_counter() t_embed = time.perf_counter()
@ -727,41 +702,6 @@ async def search_knowledge_base_async(
semaphore = asyncio.Semaphore(max_parallel_searches) semaphore = asyncio.Semaphore(max_parallel_searches)
async def _search_one_connector(connector: str) -> list[dict[str, Any]]: async def _search_one_connector(connector: str) -> list[dict[str, Any]]:
is_live = connector in _LIVE_SEARCH_CONNECTORS
if is_live:
spec = live_connector_specs.get(connector)
if spec is None:
return []
method_name, includes_date_range, includes_top_k, extra_kwargs = spec
kwargs: dict[str, Any] = {
"user_query": query,
"search_space_id": search_space_id,
**extra_kwargs,
}
if includes_top_k:
kwargs["top_k"] = top_k
if includes_date_range:
kwargs["start_date"] = resolved_start_date
kwargs["end_date"] = resolved_end_date
try:
t_conn = time.perf_counter()
async with semaphore, shielded_async_session() as isolated_session:
svc = ConnectorService(isolated_session, search_space_id)
_, chunks = await getattr(svc, method_name)(**kwargs)
perf.info(
"[kb_search] connector=%s results=%d in %.3fs",
connector,
len(chunks),
time.perf_counter() - t_conn,
)
return chunks
except Exception as e:
perf.warning("[kb_search] connector=%s FAILED: %s", connector, e)
return []
# --- Optimization 3: call _combined_rrf_search directly with shared embedding ---
try: try:
t_conn = time.perf_counter() t_conn = time.perf_counter()
async with semaphore, shielded_async_session() as isolated_session: async with semaphore, shielded_async_session() as isolated_session:
@ -967,7 +907,9 @@ Focus searches on these types for best results."""
# This is what the LLM sees when deciding whether/how to use the tool # This is what the LLM sees when deciding whether/how to use the tool
dynamic_description = f"""Search the user's personal knowledge base for relevant information. dynamic_description = f"""Search the user's personal knowledge base for relevant information.
Use this tool to find documents, notes, files, web pages, and other content that may help answer the user's question. Use this tool to find documents, notes, files, web pages, and other content the user has indexed.
This searches ONLY local/indexed data (uploaded files, Notion, Slack, browser extension captures, etc.).
For real-time web search (current events, news, live data), use the `web_search` tool instead.
IMPORTANT: IMPORTANT:
- Always craft specific, descriptive search queries using natural language keywords. - Always craft specific, descriptive search queries using natural language keywords.
@ -977,9 +919,6 @@ IMPORTANT:
- If the user requests a specific source type (e.g. "my notes", "Slack messages"), pass `connectors_to_search=[...]` using the enums below. - If the user requests a specific source type (e.g. "my notes", "Slack messages"), pass `connectors_to_search=[...]` using the enums below.
- If `connectors_to_search` is omitted/empty, the system will search broadly. - If `connectors_to_search` is omitted/empty, the system will search broadly.
- Only connectors that are enabled/configured for this search space are available.{doc_types_info} - Only connectors that are enabled/configured for this search space are available.{doc_types_info}
- For real-time/public web queries (e.g., current exchange rates, stock prices, breaking news, weather),
explicitly include live web connectors in `connectors_to_search`, prioritizing:
["LINKUP_API", "TAVILY_API", "SEARXNG_API", "BAIDU_SEARCH_API"].
## Available connector enums for `connectors_to_search` ## Available connector enums for `connectors_to_search`

View file

@ -73,6 +73,7 @@ from .shared_memory import (
create_save_shared_memory_tool, create_save_shared_memory_tool,
) )
from .user_memory import create_recall_memory_tool, create_save_memory_tool from .user_memory import create_recall_memory_tool, create_save_memory_tool
from .web_search import create_web_search_tool
# ============================================================================= # =============================================================================
# Tool Definition # Tool Definition
@ -186,7 +187,16 @@ BUILTIN_TOOLS: list[ToolDefinition] = [
), ),
requires=[], # firecrawl_api_key is optional requires=[], # firecrawl_api_key is optional
), ),
# Note: write_todos is now provided by TodoListMiddleware from deepagents # Web search tool — real-time web search via SearXNG + user-configured engines
ToolDefinition(
name="web_search",
description="Search the web for real-time information using configured search engines",
factory=lambda deps: create_web_search_tool(
search_space_id=deps.get("search_space_id"),
available_connectors=deps.get("available_connectors"),
),
requires=[],
),
# Surfsense documentation search tool # Surfsense documentation search tool
ToolDefinition( ToolDefinition(
name="search_surfsense_docs", name="search_surfsense_docs",

View file

@ -0,0 +1,247 @@
"""
Web search tool for the SurfSense agent.
Provides a unified tool for real-time web searches that dispatches to all
configured search engines: the platform SearXNG instance (always available)
plus any user-configured live-search connectors (Tavily, Linkup, Baidu).
"""
import asyncio
import json
import time
from typing import Any
from langchain_core.tools import StructuredTool
from pydantic import BaseModel, Field
from app.db import shielded_async_session
from app.services.connector_service import ConnectorService
from app.utils.perf import get_perf_logger
_LIVE_SEARCH_CONNECTORS: set[str] = {
"TAVILY_API",
"LINKUP_API",
"BAIDU_SEARCH_API",
}
_LIVE_CONNECTOR_SPECS: dict[str, tuple[str, bool, bool, dict[str, Any]]] = {
"TAVILY_API": ("search_tavily", False, True, {}),
"LINKUP_API": ("search_linkup", False, False, {"mode": "standard"}),
"BAIDU_SEARCH_API": ("search_baidu", False, True, {}),
}
_CONNECTOR_LABELS: dict[str, str] = {
"TAVILY_API": "Tavily",
"LINKUP_API": "Linkup",
"BAIDU_SEARCH_API": "Baidu",
}
class WebSearchInput(BaseModel):
"""Input schema for the web_search tool."""
query: str = Field(
description="The search query to look up on the web. Use specific, descriptive terms.",
)
top_k: int = Field(
default=10,
description="Number of results to retrieve (default: 10, max: 50).",
)
def _format_web_results(
documents: list[dict[str, Any]],
*,
max_chars: int = 50_000,
) -> str:
"""Format web search results into XML suitable for the LLM context."""
if not documents:
return "No web search results found."
parts: list[str] = []
total_chars = 0
for doc in documents:
doc_info = doc.get("document") or {}
metadata = doc_info.get("metadata") or {}
title = doc_info.get("title") or "Web Result"
url = metadata.get("url") or ""
content = (doc.get("content") or "").strip()
source = metadata.get("document_type") or doc.get("source") or "WEB_SEARCH"
if not content:
continue
metadata_json = json.dumps(metadata, ensure_ascii=False)
doc_xml = "\n".join(
[
"<document>",
"<document_metadata>",
f" <document_type>{source}</document_type>",
f" <title><![CDATA[{title}]]></title>",
f" <url><![CDATA[{url}]]></url>",
f" <metadata_json><![CDATA[{metadata_json}]]></metadata_json>",
"</document_metadata>",
"<document_content>",
f" <chunk id='{url}'><![CDATA[{content}]]></chunk>",
"</document_content>",
"</document>",
"",
]
)
if total_chars + len(doc_xml) > max_chars:
parts.append("<!-- Output truncated to fit context window -->")
break
parts.append(doc_xml)
total_chars += len(doc_xml)
return "\n".join(parts).strip() or "No web search results found."
async def _search_live_connector(
connector: str,
query: str,
search_space_id: int,
top_k: int,
semaphore: asyncio.Semaphore,
) -> list[dict[str, Any]]:
"""Dispatch a single live-search connector (Tavily / Linkup / Baidu)."""
perf = get_perf_logger()
spec = _LIVE_CONNECTOR_SPECS.get(connector)
if spec is None:
return []
method_name, _includes_date_range, includes_top_k, extra_kwargs = spec
kwargs: dict[str, Any] = {
"user_query": query,
"search_space_id": search_space_id,
**extra_kwargs,
}
if includes_top_k:
kwargs["top_k"] = top_k
try:
t0 = time.perf_counter()
async with semaphore, shielded_async_session() as session:
svc = ConnectorService(session, search_space_id)
_, chunks = await getattr(svc, method_name)(**kwargs)
perf.info(
"[web_search] connector=%s results=%d in %.3fs",
connector,
len(chunks),
time.perf_counter() - t0,
)
return chunks
except Exception as e:
perf.warning("[web_search] connector=%s FAILED: %s", connector, e)
return []
def create_web_search_tool(
search_space_id: int | None = None,
available_connectors: list[str] | None = None,
) -> StructuredTool:
"""Factory for the ``web_search`` tool.
Dispatches in parallel to the platform SearXNG instance and any
user-configured live-search connectors (Tavily, Linkup, Baidu).
"""
active_live_connectors: list[str] = []
if available_connectors:
active_live_connectors = [
c for c in available_connectors if c in _LIVE_SEARCH_CONNECTORS
]
engine_names = ["SearXNG (platform default)"]
engine_names.extend(_CONNECTOR_LABELS.get(c, c) for c in active_live_connectors)
engines_summary = ", ".join(engine_names)
description = (
"Search the web for real-time information. "
"Use this for current events, news, prices, weather, public facts, or any "
"question that requires up-to-date information from the internet.\n\n"
f"Active search engines: {engines_summary}.\n"
"All configured engines are queried in parallel and results are merged."
)
_search_space_id = search_space_id
_active_live = active_live_connectors
async def _web_search_impl(query: str, top_k: int = 10) -> str:
from app.services import web_search_service
perf = get_perf_logger()
t0 = time.perf_counter()
clamped_top_k = min(max(1, top_k), 50)
semaphore = asyncio.Semaphore(4)
tasks: list[asyncio.Task[list[dict[str, Any]]]] = []
if web_search_service.is_available():
async def _searxng() -> list[dict[str, Any]]:
async with semaphore:
_result_obj, docs = await web_search_service.search(
query=query,
top_k=clamped_top_k,
)
return docs
tasks.append(asyncio.ensure_future(_searxng()))
if _search_space_id is not None:
for connector in _active_live:
tasks.append(
asyncio.ensure_future(
_search_live_connector(
connector=connector,
query=query,
search_space_id=_search_space_id,
top_k=clamped_top_k,
semaphore=semaphore,
)
)
)
if not tasks:
return "Web search is not available — no search engines are configured."
results_lists = await asyncio.gather(*tasks, return_exceptions=True)
all_documents: list[dict[str, Any]] = []
for result in results_lists:
if isinstance(result, BaseException):
perf.warning("[web_search] a search engine failed: %s", result)
continue
all_documents.extend(result)
seen_urls: set[str] = set()
deduplicated: list[dict[str, Any]] = []
for doc in all_documents:
url = ((doc.get("document") or {}).get("metadata") or {}).get("url", "")
if url and url in seen_urls:
continue
if url:
seen_urls.add(url)
deduplicated.append(doc)
formatted = _format_web_results(deduplicated)
perf.info(
"[web_search] query=%r engines=%d results=%d deduped=%d chars=%d in %.3fs",
query[:60],
len(tasks),
len(all_documents),
len(deduplicated),
len(formatted),
time.perf_counter() - t0,
)
return formatted
return StructuredTool(
name="web_search",
description=description,
coroutine=_web_search_impl,
args_schema=WebSearchInput,
)

View file

@ -224,6 +224,9 @@ class Config:
os.getenv("CONNECTOR_INDEXING_LOCK_TTL_SECONDS", str(8 * 60 * 60)) os.getenv("CONNECTOR_INDEXING_LOCK_TTL_SECONDS", str(8 * 60 * 60))
) )
# Platform web search (SearXNG)
SEARXNG_DEFAULT_HOST = os.getenv("SEARXNG_DEFAULT_HOST")
NEXT_FRONTEND_URL = os.getenv("NEXT_FRONTEND_URL") NEXT_FRONTEND_URL = os.getenv("NEXT_FRONTEND_URL")
# Backend URL to override the http to https in the OAuth redirect URI # Backend URL to override the http to https in the OAuth redirect URI
BACKEND_URL = os.getenv("BACKEND_URL") BACKEND_URL = os.getenv("BACKEND_URL")

View file

@ -12,13 +12,11 @@ class SearchSpaceBase(BaseModel):
class SearchSpaceCreate(SearchSpaceBase): class SearchSpaceCreate(SearchSpaceBase):
# Optional on create, will use defaults if not provided
citations_enabled: bool = True citations_enabled: bool = True
qna_custom_instructions: str | None = None qna_custom_instructions: str | None = None
class SearchSpaceUpdate(BaseModel): class SearchSpaceUpdate(BaseModel):
# All fields optional on update - only send what you want to change
name: str | None = None name: str | None = None
description: str | None = None description: str | None = None
citations_enabled: bool | None = None citations_enabled: bool | None = None
@ -29,7 +27,6 @@ class SearchSpaceRead(SearchSpaceBase, IDModel, TimestampModel):
id: int id: int
created_at: datetime created_at: datetime
user_id: uuid.UUID user_id: uuid.UUID
# QnA configuration
citations_enabled: bool citations_enabled: bool
qna_custom_instructions: str | None = None qna_custom_instructions: str | None = None

View file

@ -2,7 +2,6 @@ import asyncio
import time import time
from datetime import datetime from datetime import datetime
from typing import Any from typing import Any
from urllib.parse import urljoin
import httpx import httpx
from linkup import LinkupClient from linkup import LinkupClient
@ -577,185 +576,27 @@ class ConnectorService:
search_space_id: int, search_space_id: int,
top_k: int = 20, top_k: int = 20,
) -> tuple: ) -> tuple:
"""Search using the platform SearXNG instance.
Delegates to ``WebSearchService`` which handles caching, circuit
breaking, and retries. SearXNG configuration comes from the
docker/searxng/settings.yml file.
""" """
Search using a configured SearxNG instance and return both sources and documents. from app.services import web_search_service
"""
searx_connector = await self.get_connector_by_type( if not web_search_service.is_available():
SearchSourceConnectorType.SEARXNG_API, search_space_id return {
"id": 11,
"name": "Web Search",
"type": "SEARXNG_API",
"sources": [],
}, []
return await web_search_service.search(
query=user_query,
top_k=top_k,
) )
if not searx_connector:
return {
"id": 11,
"name": "SearxNG Search",
"type": "SEARXNG_API",
"sources": [],
}, []
config = searx_connector.config or {}
host = config.get("SEARXNG_HOST")
if not host:
print("SearxNG connector is missing SEARXNG_HOST configuration")
return {
"id": 11,
"name": "SearxNG Search",
"type": "SEARXNG_API",
"sources": [],
}, []
api_key = config.get("SEARXNG_API_KEY")
engines = config.get("SEARXNG_ENGINES")
categories = config.get("SEARXNG_CATEGORIES")
language = config.get("SEARXNG_LANGUAGE")
safesearch = config.get("SEARXNG_SAFESEARCH")
def _parse_bool(value: Any, default: bool = True) -> bool:
if isinstance(value, bool):
return value
if isinstance(value, str):
lowered = value.strip().lower()
if lowered in {"true", "1", "yes", "on"}:
return True
if lowered in {"false", "0", "no", "off"}:
return False
return default
verify_ssl = _parse_bool(config.get("SEARXNG_VERIFY_SSL", True))
safesearch_value: int | None = None
if isinstance(safesearch, str):
safesearch_clean = safesearch.strip()
if safesearch_clean.isdigit():
safesearch_value = int(safesearch_clean)
elif isinstance(safesearch, int | float):
safesearch_value = int(safesearch)
if safesearch_value is not None and not (0 <= safesearch_value <= 2):
safesearch_value = None
def _format_list(value: Any) -> str | None:
if value is None:
return None
if isinstance(value, str):
value = value.strip()
return value or None
if isinstance(value, list | tuple | set):
cleaned = [str(item).strip() for item in value if str(item).strip()]
return ",".join(cleaned) if cleaned else None
return str(value)
params: dict[str, Any] = {
"q": user_query,
"format": "json",
"language": language or "",
"limit": max(1, min(top_k, 50)),
}
engines_param = _format_list(engines)
if engines_param:
params["engines"] = engines_param
categories_param = _format_list(categories)
if categories_param:
params["categories"] = categories_param
if safesearch_value is not None:
params["safesearch"] = safesearch_value
if not params.get("language"):
params.pop("language")
headers = {"Accept": "application/json"}
if api_key:
headers["X-API-KEY"] = api_key
searx_endpoint = urljoin(host if host.endswith("/") else f"{host}/", "search")
try:
async with httpx.AsyncClient(timeout=20.0, verify=verify_ssl) as client:
response = await client.get(
searx_endpoint,
params=params,
headers=headers,
)
response.raise_for_status()
except httpx.HTTPError as exc:
print(f"Error searching with SearxNG: {exc!s}")
return {
"id": 11,
"name": "SearxNG Search",
"type": "SEARXNG_API",
"sources": [],
}, []
try:
data = response.json()
except ValueError:
print("Failed to decode JSON response from SearxNG")
return {
"id": 11,
"name": "SearxNG Search",
"type": "SEARXNG_API",
"sources": [],
}, []
searx_results = data.get("results", [])
if not searx_results:
return {
"id": 11,
"name": "SearxNG Search",
"type": "SEARXNG_API",
"sources": [],
}, []
sources_list: list[dict[str, Any]] = []
documents: list[dict[str, Any]] = []
async with self.counter_lock:
for result in searx_results:
description = result.get("content") or result.get("snippet") or ""
if len(description) > 160:
description = f"{description}"
source = {
"id": self.source_id_counter,
"title": result.get("title", "SearxNG Result"),
"description": description,
"url": result.get("url", ""),
}
sources_list.append(source)
metadata = {
"url": result.get("url", ""),
"engines": result.get("engines", []),
"category": result.get("category"),
"source": "SEARXNG_API",
}
document = {
"chunk_id": self.source_id_counter,
"content": description or result.get("content", ""),
"score": result.get("score", 0.0),
"document": {
"id": self.source_id_counter,
"title": result.get("title", "SearxNG Result"),
"document_type": "SEARXNG_API",
"metadata": metadata,
},
}
documents.append(document)
self.source_id_counter += 1
result_object = {
"id": 11,
"name": "SearxNG Search",
"type": "SEARXNG_API",
"sources": sources_list,
}
return result_object, documents
async def search_baidu( async def search_baidu(
self, self,
user_query: str, user_query: str,

View file

@ -0,0 +1,290 @@
"""
Platform-level web search service backed by SearXNG.
Redis is used only for result caching (graceful degradation if unavailable).
The circuit breaker is fully in-process no external dependency, zero
latency overhead.
"""
from __future__ import annotations
import contextlib
import hashlib
import json
import logging
import threading
import time
from typing import Any
from urllib.parse import urljoin
import httpx
import redis
from app.config import config
logger = logging.getLogger(__name__)
_EMPTY_RESULT: dict[str, Any] = {
"id": 11,
"name": "Web Search",
"type": "SEARXNG_API",
"sources": [],
}
# ---------------------------------------------------------------------------
# Redis — used only for result caching
# ---------------------------------------------------------------------------
_redis_client: redis.Redis | None = None
def _get_redis() -> redis.Redis:
global _redis_client
if _redis_client is None:
_redis_client = redis.from_url(config.REDIS_APP_URL, decode_responses=True)
return _redis_client
# ---------------------------------------------------------------------------
# In-process Circuit Breaker (no Redis dependency)
# ---------------------------------------------------------------------------
_CB_FAILURE_THRESHOLD = 5
_CB_FAILURE_WINDOW_SECONDS = 60
_CB_COOLDOWN_SECONDS = 30
_cb_lock = threading.Lock()
_cb_failure_count: int = 0
_cb_last_failure_time: float = 0.0
_cb_open_until: float = 0.0
def _circuit_is_open() -> bool:
return time.monotonic() < _cb_open_until
def _record_failure() -> None:
global _cb_failure_count, _cb_last_failure_time, _cb_open_until
now = time.monotonic()
with _cb_lock:
if now - _cb_last_failure_time > _CB_FAILURE_WINDOW_SECONDS:
_cb_failure_count = 0
_cb_failure_count += 1
_cb_last_failure_time = now
if _cb_failure_count >= _CB_FAILURE_THRESHOLD:
_cb_open_until = now + _CB_COOLDOWN_SECONDS
logger.warning(
"Circuit breaker OPENED after %d failures — "
"SearXNG calls paused for %ds",
_cb_failure_count,
_CB_COOLDOWN_SECONDS,
)
def _record_success() -> None:
global _cb_failure_count, _cb_open_until
with _cb_lock:
_cb_failure_count = 0
_cb_open_until = 0.0
# ---------------------------------------------------------------------------
# Result Caching (Redis, graceful degradation)
# ---------------------------------------------------------------------------
_CACHE_TTL_SECONDS = 300 # 5 minutes
_CACHE_PREFIX = "websearch:cache:"
def _cache_key(query: str, engines: str | None, language: str | None) -> str:
raw = f"{query}|{engines or ''}|{language or ''}"
digest = hashlib.sha256(raw.encode()).hexdigest()[:24]
return f"{_CACHE_PREFIX}{digest}"
def _cache_get(key: str) -> dict | None:
try:
data = _get_redis().get(key)
if data:
return json.loads(data)
except (redis.RedisError, json.JSONDecodeError):
pass
return None
def _cache_set(key: str, value: dict) -> None:
with contextlib.suppress(redis.RedisError):
_get_redis().setex(key, _CACHE_TTL_SECONDS, json.dumps(value))
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
def is_available() -> bool:
"""Return ``True`` when the platform SearXNG host is configured."""
return bool(config.SEARXNG_DEFAULT_HOST)
async def health_check() -> dict[str, Any]:
"""Ping the SearXNG ``/healthz`` endpoint and return status info."""
host = config.SEARXNG_DEFAULT_HOST
if not host:
return {"status": "unavailable", "error": "SEARXNG_DEFAULT_HOST not set"}
healthz_url = urljoin(host if host.endswith("/") else f"{host}/", "healthz")
t0 = time.perf_counter()
try:
async with httpx.AsyncClient(timeout=5.0, verify=False) as client:
resp = await client.get(healthz_url)
resp.raise_for_status()
elapsed_ms = round((time.perf_counter() - t0) * 1000)
return {
"status": "healthy",
"response_time_ms": elapsed_ms,
"circuit_breaker": "open" if _circuit_is_open() else "closed",
}
except Exception as exc:
elapsed_ms = round((time.perf_counter() - t0) * 1000)
return {
"status": "unhealthy",
"error": str(exc),
"response_time_ms": elapsed_ms,
"circuit_breaker": "open" if _circuit_is_open() else "closed",
}
async def search(
query: str,
top_k: int = 20,
*,
engines: str | None = None,
language: str | None = None,
safesearch: int | None = None,
) -> tuple[dict[str, Any], list[dict[str, Any]]]:
"""Execute a web search against the platform SearXNG instance.
Returns the standard ``(result_object, documents)`` tuple expected by
``ConnectorService.search_searxng``.
"""
host = config.SEARXNG_DEFAULT_HOST
if not host:
return dict(_EMPTY_RESULT), []
if _circuit_is_open():
logger.info("Web search skipped — circuit breaker is open")
result = dict(_EMPTY_RESULT)
result["error"] = "Web search temporarily unavailable (circuit open)"
result["status"] = "degraded"
return result, []
ck = _cache_key(query, engines, language)
cached = _cache_get(ck)
if cached is not None:
logger.debug("Web search cache HIT for query=%r", query[:60])
return cached["result"], cached["documents"]
params: dict[str, Any] = {
"q": query,
"format": "json",
"limit": max(1, min(top_k, 50)),
}
if engines:
params["engines"] = engines
if language:
params["language"] = language
if safesearch is not None and 0 <= safesearch <= 2:
params["safesearch"] = safesearch
searx_endpoint = urljoin(host if host.endswith("/") else f"{host}/", "search")
headers = {"Accept": "application/json"}
data: dict[str, Any] | None = None
last_error: Exception | None = None
for attempt in range(2):
try:
async with httpx.AsyncClient(timeout=15.0, verify=False) as client:
response = await client.get(
searx_endpoint,
params=params,
headers=headers,
)
response.raise_for_status()
data = response.json()
break
except (httpx.HTTPStatusError, httpx.TimeoutException) as exc:
last_error = exc
if attempt == 0 and (
isinstance(exc, httpx.TimeoutException)
or (
isinstance(exc, httpx.HTTPStatusError)
and exc.response.status_code >= 500
)
):
continue
break
except httpx.HTTPError as exc:
last_error = exc
break
except ValueError as exc:
last_error = exc
break
if data is None:
_record_failure()
logger.warning("Web search failed after retries: %s", last_error)
return dict(_EMPTY_RESULT), []
_record_success()
searx_results = data.get("results", [])
if not searx_results:
return dict(_EMPTY_RESULT), []
sources_list: list[dict[str, Any]] = []
documents: list[dict[str, Any]] = []
for idx, result in enumerate(searx_results):
source_id = 200_000 + idx
description = result.get("content") or result.get("snippet") or ""
sources_list.append(
{
"id": source_id,
"title": result.get("title", "Web Search Result"),
"description": description,
"url": result.get("url", ""),
}
)
documents.append(
{
"chunk_id": source_id,
"content": description or result.get("content", ""),
"score": result.get("score", 0.0),
"document": {
"id": source_id,
"title": result.get("title", "Web Search Result"),
"document_type": "SEARXNG_API",
"metadata": {
"url": result.get("url", ""),
"engines": result.get("engines", []),
"category": result.get("category"),
"source": "SEARXNG_API",
},
},
}
)
result_object: dict[str, Any] = {
"id": 11,
"name": "Web Search",
"type": "SEARXNG_API",
"sources": sources_list,
}
_cache_set(ck, {"result": result_object, "documents": documents})
return result_object, documents

View file

@ -51,9 +51,7 @@ async def safe_set_chunks(
from app.db import Chunk from app.db import Chunk
if document.id is not None: if document.id is not None:
await session.execute( await session.execute(delete(Chunk).where(Chunk.document_id == document.id))
delete(Chunk).where(Chunk.document_id == document.id)
)
for chunk in chunks: for chunk in chunks:
chunk.document_id = document.id chunk.document_id = document.id

View file

@ -37,9 +37,7 @@ async def safe_set_chunks(
from app.db import Chunk from app.db import Chunk
if document.id is not None: if document.id is not None:
await session.execute( await session.execute(delete(Chunk).where(Chunk.document_id == document.id))
delete(Chunk).where(Chunk.document_id == document.id)
)
for chunk in chunks: for chunk in chunks:
chunk.document_id = document.id chunk.document_id = document.id

View file

@ -9,11 +9,9 @@ import re
from datetime import UTC, datetime from datetime import UTC, datetime
from pathlib import Path from pathlib import Path
from sqlalchemy import select from sqlalchemy import delete as sa_delete, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
from sqlalchemy import delete as sa_delete
from sqlalchemy.orm.attributes import set_committed_value from sqlalchemy.orm.attributes import set_committed_value
from app.config import config from app.config import config
@ -39,6 +37,7 @@ async def _safe_set_docs_chunks(
set_committed_value(document, "chunks", chunks) set_committed_value(document, "chunks", chunks)
session.add_all(chunks) session.add_all(chunks)
# Path to docs relative to project root # Path to docs relative to project root
DOCS_DIR = ( DOCS_DIR = (
Path(__file__).resolve().parent.parent.parent.parent Path(__file__).resolve().parent.parent.parent.parent

View file

@ -76,10 +76,10 @@ export function DocumentsFilters({
)} )}
</Button> </Button>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent className="w-64 !p-0 overflow-hidden" align="end"> <PopoverContent className="w-56 md:w-52 !p-0 overflow-hidden" align="end">
<div> <div>
{/* Search input */} {/* Search input */}
<div className="p-2 border-b border-border dark:border-neutral-700"> <div className="p-2">
<div className="relative"> <div className="relative">
<Search className="absolute left-0.5 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" /> <Search className="absolute left-0.5 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input <Input
@ -196,6 +196,7 @@ export function DocumentsFilters({
{/* Upload Button */} {/* Upload Button */}
<Button <Button
data-joyride="upload-button"
onClick={openUploadDialog} onClick={openUploadDialog}
variant="outline" variant="outline"
size="sm" size="sm"

View file

@ -9,14 +9,18 @@ import {
Eye, Eye,
FileText, FileText,
FileX, FileX,
MoreHorizontal,
Network, Network,
PenLine, PenLine,
SearchX, SearchX,
Trash2, Trash2,
User,
} from "lucide-react"; } from "lucide-react";
import { useRouter } from "next/navigation"; import { useAtomValue, useSetAtom } from "jotai";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import React, { useCallback, useEffect, useRef, useState } from "react"; import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { membersAtom } from "@/atoms/members/members-query.atoms";
import { openEditorPanelAtom } from "@/atoms/editor/editor-panel.atom";
import { toast } from "sonner"; import { toast } from "sonner";
import { useDocumentUploadDialog } from "@/components/assistant-ui/document-upload-popup"; import { useDocumentUploadDialog } from "@/components/assistant-ui/document-upload-popup";
import { JsonMetadataViewer } from "@/components/json-metadata-viewer"; import { JsonMetadataViewer } from "@/components/json-metadata-viewer";
@ -34,12 +38,11 @@ import {
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox"; import { Checkbox } from "@/components/ui/checkbox";
import { import {
ContextMenu, DropdownMenu,
ContextMenuContent, DropdownMenuContent,
ContextMenuItem, DropdownMenuItem,
ContextMenuSeparator, DropdownMenuTrigger,
ContextMenuTrigger, } from "@/components/ui/dropdown-menu";
} from "@/components/ui/context-menu";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { import {
Drawer, Drawer,
@ -48,6 +51,7 @@ import {
DrawerHeader, DrawerHeader,
DrawerTitle, DrawerTitle,
} from "@/components/ui/drawer"; } from "@/components/ui/drawer";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { Spinner } from "@/components/ui/spinner"; import { Spinner } from "@/components/ui/spinner";
import { import {
@ -61,12 +65,20 @@ import {
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { useLongPress } from "@/hooks/use-long-press"; import { useLongPress } from "@/hooks/use-long-press";
import { documentsApiService } from "@/lib/apis/documents-api.service"; import { documentsApiService } from "@/lib/apis/documents-api.service";
import { getDocumentTypeIcon, getDocumentTypeLabel } from "./DocumentTypeIcon"; import { getDocumentTypeIcon } from "./DocumentTypeIcon";
import type { Document, DocumentStatus } from "./types"; import type { Document, DocumentStatus } from "./types";
const EDITABLE_DOCUMENT_TYPES = ["FILE", "NOTE"] as const; const EDITABLE_DOCUMENT_TYPES = ["FILE", "NOTE"] as const;
const NON_DELETABLE_DOCUMENT_TYPES = ["SURFSENSE_DOCS"] as const; const NON_DELETABLE_DOCUMENT_TYPES = ["SURFSENSE_DOCS"] as const;
function getInitials(name: string): string {
const parts = name.trim().split(/\s+/);
if (parts.length >= 2) {
return (parts[0][0] + parts[1][0]).toUpperCase();
}
return name.slice(0, 2).toUpperCase();
}
function StatusIndicator({ status }: { status?: DocumentStatus }) { function StatusIndicator({ status }: { status?: DocumentStatus }) {
const state = status?.state ?? "ready"; const state = status?.state ?? "ready";
@ -145,45 +157,6 @@ function formatAbsoluteDate(dateStr: string): string {
}); });
} }
function DocumentNameTooltip({ doc, className }: { doc: Document; className?: string }) {
const textRef = useRef<HTMLSpanElement>(null);
const [isTruncated, setIsTruncated] = useState(false);
useEffect(() => {
const checkTruncation = () => {
if (textRef.current) {
setIsTruncated(textRef.current.scrollWidth > textRef.current.clientWidth);
}
};
checkTruncation();
window.addEventListener("resize", checkTruncation);
return () => window.removeEventListener("resize", checkTruncation);
}, []);
return (
<Tooltip>
<TooltipTrigger asChild>
<span ref={textRef} className={className}>
{doc.title}
</span>
</TooltipTrigger>
<TooltipContent side="top" align="start" className="max-w-sm">
<div className="space-y-1 text-xs">
{isTruncated && <p className="font-medium text-sm break-words">{doc.title}</p>}
<p>
<span className="text-muted-foreground">Owner:</span>{" "}
{doc.created_by_name || doc.created_by_email || "—"}
</p>
<p>
<span className="text-muted-foreground">Created:</span>{" "}
{formatAbsoluteDate(doc.created_at)}
</p>
</div>
</TooltipContent>
</Tooltip>
);
}
function SortableHeader({ function SortableHeader({
children, children,
sortKey, sortKey,
@ -217,73 +190,6 @@ function SortableHeader({
); );
} }
function RowContextMenu({
doc,
children,
onPreview,
onDelete,
searchSpaceId,
onEditNavigate,
}: {
doc: Document;
children: React.ReactNode;
onPreview: (doc: Document) => void;
onDelete: (doc: Document) => void;
searchSpaceId: string;
onEditNavigate?: () => void;
}) {
const router = useRouter();
const isEditable = EDITABLE_DOCUMENT_TYPES.includes(
doc.document_type as (typeof EDITABLE_DOCUMENT_TYPES)[number]
);
const isBeingProcessed = doc.status?.state === "pending" || doc.status?.state === "processing";
const isFileFailed = doc.document_type === "FILE" && doc.status?.state === "failed";
const shouldShowDelete = !NON_DELETABLE_DOCUMENT_TYPES.includes(
doc.document_type as (typeof NON_DELETABLE_DOCUMENT_TYPES)[number]
);
const isEditDisabled = isBeingProcessed || isFileFailed;
const isDeleteDisabled = isBeingProcessed;
return (
<ContextMenu>
<ContextMenuTrigger asChild>{children}</ContextMenuTrigger>
<ContextMenuContent className="w-48">
<ContextMenuItem onClick={() => onPreview(doc)}>
<Eye className="h-4 w-4" />
Preview
</ContextMenuItem>
{isEditable && (
<ContextMenuItem
onClick={() => {
if (!isEditDisabled) {
onEditNavigate?.();
router.push(`/dashboard/${searchSpaceId}/editor/${doc.id}`);
}
}}
disabled={isEditDisabled}
>
<PenLine className="h-4 w-4" />
Edit
</ContextMenuItem>
)}
{shouldShowDelete && (
<>
<ContextMenuSeparator />
<ContextMenuItem
onClick={() => !isDeleteDisabled && onDelete(doc)}
disabled={isDeleteDisabled}
>
<Trash2 className="h-4 w-4" />
Delete
</ContextMenuItem>
</>
)}
</ContextMenuContent>
</ContextMenu>
);
}
function MobileCardWrapper({ function MobileCardWrapper({
onLongPress, onLongPress,
children, children,
@ -327,7 +233,6 @@ export function DocumentsTableShell({
onLoadMore, onLoadMore,
mentionedDocIds, mentionedDocIds,
onToggleChatMention, onToggleChatMention,
onEditNavigate,
isSearchMode = false, isSearchMode = false,
}: { }: {
documents: Document[]; documents: Document[];
@ -346,8 +251,6 @@ export function DocumentsTableShell({
mentionedDocIds?: Set<number>; mentionedDocIds?: Set<number>;
/** Toggle a document's mention in the chat (add if not mentioned, remove if mentioned) */ /** Toggle a document's mention in the chat (add if not mentioned, remove if mentioned) */
onToggleChatMention?: (doc: Document, mentioned: boolean) => void; onToggleChatMention?: (doc: Document, mentioned: boolean) => void;
/** Called when user navigates to the editor via Edit — use to close containing sidebar/panel */
onEditNavigate?: () => void;
/** Whether results are filtered by a search query or type filters */ /** Whether results are filtered by a search query or type filters */
isSearchMode?: boolean; isSearchMode?: boolean;
}) { }) {
@ -374,7 +277,23 @@ export function DocumentsTableShell({
const [mobileActionDoc, setMobileActionDoc] = useState<Document | null>(null); const [mobileActionDoc, setMobileActionDoc] = useState<Document | null>(null);
const [bulkDeleteConfirmOpen, setBulkDeleteConfirmOpen] = useState(false); const [bulkDeleteConfirmOpen, setBulkDeleteConfirmOpen] = useState(false);
const [isBulkDeleting, setIsBulkDeleting] = useState(false); const [isBulkDeleting, setIsBulkDeleting] = useState(false);
const router = useRouter(); const openEditor = useSetAtom(openEditorPanelAtom);
const [openMenuDocId, setOpenMenuDocId] = useState<number | null>(null);
const { data: members } = useAtomValue(membersAtom);
const memberMap = useMemo(() => {
const map = new Map<string, { name: string; email?: string; avatarUrl?: string }>();
if (members) {
for (const m of members) {
map.set(m.user_id, {
name: m.user_display_name || m.user_email || "Unknown",
email: m.user_email || undefined,
avatarUrl: m.user_avatar_url || undefined,
});
}
}
return map;
}, [members]);
const desktopSentinelRef = useRef<HTMLDivElement>(null); const desktopSentinelRef = useRef<HTMLDivElement>(null);
const mobileSentinelRef = useRef<HTMLDivElement>(null); const mobileSentinelRef = useRef<HTMLDivElement>(null);
@ -554,7 +473,20 @@ export function DocumentsTableShell({
}, [deletableSelectedIds, bulkDeleteDocuments, deleteDocument]); }, [deletableSelectedIds, bulkDeleteDocuments, deleteDocument]);
return ( return (
<div className="bg-sidebar overflow-hidden select-none border-t border-border/50 flex-1 flex flex-col min-h-0"> <div className="bg-sidebar overflow-hidden select-none border-t border-border/50 flex-1 flex flex-col min-h-0 relative">
{/* Floating bulk delete pill */}
{hasDeletableSelection && (
<div className="absolute left-1/2 -translate-x-1/2 top-2 md:top-9 z-20 animate-in fade-in slide-in-from-top-1 duration-150">
<button
type="button"
onClick={() => setBulkDeleteConfirmOpen(true)}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-md bg-destructive text-destructive-foreground shadow-md text-xs font-medium"
>
<Trash2 size={12} />
Delete ({deletableSelectedIds.length} selected)
</button>
</div>
)}
{/* Desktop Table View */} {/* Desktop Table View */}
<div className="hidden md:flex md:flex-col flex-1 min-h-0"> <div className="hidden md:flex md:flex-col flex-1 min-h-0">
<Table className="table-fixed w-full"> <Table className="table-fixed w-full">
@ -586,23 +518,10 @@ export function DocumentsTableShell({
<Network size={14} className="text-muted-foreground" /> <Network size={14} className="text-muted-foreground" />
</span> </span>
</TableHead> </TableHead>
<TableHead className="w-12 text-center h-8 pl-0 pr-3"> <TableHead className="w-10 text-center h-8 px-0 pr-2">
{hasDeletableSelection ? ( <span className="flex items-center justify-center">
<Tooltip> <User size={14} className="text-muted-foreground" />
<TooltipTrigger asChild> </span>
<button
type="button"
onClick={() => setBulkDeleteConfirmOpen(true)}
className="inline-flex items-center justify-center h-6 w-6 rounded-md text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors"
>
<Trash2 size={14} />
</button>
</TooltipTrigger>
<TooltipContent>Delete {deletableSelectedIds.length} selected</TooltipContent>
</Tooltip>
) : (
<span className="text-xs font-medium text-muted-foreground">Status</span>
)}
</TableHead> </TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
@ -627,7 +546,7 @@ export function DocumentsTableShell({
<TableCell className="w-10 px-0 py-1.5 text-center"> <TableCell className="w-10 px-0 py-1.5 text-center">
<Skeleton className="h-4 w-4 mx-auto rounded" /> <Skeleton className="h-4 w-4 mx-auto rounded" />
</TableCell> </TableCell>
<TableCell className="w-12 pl-0 pr-3 py-1.5 text-center"> <TableCell className="w-10 px-0 pr-2 py-1.5 text-center">
<Skeleton className="h-5 w-5 mx-auto rounded-full" /> <Skeleton className="h-5 w-5 mx-auto rounded-full" />
</TableCell> </TableCell>
</TableRow> </TableRow>
@ -680,6 +599,17 @@ export function DocumentsTableShell({
{sorted.map((doc) => { {sorted.map((doc) => {
const isMentioned = mentionedDocIds?.has(doc.id) ?? false; const isMentioned = mentionedDocIds?.has(doc.id) ?? false;
const canInteract = isSelectable(doc); const canInteract = isSelectable(doc);
const isBeingProcessed =
doc.status?.state === "pending" || doc.status?.state === "processing";
const isFileFailed =
doc.document_type === "FILE" && doc.status?.state === "failed";
const isEditable = EDITABLE_DOCUMENT_TYPES.includes(
doc.document_type as (typeof EDITABLE_DOCUMENT_TYPES)[number]
);
const shouldShowDelete = !NON_DELETABLE_DOCUMENT_TYPES.includes(
doc.document_type as (typeof NON_DELETABLE_DOCUMENT_TYPES)[number]
);
const isMenuOpen = openMenuDocId === doc.id;
const handleRowToggle = () => { const handleRowToggle = () => {
if (canInteract && onToggleChatMention) { if (canInteract && onToggleChatMention) {
onToggleChatMention(doc, isMentioned); onToggleChatMention(doc, isMentioned);
@ -695,16 +625,9 @@ export function DocumentsTableShell({
handleRowToggle(); handleRowToggle();
}; };
return ( return (
<RowContextMenu
key={doc.id}
doc={doc}
onPreview={handleViewDocument}
onDelete={setDeleteDoc}
searchSpaceId={searchSpaceId}
onEditNavigate={onEditNavigate}
>
<tr <tr
className={`border-b border-border/50 transition-colors ${ key={doc.id}
className={`group border-b border-border/50 transition-colors ${
isMentioned ? "bg-primary/5 hover:bg-primary/8" : "hover:bg-muted/30" isMentioned ? "bg-primary/5 hover:bg-primary/8" : "hover:bg-muted/30"
} ${canInteract && hasChatMode ? "cursor-pointer" : ""}`} } ${canInteract && hasChatMode ? "cursor-pointer" : ""}`}
onClick={handleRowClick} onClick={handleRowClick}
@ -714,38 +637,137 @@ export function DocumentsTableShell({
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
<div className="flex items-center justify-center h-full"> <div className="flex items-center justify-center h-full">
{(() => {
const state = doc.status?.state ?? "ready";
if (state === "pending" || state === "processing") {
return <StatusIndicator status={doc.status} />;
}
if (state === "failed") {
return (
<>
<span className="group-hover:hidden">
<StatusIndicator status={doc.status} />
</span>
<span className="hidden group-hover:inline-flex">
<Checkbox <Checkbox
checked={isMentioned} checked={isMentioned}
onCheckedChange={() => handleRowToggle()} onCheckedChange={() => handleRowToggle()}
disabled={!canInteract}
aria-label={isMentioned ? "Remove from chat" : "Add to chat"} aria-label={isMentioned ? "Remove from chat" : "Add to chat"}
className={`border-foreground data-[state=checked]:bg-primary data-[state=checked]:border-primary ${!canInteract ? "opacity-40 cursor-not-allowed" : ""}`} className="border-foreground data-[state=checked]:bg-primary data-[state=checked]:border-primary"
/> />
</span>
</>
);
}
return (
<Checkbox
checked={isMentioned}
onCheckedChange={() => handleRowToggle()}
aria-label={isMentioned ? "Remove from chat" : "Add to chat"}
className="border-foreground data-[state=checked]:bg-primary data-[state=checked]:border-primary"
/>
);
})()}
</div> </div>
</TableCell> </TableCell>
<TableCell className="px-2 py-1.5 max-w-0"> <TableCell className="px-2 py-1.5 max-w-0">
<DocumentNameTooltip <span className="truncate block text-sm text-foreground cursor-default">
doc={doc} {doc.title}
className="truncate block text-sm text-foreground cursor-default" </span>
/>
</TableCell> </TableCell>
<TableCell className="w-10 px-0 py-1.5 text-center"> <TableCell className="w-10 px-0 py-1.5 text-center">
<Tooltip>
<TooltipTrigger asChild>
<span className="flex items-center justify-center"> <span className="flex items-center justify-center">
{getDocumentTypeIcon(doc.document_type, "h-4 w-4")} {getDocumentTypeIcon(doc.document_type, "h-4 w-4")}
</span> </span>
</TooltipTrigger>
<TooltipContent side="top">
{getDocumentTypeLabel(doc.document_type)}
</TooltipContent>
</Tooltip>
</TableCell> </TableCell>
<TableCell className="w-12 pl-0 pr-3 py-1.5 text-center"> <TableCell
<StatusIndicator status={doc.status} /> className="w-10 px-0 pr-2 py-1.5 text-center"
onClick={(e) => e.stopPropagation()}
>
<div className="relative flex items-center justify-center">
{(() => {
const member = doc.created_by_id
? memberMap.get(doc.created_by_id)
: null;
const displayName =
member?.name ||
doc.created_by_name ||
doc.created_by_email ||
"Unknown";
const avatarUrl = member?.avatarUrl;
const email = member?.email || doc.created_by_email || displayName;
return (
<Tooltip>
<TooltipTrigger asChild>
<span
className={`flex items-center justify-center ${isMenuOpen ? "invisible" : "group-hover:invisible"}`}
>
<Avatar className="size-5 shrink-0">
{avatarUrl && (
<AvatarImage src={avatarUrl} alt={displayName} />
)}
<AvatarFallback className="text-[9px]">
{getInitials(displayName)}
</AvatarFallback>
</Avatar>
</span>
</TooltipTrigger>
<TooltipContent side="top">{email}</TooltipContent>
</Tooltip>
);
})()}
<div
className={`absolute inset-0 flex items-center justify-center ${isMenuOpen ? "visible" : "invisible group-hover:visible"}`}
>
<DropdownMenu
onOpenChange={(open) => setOpenMenuDocId(open ? doc.id : null)}
>
<DropdownMenuTrigger asChild>
<button
type="button"
className="flex items-center justify-center h-6 w-6 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted/50 transition-colors"
>
<MoreHorizontal size={14} />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuItem onClick={() => handleViewDocument(doc)}>
<Eye className="h-4 w-4" />
Preview
</DropdownMenuItem>
{isEditable && (
<DropdownMenuItem
onClick={() => {
if (!(isBeingProcessed || isFileFailed)) {
openEditor({
documentId: doc.id,
searchSpaceId: Number(searchSpaceId),
title: doc.title,
});
}
}}
disabled={isBeingProcessed || isFileFailed}
>
<PenLine className="h-4 w-4" />
Edit
</DropdownMenuItem>
)}
{shouldShowDelete && (
<DropdownMenuItem
onClick={() => !isBeingProcessed && setDeleteDoc(doc)}
disabled={isBeingProcessed}
className=""
>
<Trash2 className="h-4 w-4" />
Delete
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</TableCell> </TableCell>
</tr> </tr>
</RowContextMenu>
); );
})} })}
</TableBody> </TableBody>
@ -765,12 +787,10 @@ export function DocumentsTableShell({
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<Skeleton className="h-4" style={{ width: `${widthPercent}%` }} /> <Skeleton className="h-4" style={{ width: `${widthPercent}%` }} />
</div> </div>
<div className="flex items-center gap-2">
<Skeleton className="h-4 w-4 rounded shrink-0" /> <Skeleton className="h-4 w-4 rounded shrink-0" />
<Skeleton className="h-5 w-5 rounded-full shrink-0" /> <Skeleton className="h-5 w-5 rounded-full shrink-0" />
</div> </div>
</div> </div>
</div>
))} ))}
</div> </div>
) : error ? ( ) : error ? (
@ -814,25 +834,11 @@ export function DocumentsTableShell({
ref={mobileScrollRef} ref={mobileScrollRef}
className="md:hidden divide-y divide-border/50 flex-1 overflow-auto" className="md:hidden divide-y divide-border/50 flex-1 overflow-auto"
> >
{hasDeletableSelection && (
<div className="flex items-center justify-between px-3 py-2 bg-muted/50 border-b border-border/50 sticky top-0 z-10">
<span className="text-xs text-muted-foreground">
{deletableSelectedIds.length} deletable selected
</span>
<Button
variant="destructive"
size="sm"
className="h-7 px-2.5 text-xs"
onClick={() => setBulkDeleteConfirmOpen(true)}
>
<Trash2 size={12} className="mr-1" />
Delete
</Button>
</div>
)}
{sorted.map((doc) => { {sorted.map((doc) => {
const isMentioned = mentionedDocIds?.has(doc.id) ?? false; const isMentioned = mentionedDocIds?.has(doc.id) ?? false;
const canInteract = isSelectable(doc); const statusState = doc.status?.state ?? "ready";
const showCheckbox = statusState === "ready";
const canInteract = showCheckbox;
const handleCardClick = (e?: React.MouseEvent) => { const handleCardClick = (e?: React.MouseEvent) => {
if (e && (e.ctrlKey || e.metaKey)) { if (e && (e.ctrlKey || e.metaKey)) {
e.preventDefault(); e.preventDefault();
@ -862,24 +868,40 @@ export function DocumentsTableShell({
/> />
)} )}
<div className="relative z-10 flex items-center gap-3 pointer-events-none"> <div className="relative z-10 flex items-center gap-3 pointer-events-none">
<span className="pointer-events-auto"> <span className="pointer-events-auto shrink-0">
{showCheckbox ? (
<Checkbox <Checkbox
checked={isMentioned} checked={isMentioned}
onCheckedChange={() => handleCardClick()} onCheckedChange={() => handleCardClick()}
disabled={!canInteract}
aria-label={isMentioned ? "Remove from chat" : "Add to chat"} aria-label={isMentioned ? "Remove from chat" : "Add to chat"}
className={`border-foreground data-[state=checked]:bg-primary data-[state=checked]:border-primary shrink-0 ${!canInteract ? "opacity-40 cursor-not-allowed" : ""}`} className="border-foreground data-[state=checked]:bg-primary data-[state=checked]:border-primary"
/> />
) : (
<StatusIndicator status={doc.status} />
)}
</span> </span>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<span className="truncate block text-sm text-foreground">{doc.title}</span> <span className="truncate block text-sm text-foreground">{doc.title}</span>
</div> </div>
<div className="flex items-center gap-2 shrink-0"> <span className="flex items-center justify-center shrink-0">
<span className="flex items-center justify-center">
{getDocumentTypeIcon(doc.document_type, "h-4 w-4")} {getDocumentTypeIcon(doc.document_type, "h-4 w-4")}
</span> </span>
<StatusIndicator status={doc.status} /> {(() => {
</div> const member = doc.created_by_id ? memberMap.get(doc.created_by_id) : null;
const displayName =
member?.name || doc.created_by_name || doc.created_by_email || "Unknown";
const avatarUrl = member?.avatarUrl;
return (
<span className="flex items-center justify-center shrink-0">
<Avatar className="size-5">
{avatarUrl && <AvatarImage src={avatarUrl} alt={displayName} />}
<AvatarFallback className="text-[9px]">
{getInitials(displayName)}
</AvatarFallback>
</Avatar>
</span>
);
})()}
</div> </div>
</div> </div>
</MobileCardWrapper> </MobileCardWrapper>
@ -985,7 +1007,7 @@ export function DocumentsTableShell({
</DrawerHeader> </DrawerHeader>
<div className="px-4 pb-6 flex flex-col gap-2"> <div className="px-4 pb-6 flex flex-col gap-2">
<Button <Button
variant="outline" variant="secondary"
className="justify-start gap-2" className="justify-start gap-2"
onClick={() => { onClick={() => {
if (mobileActionDoc) handleViewDocument(mobileActionDoc); if (mobileActionDoc) handleViewDocument(mobileActionDoc);
@ -1000,7 +1022,7 @@ export function DocumentsTableShell({
mobileActionDoc.document_type as (typeof EDITABLE_DOCUMENT_TYPES)[number] mobileActionDoc.document_type as (typeof EDITABLE_DOCUMENT_TYPES)[number]
) && ( ) && (
<Button <Button
variant="outline" variant="secondary"
className="justify-start gap-2" className="justify-start gap-2"
disabled={ disabled={
mobileActionDoc.status?.state === "pending" || mobileActionDoc.status?.state === "pending" ||
@ -1010,8 +1032,11 @@ export function DocumentsTableShell({
} }
onClick={() => { onClick={() => {
if (mobileActionDoc) { if (mobileActionDoc) {
onEditNavigate?.(); openEditor({
router.push(`/dashboard/${searchSpaceId}/editor/${mobileActionDoc.id}`); documentId: mobileActionDoc.id,
searchSpaceId: Number(searchSpaceId),
title: mobileActionDoc.title,
});
setMobileActionDoc(null); setMobileActionDoc(null);
} }
}} }}

View file

@ -1,9 +1,10 @@
"use client"; "use client";
import { useSetAtom } from "jotai";
import { MoreHorizontal, PenLine, Trash2 } from "lucide-react"; import { MoreHorizontal, PenLine, Trash2 } from "lucide-react";
import { useRouter } from "next/navigation";
import { useState } from "react"; import { useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { openEditorPanelAtom } from "@/atoms/editor/editor-panel.atom";
import { import {
AlertDialog, AlertDialog,
AlertDialogAction, AlertDialogAction,
@ -40,7 +41,7 @@ export function RowActions({
}) { }) {
const [isDeleteOpen, setIsDeleteOpen] = useState(false); const [isDeleteOpen, setIsDeleteOpen] = useState(false);
const [isDeleting, setIsDeleting] = useState(false); const [isDeleting, setIsDeleting] = useState(false);
const router = useRouter(); const openEditorPanel = useSetAtom(openEditorPanelAtom);
const isEditable = EDITABLE_DOCUMENT_TYPES.includes( const isEditable = EDITABLE_DOCUMENT_TYPES.includes(
document.document_type as (typeof EDITABLE_DOCUMENT_TYPES)[number] document.document_type as (typeof EDITABLE_DOCUMENT_TYPES)[number]
@ -87,7 +88,11 @@ export function RowActions({
}; };
const handleEdit = () => { const handleEdit = () => {
router.push(`/dashboard/${searchSpaceId}/editor/${document.id}`); openEditorPanel({
documentId: document.id,
searchSpaceId: Number(searchSpaceId),
title: document.title,
});
}; };
return ( return (

View file

@ -1,505 +0,0 @@
"use client";
import { useAtom } from "jotai";
import { AlertCircle, ArrowLeft, FileText } from "lucide-react";
import { motion } from "motion/react";
import dynamic from "next/dynamic";
import { useParams, useRouter } from "next/navigation";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
import { hasUnsavedEditorChangesAtom, pendingEditorNavigationAtom } from "@/atoms/editor/ui.atoms";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Button, buttonVariants } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { notesApiService } from "@/lib/apis/notes-api.service";
import { authenticatedFetch, getBearerToken, redirectToLogin } from "@/lib/auth-utils";
// Dynamically import PlateEditor (uses 'use client' internally)
const PlateEditor = dynamic(
() => import("@/components/editor/plate-editor").then((mod) => ({ default: mod.PlateEditor })),
{
ssr: false,
loading: () => (
<div className="mx-auto w-full max-w-[900px] px-6 md:px-12 pt-10 space-y-4">
<Skeleton className="h-8 w-3/5 rounded" />
<div className="space-y-3 pt-4">
<Skeleton className="h-4 w-full rounded" />
<Skeleton className="h-4 w-full rounded" />
<Skeleton className="h-4 w-4/5 rounded" />
</div>
<div className="space-y-3 pt-3">
<Skeleton className="h-4 w-full rounded" />
<Skeleton className="h-4 w-5/6 rounded" />
<Skeleton className="h-4 w-3/4 rounded" />
</div>
<div className="space-y-3 pt-3">
<Skeleton className="h-4 w-full rounded" />
<Skeleton className="h-4 w-2/3 rounded" />
</div>
</div>
),
}
);
interface EditorContent {
document_id: number;
title: string;
document_type?: string;
source_markdown: string;
updated_at: string | null;
}
/** Extract title from markdown: first # heading, or first non-empty line. */
function extractTitleFromMarkdown(markdown: string | null | undefined): string {
if (!markdown) return "Untitled";
for (const line of markdown.split("\n")) {
const trimmed = line.trim();
if (trimmed.startsWith("# ")) return trimmed.slice(2).trim() || "Untitled";
if (trimmed) return trimmed.slice(0, 100);
}
return "Untitled";
}
export default function EditorPage() {
const params = useParams();
const router = useRouter();
const documentId = params.documentId as string;
const searchSpaceId = Number(params.search_space_id);
const isNewNote = documentId === "new";
const [document, setDocument] = useState<EditorContent | null>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
const [showUnsavedDialog, setShowUnsavedDialog] = useState(false);
const [editorTitle, setEditorTitle] = useState<string>("Untitled");
// Store the latest markdown from the editor
const markdownRef = useRef<string>("");
const initialLoadDone = useRef(false);
// Global state for cross-component communication
const [, setGlobalHasUnsavedChanges] = useAtom(hasUnsavedEditorChangesAtom);
const [pendingNavigation, setPendingNavigation] = useAtom(pendingEditorNavigationAtom);
// Sync local unsaved changes state with global atom
useEffect(() => {
setGlobalHasUnsavedChanges(hasUnsavedChanges);
}, [hasUnsavedChanges, setGlobalHasUnsavedChanges]);
// Cleanup global state when component unmounts
useEffect(() => {
return () => {
setGlobalHasUnsavedChanges(false);
setPendingNavigation(null);
};
}, [setGlobalHasUnsavedChanges, setPendingNavigation]);
// Handle pending navigation from sidebar
useEffect(() => {
if (pendingNavigation) {
if (hasUnsavedChanges) {
setShowUnsavedDialog(true);
} else {
router.push(pendingNavigation);
setPendingNavigation(null);
}
}
}, [pendingNavigation, hasUnsavedChanges, router, setPendingNavigation]);
// Reset state and fetch document content when documentId changes
useEffect(() => {
setDocument(null);
setError(null);
setHasUnsavedChanges(false);
setLoading(true);
initialLoadDone.current = false;
async function fetchDocument() {
if (isNewNote) {
markdownRef.current = "";
setEditorTitle("Untitled");
setDocument({
document_id: 0,
title: "Untitled",
document_type: "NOTE",
source_markdown: "",
updated_at: null,
});
setLoading(false);
initialLoadDone.current = true;
return;
}
const token = getBearerToken();
if (!token) {
redirectToLogin();
return;
}
try {
const response = await authenticatedFetch(
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/search-spaces/${params.search_space_id}/documents/${documentId}/editor-content`,
{ method: "GET" }
);
if (!response.ok) {
const errorData = await response
.json()
.catch(() => ({ detail: "Failed to fetch document" }));
throw new Error(errorData.detail || "Failed to fetch document");
}
const data = await response.json();
if (data.source_markdown === undefined || data.source_markdown === null) {
setError(
"This document does not have editable content. Please re-upload to enable editing."
);
setLoading(false);
return;
}
markdownRef.current = data.source_markdown;
setEditorTitle(extractTitleFromMarkdown(data.source_markdown));
setDocument(data);
setError(null);
initialLoadDone.current = true;
} catch (error) {
console.error("Error fetching document:", error);
setError(
error instanceof Error ? error.message : "Failed to fetch document. Please try again."
);
} finally {
setLoading(false);
}
}
if (documentId) {
fetchDocument();
}
}, [documentId, params.search_space_id, isNewNote]);
const isNote = isNewNote || document?.document_type === "NOTE";
const displayTitle = useMemo(() => {
if (isNote) return editorTitle;
return document?.title || "Untitled";
}, [isNote, document?.title, editorTitle]);
// Handle markdown changes from the Plate editor
const handleMarkdownChange = useCallback((md: string) => {
markdownRef.current = md;
if (initialLoadDone.current) {
setHasUnsavedChanges(true);
setEditorTitle(extractTitleFromMarkdown(md));
}
}, []);
// Save handler
const handleSave = useCallback(async () => {
const token = getBearerToken();
if (!token) {
toast.error("Please login to save");
redirectToLogin();
return;
}
setSaving(true);
setError(null);
try {
const currentMarkdown = markdownRef.current;
if (isNewNote) {
const title = extractTitleFromMarkdown(currentMarkdown);
// Create the note
const note = await notesApiService.createNote({
search_space_id: searchSpaceId,
title,
source_markdown: currentMarkdown || undefined,
});
// If there's content, save & trigger reindexing
if (currentMarkdown) {
const response = await authenticatedFetch(
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/search-spaces/${searchSpaceId}/documents/${note.id}/save`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ source_markdown: currentMarkdown }),
}
);
if (!response.ok) {
const errorData = await response
.json()
.catch(() => ({ detail: "Failed to save document" }));
throw new Error(errorData.detail || "Failed to save document");
}
}
setHasUnsavedChanges(false);
toast.success("Note created successfully! Reindexing in background...");
router.push(`/dashboard/${searchSpaceId}/new-chat`);
} else {
// Existing document — save
const response = await authenticatedFetch(
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/search-spaces/${params.search_space_id}/documents/${documentId}/save`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ source_markdown: currentMarkdown }),
}
);
if (!response.ok) {
const errorData = await response
.json()
.catch(() => ({ detail: "Failed to save document" }));
throw new Error(errorData.detail || "Failed to save document");
}
setHasUnsavedChanges(false);
toast.success("Document saved! Reindexing in background...");
router.push(`/dashboard/${searchSpaceId}/new-chat`);
}
} catch (error) {
console.error("Error saving document:", error);
const errorMessage =
error instanceof Error
? error.message
: isNewNote
? "Failed to create note. Please try again."
: "Failed to save document. Please try again.";
setError(errorMessage);
toast.error(errorMessage);
} finally {
setSaving(false);
}
}, [isNewNote, searchSpaceId, documentId, params.search_space_id, router]);
const handleBack = () => {
if (hasUnsavedChanges) {
setShowUnsavedDialog(true);
} else {
router.push(`/dashboard/${searchSpaceId}/new-chat`);
}
};
const handleConfirmLeave = () => {
setShowUnsavedDialog(false);
setGlobalHasUnsavedChanges(false);
setHasUnsavedChanges(false);
if (pendingNavigation) {
router.push(pendingNavigation);
setPendingNavigation(null);
} else {
router.push(`/dashboard/${searchSpaceId}/new-chat`);
}
};
const handleSaveAndLeave = async () => {
setShowUnsavedDialog(false);
setPendingNavigation(null);
await handleSave();
};
const handleCancelLeave = () => {
setShowUnsavedDialog(false);
setPendingNavigation(null);
};
if (loading) {
return (
<div className="flex flex-col h-screen w-full overflow-hidden">
{/* Top bar skeleton — real back button & file icon, skeleton title */}
<div className="flex h-14 md:h-16 shrink-0 items-center border-b bg-background pl-1.5 pr-3 md:pl-3 md:pr-6">
<div className="flex items-center gap-1.5 md:gap-2 flex-1 min-w-0">
<Button
variant="ghost"
size="icon"
onClick={() => router.push(`/dashboard/${searchSpaceId}/documents`)}
className="h-7 w-7 shrink-0 p-0"
>
<ArrowLeft className="h-4 w-4" />
<span className="sr-only">Back</span>
</Button>
<FileText className="h-4 w-4 md:h-5 md:w-5 text-muted-foreground shrink-0" />
<Skeleton className="h-5 w-40 rounded" />
</div>
</div>
{/* Fixed toolbar placeholder — matches real toolbar styling */}
<div className="sticky top-0 left-0 z-50 w-full border-b bg-background/95 backdrop-blur supports-backdrop-filter:bg-background/60 h-10" />
{/* Content area skeleton — mimics document text lines */}
<div className="flex-1 min-h-0 overflow-hidden">
<div className="mx-auto w-full max-w-[900px] px-6 md:px-12 pt-10 space-y-4">
{/* Title-like line */}
<Skeleton className="h-8 w-3/5 rounded" />
{/* Paragraph lines */}
<div className="space-y-3 pt-4">
<Skeleton className="h-4 w-full rounded" />
<Skeleton className="h-4 w-full rounded" />
<Skeleton className="h-4 w-4/5 rounded" />
</div>
<div className="space-y-3 pt-3">
<Skeleton className="h-4 w-full rounded" />
<Skeleton className="h-4 w-5/6 rounded" />
<Skeleton className="h-4 w-3/4 rounded" />
</div>
<div className="space-y-3 pt-3">
<Skeleton className="h-4 w-full rounded" />
<Skeleton className="h-4 w-2/3 rounded" />
</div>
</div>
</div>
</div>
);
}
if (error && !document) {
return (
<div className="flex items-center justify-center min-h-[400px] p-6">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="w-full max-w-md"
>
<Card className="border-destructive/50">
<CardHeader>
<div className="flex items-center gap-2">
<AlertCircle className="h-5 w-5 text-destructive" />
<CardTitle className="text-destructive">Error</CardTitle>
</div>
<CardDescription>{error}</CardDescription>
</CardHeader>
<CardContent>
<Button
onClick={() => router.push(`/dashboard/${searchSpaceId}/documents`)}
variant="outline"
className="gap-2"
>
<ArrowLeft className="h-4 w-4" />
Back
</Button>
</CardContent>
</Card>
</motion.div>
</div>
);
}
if (!document && !isNewNote) {
return (
<div className="flex items-center justify-center min-h-[400px] p-6">
<Card className="w-full max-w-md">
<CardContent className="flex flex-col items-center justify-center py-12">
<FileText className="h-12 w-12 text-muted-foreground mb-4" />
<p className="text-muted-foreground">Document not found</p>
</CardContent>
</Card>
</div>
);
}
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="flex flex-col h-screen w-full overflow-hidden"
>
{/* Toolbar */}
<div className="flex h-14 md:h-16 shrink-0 items-center border-b bg-background pl-1.5 pr-3 md:pl-3 md:pr-6">
<div className="flex items-center gap-1.5 md:gap-2 flex-1 min-w-0">
<Button
variant="ghost"
size="icon"
onClick={handleBack}
disabled={saving}
className="h-7 w-7 shrink-0 p-0"
>
<ArrowLeft className="h-4 w-4" />
<span className="sr-only">Back</span>
</Button>
<FileText className="h-4 w-4 md:h-5 md:w-5 text-muted-foreground shrink-0" />
<div className="flex flex-col min-w-0">
<h1 className="text-base md:text-lg font-semibold truncate">{displayTitle}</h1>
{hasUnsavedChanges && (
<p className="text-[10px] md:text-xs text-muted-foreground">Unsaved changes</p>
)}
</div>
</div>
</div>
{/* Editor Container */}
<div className="flex-1 min-h-0 flex flex-col overflow-hidden relative">
{error && (
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
className="px-3 md:px-6 pt-3 md:pt-6"
>
<div className="flex items-center gap-2 p-4 rounded-lg border border-destructive/50 bg-destructive/10 text-destructive max-w-4xl mx-auto">
<AlertCircle className="h-5 w-5 shrink-0" />
<p className="text-sm">{error}</p>
</div>
</motion.div>
)}
<div className="flex-1 min-h-0">
<PlateEditor
key={documentId}
preset="full"
markdown={document?.source_markdown ?? ""}
onMarkdownChange={handleMarkdownChange}
onSave={handleSave}
hasUnsavedChanges={hasUnsavedChanges}
isSaving={saving}
defaultEditing={true}
/>
</div>
</div>
{/* Unsaved Changes Dialog */}
<AlertDialog
open={showUnsavedDialog}
onOpenChange={(open) => {
if (!open) handleCancelLeave();
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Unsaved Changes</AlertDialogTitle>
<AlertDialogDescription>
You have unsaved changes. Are you sure you want to leave?
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={handleCancelLeave}>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={handleConfirmLeave}
className={buttonVariants({ variant: "secondary" })}
>
Leave without saving
</AlertDialogAction>
<AlertDialogAction onClick={handleSaveAndLeave}>Save</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</motion.div>
);
}

View file

@ -1,80 +1,9 @@
"use client"; "use client";
import { IconCalendar, IconMailFilled } from "@tabler/icons-react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Check, ExternalLink, Gift, Mail, Star, Zap } from "lucide-react";
import { motion } from "motion/react"; import { motion } from "motion/react";
import Link from "next/link"; import { MorePagesContent } from "@/components/settings/more-pages-content";
import { useEffect } from "react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Separator } from "@/components/ui/separator";
import { Skeleton } from "@/components/ui/skeleton";
import { Spinner } from "@/components/ui/spinner";
import type { IncentiveTaskInfo } from "@/contracts/types/incentive-tasks.types";
import { incentiveTasksApiService } from "@/lib/apis/incentive-tasks-api.service";
import {
trackIncentiveContactOpened,
trackIncentivePageViewed,
trackIncentiveTaskClicked,
trackIncentiveTaskCompleted,
} from "@/lib/posthog/events";
import { cn } from "@/lib/utils";
export default function MorePagesPage() { export default function MorePagesPage() {
const queryClient = useQueryClient();
useEffect(() => {
trackIncentivePageViewed();
}, []);
const { data, isLoading } = useQuery({
queryKey: ["incentive-tasks"],
queryFn: () => incentiveTasksApiService.getTasks(),
});
const completeMutation = useMutation({
mutationFn: incentiveTasksApiService.completeTask,
onSuccess: (response, taskType) => {
if (response.success) {
toast.success(response.message);
const task = data?.tasks.find((t) => t.task_type === taskType);
if (task) {
trackIncentiveTaskCompleted(taskType, task.pages_reward);
}
queryClient.invalidateQueries({ queryKey: ["incentive-tasks"] });
queryClient.invalidateQueries({ queryKey: ["user"] });
}
},
onError: () => {
toast.error("Failed to complete task. Please try again.");
},
});
const handleTaskClick = (task: IncentiveTaskInfo) => {
if (!task.completed) {
trackIncentiveTaskClicked(task.task_type);
completeMutation.mutate(task.task_type);
}
};
return ( return (
<div className="flex min-h-[calc(100vh-64px)] select-none items-center justify-center px-4 py-8"> <div className="flex min-h-[calc(100vh-64px)] select-none items-center justify-center px-4 py-8">
<motion.div <motion.div
@ -83,140 +12,7 @@ export default function MorePagesPage() {
transition={{ duration: 0.3 }} transition={{ duration: 0.3 }}
className="w-full max-w-md space-y-6" className="w-full max-w-md space-y-6"
> >
{/* Header */} <MorePagesContent />
<div className="text-center">
<Gift className="mx-auto mb-3 h-8 w-8 text-primary" />
<h2 className="text-xl font-bold tracking-tight">Get More Pages</h2>
<p className="mt-1 text-sm text-muted-foreground">
Complete tasks to earn additional pages
</p>
</div>
{/* Tasks */}
{isLoading ? (
<Card>
<CardContent className="flex items-center gap-3 p-3">
<Skeleton className="h-9 w-9 rounded-full" />
<div className="flex-1 space-y-2">
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-3 w-1/4" />
</div>
<Skeleton className="h-8 w-16" />
</CardContent>
</Card>
) : (
<div className="space-y-2">
{data?.tasks.map((task) => (
<Card
key={task.task_type}
className={cn("transition-colors", task.completed && "bg-muted/50")}
>
<CardContent className="flex items-center gap-3 p-3">
<div
className={cn(
"flex h-9 w-9 shrink-0 items-center justify-center rounded-full",
task.completed ? "bg-primary text-primary-foreground" : "bg-muted"
)}
>
{task.completed ? <Check className="h-4 w-4" /> : <Star className="h-4 w-4" />}
</div>
<div className="min-w-0 flex-1">
<p
className={cn(
"text-sm font-medium",
task.completed && "text-muted-foreground line-through"
)}
>
{task.title}
</p>
<p className="text-xs text-muted-foreground">+{task.pages_reward} pages</p>
</div>
<Button
variant={task.completed ? "ghost" : "outline"}
size="sm"
disabled={task.completed || completeMutation.isPending}
onClick={() => handleTaskClick(task)}
asChild={!task.completed}
>
{task.completed ? (
<span>Done</span>
) : (
<a
href={task.action_url}
target="_blank"
rel="noopener noreferrer"
className="gap-1"
>
{completeMutation.isPending ? (
<Spinner size="xs" />
) : (
<>
Go
<ExternalLink className="h-3 w-3" />
</>
)}
</a>
)}
</Button>
</CardContent>
</Card>
))}
</div>
)}
{/* PRO Upgrade */}
<Separator />
<Card className="overflow-hidden border-emerald-500/20">
<CardHeader className="pb-2">
<div className="flex items-center gap-2">
<Zap className="h-4 w-4 text-emerald-500" />
<CardTitle className="text-base">Upgrade to PRO</CardTitle>
<Badge className="bg-emerald-600 text-white border-transparent hover:bg-emerald-600">
FREE
</Badge>
</div>
<CardDescription>
For a limited time, get{" "}
<span className="font-semibold text-foreground">6,000 additional pages</span> at no
cost. Contact us and we&apos;ll upgrade your account instantly.
</CardDescription>
</CardHeader>
<CardFooter className="pt-2">
<Dialog onOpenChange={(open) => open && trackIncentiveContactOpened()}>
<DialogTrigger asChild>
<Button className="w-full bg-emerald-600 text-white hover:bg-emerald-700">
<Mail className="h-4 w-4" />
Contact Us to Upgrade
</Button>
</DialogTrigger>
<DialogContent className="select-none sm:max-w-sm">
<DialogHeader>
<DialogTitle>Get in Touch</DialogTitle>
<DialogDescription>Pick the option that works best for you.</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-2">
<Button asChild>
<Link
href="https://cal.com/mod-rohan"
target="_blank"
rel="noopener noreferrer"
>
<IconCalendar className="h-4 w-4" />
Schedule a Meeting
</Link>
</Button>
<Button variant="outline" asChild>
<Link href="mailto:rohan@surfsense.com">
<IconMailFilled className="h-4 w-4" />
rohan@surfsense.com
</Link>
</Button>
</div>
</DialogContent>
</Dialog>
</CardFooter>
</Card>
</motion.div> </motion.div>
</div> </div>
); );

View file

@ -30,9 +30,11 @@ import {
// extractWriteTodosFromContent, // extractWriteTodosFromContent,
} from "@/atoms/chat/plan-state.atom"; } from "@/atoms/chat/plan-state.atom";
import { closeReportPanelAtom } from "@/atoms/chat/report-panel.atom"; import { closeReportPanelAtom } from "@/atoms/chat/report-panel.atom";
import { closeEditorPanelAtom } from "@/atoms/editor/editor-panel.atom";
import { membersAtom } from "@/atoms/members/members-query.atoms"; import { membersAtom } from "@/atoms/members/members-query.atoms";
import { currentUserAtom } from "@/atoms/user/user-query.atoms"; import { currentUserAtom } from "@/atoms/user/user-query.atoms";
import { Thread } from "@/components/assistant-ui/thread"; import { Thread } from "@/components/assistant-ui/thread";
import { MobileEditorPanel } from "@/components/editor-panel/editor-panel";
import { MobileReportPanel } from "@/components/report-panel/report-panel"; import { MobileReportPanel } from "@/components/report-panel/report-panel";
import type { ThinkingStep } from "@/components/tool-ui/deepagent-thinking"; import type { ThinkingStep } from "@/components/tool-ui/deepagent-thinking";
import { DisplayImageToolUI } from "@/components/tool-ui/display-image"; import { DisplayImageToolUI } from "@/components/tool-ui/display-image";
@ -195,6 +197,7 @@ export default function NewChatPage() {
const setTargetCommentId = useSetAtom(setTargetCommentIdAtom); const setTargetCommentId = useSetAtom(setTargetCommentIdAtom);
const clearTargetCommentId = useSetAtom(clearTargetCommentIdAtom); const clearTargetCommentId = useSetAtom(clearTargetCommentIdAtom);
const closeReportPanel = useSetAtom(closeReportPanelAtom); const closeReportPanel = useSetAtom(closeReportPanelAtom);
const closeEditorPanel = useSetAtom(closeEditorPanelAtom);
// Get current user for author info in shared chats // Get current user for author info in shared chats
const { data: currentUser } = useAtomValue(currentUserAtom); const { data: currentUser } = useAtomValue(currentUserAtom);
@ -286,6 +289,7 @@ export default function NewChatPage() {
setMessageDocumentsMap({}); setMessageDocumentsMap({});
clearPlanOwnerRegistry(); clearPlanOwnerRegistry();
closeReportPanel(); closeReportPanel();
closeEditorPanel();
try { try {
if (urlChatId > 0) { if (urlChatId > 0) {
@ -351,6 +355,7 @@ export default function NewChatPage() {
setMentionedDocuments, setMentionedDocuments,
setSidebarDocuments, setSidebarDocuments,
closeReportPanel, closeReportPanel,
closeEditorPanel,
]); ]);
// Initialize on mount // Initialize on mount
@ -1596,7 +1601,7 @@ export default function NewChatPage() {
// Show loading state only when loading an existing thread // Show loading state only when loading an existing thread
if (isInitializing) { if (isInitializing) {
return ( return (
<div className="flex h-[calc(100dvh-64px)] flex-col bg-background px-4"> <div className="flex h-[calc(100dvh-64px)] flex-col bg-main-panel px-4">
<div className="mx-auto w-full max-w-[44rem] flex flex-1 flex-col gap-6 py-8"> <div className="mx-auto w-full max-w-[44rem] flex flex-1 flex-col gap-6 py-8">
{/* User message */} {/* User message */}
<div className="flex justify-end"> <div className="flex justify-end">
@ -1624,7 +1629,7 @@ export default function NewChatPage() {
</div> </div>
{/* Input bar */} {/* Input bar */}
<div className="sticky bottom-0 pb-6 bg-background"> <div className="sticky bottom-0 pb-6 bg-main-panel">
<div className="mx-auto w-full max-w-[44rem]"> <div className="mx-auto w-full max-w-[44rem]">
<Skeleton className="h-24 w-full rounded-2xl" /> <Skeleton className="h-24 w-full rounded-2xl" />
</div> </div>
@ -1677,6 +1682,7 @@ export default function NewChatPage() {
<Thread messageThinkingSteps={messageThinkingSteps} /> <Thread messageThinkingSteps={messageThinkingSteps} />
</div> </div>
<MobileReportPanel /> <MobileReportPanel />
<MobileEditorPanel />
</div> </div>
</AssistantRuntimeProvider> </AssistantRuntimeProvider>
); );

View file

@ -1,6 +1,6 @@
"use client"; "use client";
import { useAtomValue } from "jotai"; import { useAtomValue, useSetAtom } from "jotai";
import { motion } from "motion/react"; import { motion } from "motion/react";
import { useParams, useRouter } from "next/navigation"; import { useParams, useRouter } from "next/navigation";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
@ -13,6 +13,7 @@ import {
globalNewLLMConfigsAtom, globalNewLLMConfigsAtom,
llmPreferencesAtom, llmPreferencesAtom,
} from "@/atoms/new-llm-config/new-llm-config-query.atoms"; } from "@/atoms/new-llm-config/new-llm-config-query.atoms";
import { searchSpaceSettingsDialogAtom } from "@/atoms/settings/settings-dialog.atoms";
import { Logo } from "@/components/Logo"; import { Logo } from "@/components/Logo";
import { LLMConfigForm, type LLMConfigFormData } from "@/components/shared/llm-config-form"; import { LLMConfigForm, type LLMConfigFormData } from "@/components/shared/llm-config-form";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
@ -23,6 +24,7 @@ export default function OnboardPage() {
const router = useRouter(); const router = useRouter();
const params = useParams(); const params = useParams();
const searchSpaceId = Number(params.search_space_id); const searchSpaceId = Number(params.search_space_id);
const setSearchSpaceSettingsDialog = useSetAtom(searchSpaceSettingsDialogAtom);
// Queries // Queries
const { const {
@ -259,7 +261,7 @@ export default function OnboardPage() {
You can add more configurations and customize settings anytime in{" "} You can add more configurations and customize settings anytime in{" "}
<button <button
type="button" type="button"
onClick={() => router.push(`/dashboard/${searchSpaceId}/settings?tab=general`)} onClick={() => setSearchSpaceSettingsDialog({ open: true, initialTab: "general" })}
className="text-violet-500 hover:underline" className="text-violet-500 hover:underline"
> >
Settings Settings

View file

@ -1,5 +0,0 @@
import type React from "react";
export default function SettingsLayout({ children }: { children: React.ReactNode }) {
return <>{children}</>;
}

View file

@ -1,113 +0,0 @@
"use client";
import { Bot, Brain, FileText, Globe, ImageIcon, MessageSquare, Shield } from "lucide-react";
import { useParams, useRouter, useSearchParams } from "next/navigation";
import { useTranslations } from "next-intl";
import { useCallback, useEffect } from "react";
import { PublicChatSnapshotsManager } from "@/components/public-chat-snapshots/public-chat-snapshots-manager";
import { GeneralSettingsManager } from "@/components/settings/general-settings-manager";
import { ImageModelManager } from "@/components/settings/image-model-manager";
import { LLMRoleManager } from "@/components/settings/llm-role-manager";
import { ModelConfigManager } from "@/components/settings/model-config-manager";
import { PromptConfigManager } from "@/components/settings/prompt-config-manager";
import { RolesManager } from "@/components/settings/roles-manager";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/animated-tabs";
import { trackSettingsViewed } from "@/lib/posthog/events";
const VALID_TABS = [
"general",
"models",
"roles",
"image-models",
"prompts",
"public-links",
"team-roles",
] as const;
const DEFAULT_TAB = "general";
export default function SettingsPage() {
const t = useTranslations("searchSpaceSettings");
const router = useRouter();
const params = useParams();
const searchParams = useSearchParams();
const searchSpaceId = Number(params.search_space_id);
const tabParam = searchParams.get("tab") ?? "";
const activeTab = VALID_TABS.includes(tabParam as (typeof VALID_TABS)[number])
? tabParam
: DEFAULT_TAB;
const handleTabChange = useCallback(
(value: string) => {
const p = new URLSearchParams(searchParams.toString());
p.set("tab", value);
router.replace(`?${p.toString()}`, { scroll: false });
},
[router, searchParams]
);
useEffect(() => {
trackSettingsViewed(searchSpaceId, activeTab);
}, [searchSpaceId, activeTab]);
return (
<div className="h-full overflow-y-auto">
<div className="mx-auto w-full max-w-4xl px-4 py-10">
<Tabs value={activeTab} onValueChange={handleTabChange} className="w-full">
<TabsList showBottomBorder>
<TabsTrigger value="general">
<FileText className="mr-2 h-4 w-4" />
{t("nav_general")}
</TabsTrigger>
<TabsTrigger value="models">
<Bot className="mr-2 h-4 w-4" />
{t("nav_agent_configs")}
</TabsTrigger>
<TabsTrigger value="roles">
<Brain className="mr-2 h-4 w-4" />
{t("nav_role_assignments")}
</TabsTrigger>
<TabsTrigger value="image-models">
<ImageIcon className="mr-2 h-4 w-4" />
{t("nav_image_models")}
</TabsTrigger>
<TabsTrigger value="team-roles">
<Shield className="mr-2 h-4 w-4" />
{t("nav_team_roles")}
</TabsTrigger>
<TabsTrigger value="prompts">
<MessageSquare className="mr-2 h-4 w-4" />
{t("nav_system_instructions")}
</TabsTrigger>
<TabsTrigger value="public-links">
<Globe className="mr-2 h-4 w-4" />
{t("nav_public_links")}
</TabsTrigger>
</TabsList>
<TabsContent value="general" className="mt-6">
<GeneralSettingsManager searchSpaceId={searchSpaceId} />
</TabsContent>
<TabsContent value="models" className="mt-6">
<ModelConfigManager searchSpaceId={searchSpaceId} />
</TabsContent>
<TabsContent value="roles" className="mt-6">
<LLMRoleManager searchSpaceId={searchSpaceId} />
</TabsContent>
<TabsContent value="image-models" className="mt-6">
<ImageModelManager searchSpaceId={searchSpaceId} />
</TabsContent>
<TabsContent value="prompts" className="mt-6">
<PromptConfigManager searchSpaceId={searchSpaceId} />
</TabsContent>
<TabsContent value="public-links" className="mt-6">
<PublicChatSnapshotsManager searchSpaceId={searchSpaceId} />
</TabsContent>
<TabsContent value="team-roles" className="mt-6">
<RolesManager searchSpaceId={searchSpaceId} />
</TabsContent>
</Tabs>
</div>
</div>
);
}

View file

@ -1,7 +1,7 @@
"use client"; "use client";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { useAtomValue } from "jotai"; import { useAtomValue, useSetAtom } from "jotai";
import { import {
Calendar, Calendar,
Check, Check,
@ -20,9 +20,7 @@ import {
UserPlus, UserPlus,
Users, Users,
} from "lucide-react"; } from "lucide-react";
import { motion } from "motion/react"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import Image from "next/image";
import { useParams, useRouter } from "next/navigation";
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { import {
@ -34,6 +32,7 @@ import {
updateMemberMutationAtom, updateMemberMutationAtom,
} from "@/atoms/members/members-mutation.atoms"; } from "@/atoms/members/members-mutation.atoms";
import { membersAtom, myAccessAtom } from "@/atoms/members/members-query.atoms"; import { membersAtom, myAccessAtom } from "@/atoms/members/members-query.atoms";
import { searchSpaceSettingsDialogAtom } from "@/atoms/settings/settings-dialog.atoms";
import { import {
AlertDialog, AlertDialog,
AlertDialogAction, AlertDialogAction,
@ -101,27 +100,6 @@ import { trackSearchSpaceInviteSent, trackSearchSpaceUsersViewed } from "@/lib/p
import { cacheKeys } from "@/lib/query-client/cache-keys"; import { cacheKeys } from "@/lib/query-client/cache-keys";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
const AVATAR_COLORS = [
"bg-amber-600",
"bg-blue-600",
"bg-emerald-600",
"bg-violet-600",
"bg-rose-600",
"bg-cyan-600",
"bg-orange-600",
"bg-teal-600",
"bg-pink-600",
"bg-indigo-600",
];
function getAvatarColor(identifier: string): string {
let hash = 0;
for (let i = 0; i < identifier.length; i++) {
hash = identifier.charCodeAt(i) + ((hash << 5) - hash);
}
return AVATAR_COLORS[Math.abs(hash) % AVATAR_COLORS.length];
}
function getAvatarInitials(member: Membership): string { function getAvatarInitials(member: Membership): string {
if (member.user_display_name) { if (member.user_display_name) {
const parts = member.user_display_name.trim().split(/\s+/); const parts = member.user_display_name.trim().split(/\s+/);
@ -140,10 +118,11 @@ function getAvatarInitials(member: Membership): string {
const PAGE_SIZE = 5; const PAGE_SIZE = 5;
const SKELETON_KEYS = Array.from({ length: PAGE_SIZE }, (_, i) => `skeleton-${i}`); const SKELETON_KEYS = Array.from({ length: PAGE_SIZE }, (_, i) => `skeleton-${i}`);
export default function TeamManagementPage() { interface TeamContentProps {
const params = useParams(); searchSpaceId: number;
const searchSpaceId = Number(params.search_space_id); }
export function TeamContent({ searchSpaceId }: TeamContentProps) {
const { data: access = null, isLoading: accessLoading } = useAtomValue(myAccessAtom); const { data: access = null, isLoading: accessLoading } = useAtomValue(myAccessAtom);
const hasPermission = useCallback( const hasPermission = useCallback(
@ -261,13 +240,6 @@ export default function TeamManagementPage() {
if (accessLoading || membersLoading) { if (accessLoading || membersLoading) {
return ( return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.3 }}
className="bg-background select-none"
>
<div className="container max-w-5xl mx-auto p-4 md:p-6 lg:p-8 pt-20 md:pt-24 lg:pt-28">
<div className="space-y-6"> <div className="space-y-6">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<Skeleton className="h-9 w-36 rounded-md" /> <Skeleton className="h-9 w-36 rounded-md" />
@ -316,23 +288,12 @@ export default function TeamManagementPage() {
</Table> </Table>
</div> </div>
</div> </div>
</div>
</motion.div>
); );
} }
return ( return (
<motion.div <div className="space-y-4 md:space-y-6">
initial={{ opacity: 0 }} <div className="flex items-center gap-2 flex-wrap">
animate={{ opacity: 1 }}
transition={{ duration: 0.3 }}
className="bg-background select-none"
>
<div className="container max-w-5xl mx-auto p-4 md:p-6 lg:p-8 pt-20 md:pt-24 lg:pt-28">
<div className="space-y-6">
{/* Header row: Invite button on left, member count on right */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
{canInvite && ( {canInvite && (
<CreateInviteDialog <CreateInviteDialog
roles={roles} roles={roles}
@ -343,13 +304,11 @@ export default function TeamManagementPage() {
{canInvite && activeInvites.length > 0 && ( {canInvite && activeInvites.length > 0 && (
<AllInvitesDialog invites={activeInvites} onRevokeInvite={handleRevokeInvite} /> <AllInvitesDialog invites={activeInvites} onRevokeInvite={handleRevokeInvite} />
)} )}
</div> <p className="text-xs md:text-sm text-muted-foreground whitespace-nowrap">
<p className="hidden md:block text-sm text-muted-foreground">
{members.length} {members.length === 1 ? "member" : "members"} {members.length} {members.length === 1 ? "member" : "members"}
</p> </p>
</div> </div>
{/* Members & Invites Table */}
<div className="rounded-lg border border-border/40 bg-background overflow-hidden"> <div className="rounded-lg border border-border/40 bg-background overflow-hidden">
<Table className="table-fixed w-full"> <Table className="table-fixed w-full">
<TableHeader> <TableHeader>
@ -375,7 +334,7 @@ export default function TeamManagementPage() {
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{owners.map((member, index) => ( {owners.map((member) => (
<MemberRow <MemberRow
key={`member-${member.id}`} key={`member-${member.id}`}
member={member} member={member}
@ -384,11 +343,9 @@ export default function TeamManagementPage() {
canRemove={canRemove} canRemove={canRemove}
onUpdateRole={handleUpdateMember} onUpdateRole={handleUpdateMember}
onRemoveMember={handleRemoveMember} onRemoveMember={handleRemoveMember}
searchSpaceId={searchSpaceId}
index={index}
/> />
))} ))}
{paginatedMembers.map((member, index) => ( {paginatedMembers.map((member) => (
<MemberRow <MemberRow
key={`member-${member.id}`} key={`member-${member.id}`}
member={member} member={member}
@ -397,8 +354,6 @@ export default function TeamManagementPage() {
canRemove={canRemove} canRemove={canRemove}
onUpdateRole={handleUpdateMember} onUpdateRole={handleUpdateMember}
onRemoveMember={handleRemoveMember} onRemoveMember={handleRemoveMember}
searchSpaceId={searchSpaceId}
index={owners.length + index}
/> />
))} ))}
{members.length === 0 && ( {members.length === 0 && (
@ -415,14 +370,8 @@ export default function TeamManagementPage() {
</Table> </Table>
</div> </div>
{/* Pagination */}
{totalItems > PAGE_SIZE && ( {totalItems > PAGE_SIZE && (
<motion.div <div className="flex items-center justify-end gap-3 py-3 px-2">
className="flex items-center justify-end gap-3 py-3 px-2"
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ type: "spring", stiffness: 300, damping: 30, delay: 0.3 }}
>
<span className="text-sm text-muted-foreground tabular-nums"> <span className="text-sm text-muted-foreground tabular-nums">
{displayStart}-{displayEnd} of {totalItems} {displayStart}-{displayEnd} of {totalItems}
</span> </span>
@ -468,16 +417,12 @@ export default function TeamManagementPage() {
<ChevronLast size={18} strokeWidth={2} /> <ChevronLast size={18} strokeWidth={2} />
</Button> </Button>
</div> </div>
</motion.div> </div>
)} )}
</div> </div>
</div>
</motion.div>
); );
} }
// ============ Member Row ============
function MemberRow({ function MemberRow({
member, member,
roles, roles,
@ -485,8 +430,6 @@ function MemberRow({
canRemove, canRemove,
onUpdateRole, onUpdateRole,
onRemoveMember, onRemoveMember,
searchSpaceId,
index,
}: { }: {
member: Membership; member: Membership;
roles: Role[]; roles: Role[];
@ -494,44 +437,23 @@ function MemberRow({
canRemove: boolean; canRemove: boolean;
onUpdateRole: (membershipId: number, roleId: number | null) => Promise<Membership>; onUpdateRole: (membershipId: number, roleId: number | null) => Promise<Membership>;
onRemoveMember: (membershipId: number) => Promise<boolean>; onRemoveMember: (membershipId: number) => Promise<boolean>;
searchSpaceId: number;
index: number;
}) { }) {
const router = useRouter(); const setSearchSpaceSettingsDialog = useSetAtom(searchSpaceSettingsDialogAtom);
const initials = getAvatarInitials(member); const initials = getAvatarInitials(member);
const avatarColor = getAvatarColor(member.user_id);
const displayName = member.user_display_name || member.user_email || "Unknown"; const displayName = member.user_display_name || member.user_email || "Unknown";
const roleName = member.is_owner ? "Owner" : member.role?.name || "No role"; const roleName = member.is_owner ? "Owner" : member.role?.name || "No role";
const showActions = !member.is_owner && (canManageRoles || canRemove); const showActions = !member.is_owner && (canManageRoles || canRemove);
return ( return (
<motion.tr <TableRow className="border-b border-border/40 transition-colors hover:bg-muted/30">
initial={{ opacity: 0 }}
animate={{ opacity: 1, transition: { duration: 0.2, delay: index * 0.02 } }}
className="border-b border-border/40 transition-colors hover:bg-muted/30"
>
<TableCell className="w-[45%] py-2.5 px-4 md:px-6 max-w-0 border-r border-border/40"> <TableCell className="w-[45%] py-2.5 px-4 md:px-6 max-w-0 border-r border-border/40">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="shrink-0"> <Avatar className="size-10 shrink-0">
{member.user_avatar_url ? ( {member.user_avatar_url && (
<Image <AvatarImage src={member.user_avatar_url} alt={displayName} />
src={member.user_avatar_url}
alt={displayName}
width={40}
height={40}
className="h-10 w-10 rounded-full object-cover"
/>
) : (
<div
className={cn(
"h-10 w-10 rounded-full flex items-center justify-center text-white font-medium text-sm",
avatarColor
)} )}
> <AvatarFallback className="text-sm">{initials}</AvatarFallback>
{initials} </Avatar>
</div>
)}
</div>
<div className="min-w-0"> <div className="min-w-0">
<p className="font-medium text-sm truncate select-text">{displayName}</p> <p className="font-medium text-sm truncate select-text">{displayName}</p>
{member.user_display_name && member.user_email && ( {member.user_display_name && member.user_email && (
@ -607,7 +529,12 @@ function MemberRow({
)} )}
<DropdownMenuSeparator className="dark:bg-white/5" /> <DropdownMenuSeparator className="dark:bg-white/5" />
<DropdownMenuItem <DropdownMenuItem
onClick={() => router.push(`/dashboard/${searchSpaceId}/settings?tab=team-roles`)} onClick={() =>
setSearchSpaceSettingsDialog({
open: true,
initialTab: "team-roles",
})
}
> >
Manage Roles Manage Roles
</DropdownMenuItem> </DropdownMenuItem>
@ -617,12 +544,10 @@ function MemberRow({
<span className="text-sm text-foreground">{roleName}</span> <span className="text-sm text-foreground">{roleName}</span>
)} )}
</TableCell> </TableCell>
</motion.tr> </TableRow>
); );
} }
// ============ Create Invite Dialog ============
function CreateInviteDialog({ function CreateInviteDialog({
roles, roles,
onCreateInvite, onCreateInvite,
@ -698,9 +623,10 @@ function CreateInviteDialog({
<DialogTrigger asChild> <DialogTrigger asChild>
<Button <Button
variant="outline" variant="outline"
className="gap-2 bg-black text-white dark:bg-white dark:text-black hover:bg-black/90 dark:hover:bg-white/90" size="sm"
className="gap-1.5 md:gap-2 text-xs md:text-sm bg-black text-white dark:bg-white dark:text-black hover:bg-black/90 dark:hover:bg-white/90"
> >
<UserPlus className="h-4 w-4" /> <UserPlus className="h-3.5 w-3.5 md:h-4 md:w-4" />
Invite members Invite members
</Button> </Button>
</DialogTrigger> </DialogTrigger>
@ -850,8 +776,6 @@ function CreateInviteDialog({
); );
} }
// ============ All Invites Dialog ============
function AllInvitesDialog({ function AllInvitesDialog({
invites, invites,
onRevokeInvite, onRevokeInvite,
@ -872,10 +796,10 @@ function AllInvitesDialog({
return ( return (
<Dialog> <Dialog>
<DialogTrigger asChild> <DialogTrigger asChild>
<Button variant="secondary" className="gap-2"> <Button variant="secondary" size="sm" className="gap-1.5 md:gap-2 text-xs md:text-sm">
<Link2 className="h-4 w-4 rotate-315" /> <Link2 className="h-3.5 w-3.5 md:h-4 md:w-4 rotate-315" />
Active invites Active invites
<span className="inline-flex items-center justify-center h-5 min-w-5 px-1 rounded-full bg-neutral-700 text-neutral-200 text-xs font-medium"> <span className="inline-flex items-center justify-center h-4 md:h-5 min-w-4 md:min-w-5 px-1 rounded-full bg-neutral-700 text-neutral-200 text-[10px] md:text-xs font-medium">
{invites.length} {invites.length}
</span> </span>
</Button> </Button>

View file

@ -1,7 +1,6 @@
"use client"; "use client";
import { Check, Copy, Info } from "lucide-react"; import { Check, Copy, Info } from "lucide-react";
import { AnimatePresence, motion } from "motion/react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useCallback, useRef, useState } from "react"; import { useCallback, useRef, useState } from "react";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
@ -27,15 +26,7 @@ export function ApiKeyContent() {
}, [apiKey]); }, [apiKey]);
return ( return (
<AnimatePresence mode="wait"> <div className="space-y-6 min-w-0 overflow-hidden">
<motion.div
key="api-key-content"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
transition={{ duration: 0.35, ease: [0.4, 0, 0.2, 1] }}
className="space-y-6"
>
<Alert className="border-border/60 bg-muted/30 text-muted-foreground"> <Alert className="border-border/60 bg-muted/30 text-muted-foreground">
<Info className="h-4 w-4 text-muted-foreground" /> <Info className="h-4 w-4 text-muted-foreground" />
<AlertTitle className="text-muted-foreground">{t("api_key_warning_title")}</AlertTitle> <AlertTitle className="text-muted-foreground">{t("api_key_warning_title")}</AlertTitle>
@ -44,7 +35,7 @@ export function ApiKeyContent() {
</AlertDescription> </AlertDescription>
</Alert> </Alert>
<div className="rounded-lg border border-border/60 bg-card p-6"> <div className="rounded-lg border border-border/60 bg-card p-6 min-w-0 overflow-hidden">
<h3 className="mb-4 text-sm font-semibold tracking-tight">{t("your_api_key")}</h3> <h3 className="mb-4 text-sm font-semibold tracking-tight">{t("your_api_key")}</h3>
{isLoading ? ( {isLoading ? (
<div className="h-12 w-full animate-pulse rounded-md border border-border/60 bg-muted/30" /> <div className="h-12 w-full animate-pulse rounded-md border border-border/60 bg-muted/30" />
@ -80,7 +71,7 @@ export function ApiKeyContent() {
)} )}
</div> </div>
<div className="rounded-lg border border-border/60 bg-card p-6"> <div className="rounded-lg border border-border/60 bg-card p-6 min-w-0 overflow-hidden">
<h3 className="mb-2 text-sm font-semibold tracking-tight">{t("usage_title")}</h3> <h3 className="mb-2 text-sm font-semibold tracking-tight">{t("usage_title")}</h3>
<p className="mb-4 text-[11px] text-muted-foreground/60">{t("usage_description")}</p> <p className="mb-4 text-[11px] text-muted-foreground/60">{t("usage_description")}</p>
<div className="flex items-center gap-2 rounded-md border border-border/60 bg-muted/30 px-2.5 py-1.5"> <div className="flex items-center gap-2 rounded-md border border-border/60 bg-muted/30 px-2.5 py-1.5">
@ -110,7 +101,6 @@ export function ApiKeyContent() {
</TooltipProvider> </TooltipProvider>
</div> </div>
</div> </div>
</motion.div> </div>
</AnimatePresence>
); );
} }

View file

@ -1,7 +1,6 @@
"use client"; "use client";
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
import { AnimatePresence, motion } from "motion/react";
import Image from "next/image"; import Image from "next/image";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
@ -72,14 +71,7 @@ export function ProfileContent() {
const hasChanges = displayName !== (user?.display_name || ""); const hasChanges = displayName !== (user?.display_name || "");
return ( return (
<AnimatePresence mode="wait"> <div>
<motion.div
key="profile-content"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
transition={{ duration: 0.35, ease: [0.4, 0, 0.2, 1] }}
>
{isUserLoading ? ( {isUserLoading ? (
<div className="flex items-center justify-center py-12"> <div className="flex items-center justify-center py-12">
<Spinner size="md" className="text-muted-foreground" /> <Spinner size="md" className="text-muted-foreground" />
@ -116,14 +108,18 @@ export function ProfileContent() {
</div> </div>
<div className="flex justify-end"> <div className="flex justify-end">
<Button type="submit" disabled={isPending || !hasChanges}> <Button
type="submit"
variant="outline"
disabled={isPending || !hasChanges}
className="gap-2 bg-white text-black hover:bg-neutral-100 dark:bg-white dark:text-black dark:hover:bg-neutral-200"
>
{isPending && <Spinner size="sm" className="mr-2" />} {isPending && <Spinner size="sm" className="mr-2" />}
{t("profile_save")} {t("profile_save")}
</Button> </Button>
</div> </div>
</form> </form>
)} )}
</motion.div> </div>
</AnimatePresence>
); );
} }

View file

@ -1,57 +0,0 @@
"use client";
import { User, UserKey } from "lucide-react";
import { useRouter, useSearchParams } from "next/navigation";
import { useTranslations } from "next-intl";
import { useCallback } from "react";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/animated-tabs";
import { ApiKeyContent } from "./components/ApiKeyContent";
import { ProfileContent } from "./components/ProfileContent";
const VALID_TABS = ["profile", "api-key"] as const;
const DEFAULT_TAB = "profile";
export default function UserSettingsPage() {
const t = useTranslations("userSettings");
const router = useRouter();
const searchParams = useSearchParams();
const tabParam = searchParams.get("tab") ?? "";
const activeTab = VALID_TABS.includes(tabParam as (typeof VALID_TABS)[number])
? tabParam
: DEFAULT_TAB;
const handleTabChange = useCallback(
(value: string) => {
const params = new URLSearchParams(searchParams.toString());
params.set("tab", value);
router.replace(`?${params.toString()}`, { scroll: false });
},
[router, searchParams]
);
return (
<div className="h-full overflow-y-auto">
<div className="mx-auto w-full max-w-4xl px-4 py-10">
<Tabs value={activeTab} onValueChange={handleTabChange} className="w-full">
<TabsList showBottomBorder>
<TabsTrigger value="profile">
<User className="mr-2 h-4 w-4" />
{t("profile_nav_label")}
</TabsTrigger>
<TabsTrigger value="api-key">
<UserKey className="mr-2 h-4 w-4" />
{t("api_key_nav_label")}
</TabsTrigger>
</TabsList>
<TabsContent value="profile" className="mt-6">
<ProfileContent />
</TabsContent>
<TabsContent value="api-key" className="mt-6">
<ApiKeyContent />
</TabsContent>
</Tabs>
</div>
</div>
);
}

View file

@ -47,6 +47,7 @@
--sidebar-accent-foreground: oklch(0.205 0 0); --sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0); --sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0); --sidebar-ring: oklch(0.708 0 0);
--main-panel: oklch(1 0 0);
--syntax-bg: #f5f5f5; --syntax-bg: #f5f5f5;
--brand: oklch(0.623 0.214 259.815); --brand: oklch(0.623 0.214 259.815);
--highlight: oklch(0.852 0.199 91.936); --highlight: oklch(0.852 0.199 91.936);
@ -85,6 +86,7 @@
--sidebar-accent-foreground: oklch(0.985 0 0); --sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(0.269 0 0); --sidebar-border: oklch(0.269 0 0);
--sidebar-ring: oklch(0.439 0 0); --sidebar-ring: oklch(0.439 0 0);
--main-panel: oklch(0.18 0 0);
--syntax-bg: #1e1e1e; --syntax-bg: #1e1e1e;
--brand: oklch(0.707 0.165 254.624); --brand: oklch(0.707 0.165 254.624);
--highlight: oklch(0.852 0.199 91.936); --highlight: oklch(0.852 0.199 91.936);
@ -115,6 +117,7 @@
--color-chart-3: var(--chart-3); --color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4); --color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5); --color-chart-5: var(--chart-5);
--color-main-panel: var(--main-panel);
--radius-sm: calc(var(--radius) - 4px); --radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px); --radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius); --radius-lg: var(--radius);

View file

@ -224,6 +224,12 @@ export default function sitemap(): MetadataRoute.Sitemap {
changeFrequency: "daily", changeFrequency: "daily",
priority: 0.8, priority: 0.8,
}, },
{
url: "https://www.surfsense.com/docs/how-to/web-search",
lastModified,
changeFrequency: "daily",
priority: 0.8,
},
// Developer documentation // Developer documentation
{ {
url: "https://www.surfsense.com/docs/testing", url: "https://www.surfsense.com/docs/testing",

View file

@ -0,0 +1,57 @@
import { atom } from "jotai";
import { rightPanelCollapsedAtom, rightPanelTabAtom } from "@/atoms/layout/right-panel.atom";
interface EditorPanelState {
isOpen: boolean;
documentId: number | null;
searchSpaceId: number | null;
title: string | null;
}
const initialState: EditorPanelState = {
isOpen: false,
documentId: null,
searchSpaceId: null,
title: null,
};
export const editorPanelAtom = atom<EditorPanelState>(initialState);
export const editorPanelOpenAtom = atom((get) => get(editorPanelAtom).isOpen);
const preEditorCollapsedAtom = atom<boolean | null>(null);
export const openEditorPanelAtom = atom(
null,
(
get,
set,
{
documentId,
searchSpaceId,
title,
}: { documentId: number; searchSpaceId: number; title?: string }
) => {
if (!get(editorPanelAtom).isOpen) {
set(preEditorCollapsedAtom, get(rightPanelCollapsedAtom));
}
set(editorPanelAtom, {
isOpen: true,
documentId,
searchSpaceId,
title: title ?? null,
});
set(rightPanelTabAtom, "editor");
set(rightPanelCollapsedAtom, false);
}
);
export const closeEditorPanelAtom = atom(null, (get, set) => {
set(editorPanelAtom, initialState);
set(rightPanelTabAtom, "sources");
const prev = get(preEditorCollapsedAtom);
if (prev !== null) {
set(rightPanelCollapsedAtom, prev);
set(preEditorCollapsedAtom, null);
}
});

View file

@ -1,27 +0,0 @@
import { atom } from "jotai";
interface EditorUIState {
hasUnsavedChanges: boolean;
pendingNavigation: string | null; // URL to navigate to after user confirms
}
export const editorUIAtom = atom<EditorUIState>({
hasUnsavedChanges: false,
pendingNavigation: null,
});
// Derived atom for just the unsaved changes state
export const hasUnsavedEditorChangesAtom = atom(
(get) => get(editorUIAtom).hasUnsavedChanges,
(get, set, value: boolean) => {
set(editorUIAtom, { ...get(editorUIAtom), hasUnsavedChanges: value });
}
);
// Derived atom for pending navigation
export const pendingEditorNavigationAtom = atom(
(get) => get(editorUIAtom).pendingNavigation,
(get, set, value: string | null) => {
set(editorUIAtom, { ...get(editorUIAtom), pendingNavigation: value });
}
);

View file

@ -1,6 +1,6 @@
import { atom } from "jotai"; import { atom } from "jotai";
export type RightPanelTab = "sources" | "report"; export type RightPanelTab = "sources" | "report" | "editor";
export const rightPanelTabAtom = atom<RightPanelTab>("sources"); export const rightPanelTabAtom = atom<RightPanelTab>("sources");

View file

@ -0,0 +1,25 @@
import { atom } from "jotai";
export interface SearchSpaceSettingsDialogState {
open: boolean;
initialTab: string;
}
export interface UserSettingsDialogState {
open: boolean;
initialTab: string;
}
export const searchSpaceSettingsDialogAtom = atom<SearchSpaceSettingsDialogState>({
open: false,
initialTab: "general",
});
export const userSettingsDialogAtom = atom<UserSettingsDialogState>({
open: false,
initialTab: "profile",
});
export const teamDialogAtom = atom<boolean>(false);
export const morePagesDialogAtom = atom<boolean>(false);

View file

@ -1,8 +1,7 @@
"use client"; "use client";
import { useAtomValue } from "jotai"; import { useAtomValue, useSetAtom } from "jotai";
import { AlertTriangle, Cable, Settings } from "lucide-react"; import { AlertTriangle, Cable, Settings } from "lucide-react";
import Link from "next/link";
import { useSearchParams } from "next/navigation"; import { useSearchParams } from "next/navigation";
import { type FC, forwardRef, useEffect, useImperativeHandle, useMemo, useState } from "react"; import { type FC, forwardRef, useEffect, useImperativeHandle, useMemo, useState } from "react";
import { documentTypeCountsAtom } from "@/atoms/documents/document-query.atoms"; import { documentTypeCountsAtom } from "@/atoms/documents/document-query.atoms";
@ -12,6 +11,7 @@ import {
llmPreferencesAtom, llmPreferencesAtom,
} from "@/atoms/new-llm-config/new-llm-config-query.atoms"; } from "@/atoms/new-llm-config/new-llm-config-query.atoms";
import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms"; import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms";
import { searchSpaceSettingsDialogAtom } from "@/atoms/settings/settings-dialog.atoms";
import { currentUserAtom } from "@/atoms/user/user-query.atoms"; import { currentUserAtom } from "@/atoms/user/user-query.atoms";
import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button"; import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
@ -50,6 +50,7 @@ export const ConnectorIndicator = forwardRef<ConnectorIndicatorHandle, Connector
({ showTrigger = true }, ref) => { ({ showTrigger = true }, ref) => {
const searchSpaceId = useAtomValue(activeSearchSpaceIdAtom); const searchSpaceId = useAtomValue(activeSearchSpaceIdAtom);
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const setSearchSpaceSettingsDialog = useSetAtom(searchSpaceSettingsDialogAtom);
const { data: currentUser } = useAtomValue(currentUserAtom); const { data: currentUser } = useAtomValue(currentUserAtom);
const { data: preferences = {}, isFetching: preferencesLoading } = const { data: preferences = {}, isFetching: preferencesLoading } =
useAtomValue(llmPreferencesAtom); useAtomValue(llmPreferencesAtom);
@ -417,11 +418,19 @@ export const ConnectorIndicator = forwardRef<ConnectorIndicatorHandle, Connector
? "Auto mode is selected but no global LLM configurations are available. Please configure a custom LLM in Settings to process and summarize documents from your connected sources." ? "Auto mode is selected but no global LLM configurations are available. Please configure a custom LLM in Settings to process and summarize documents from your connected sources."
: "You need to configure a Document Summary LLM before adding connectors. This LLM is used to process and summarize documents from your connected sources."} : "You need to configure a Document Summary LLM before adding connectors. This LLM is used to process and summarize documents from your connected sources."}
</p> </p>
<Button asChild size="sm" variant="outline"> <Button
<Link href={`/dashboard/${searchSpaceId}/settings?tab=models`}> size="sm"
variant="outline"
onClick={() => {
handleOpenChange(false);
setSearchSpaceSettingsDialog({
open: true,
initialTab: "models",
});
}}
>
<Settings className="mr-2 h-4 w-4" /> <Settings className="mr-2 h-4 w-4" />
Go to Settings Go to Settings
</Link>
</Button> </Button>
</AlertDescription> </AlertDescription>
</Alert> </Alert>

View file

@ -23,11 +23,6 @@ export function getConnectorBenefits(connectorType: string): string[] | null {
"Real-time information from the web", "Real-time information from the web",
"Enhanced search capabilities for your projects", "Enhanced search capabilities for your projects",
], ],
SEARXNG_API: [
"Privacy-focused meta-search across multiple engines",
"Self-hosted search instance for full control",
"Real-time web search results from multiple sources",
],
LINKUP_API: [ LINKUP_API: [
"AI-powered search results tailored to your queries", "AI-powered search results tailored to your queries",
"Real-time information from the web", "Real-time information from the web",

View file

@ -8,7 +8,6 @@ import { LinkupApiConnectForm } from "./components/linkup-api-connect-form";
import { LumaConnectForm } from "./components/luma-connect-form"; import { LumaConnectForm } from "./components/luma-connect-form";
import { MCPConnectForm } from "./components/mcp-connect-form"; import { MCPConnectForm } from "./components/mcp-connect-form";
import { ObsidianConnectForm } from "./components/obsidian-connect-form"; import { ObsidianConnectForm } from "./components/obsidian-connect-form";
import { SearxngConnectForm } from "./components/searxng-connect-form";
import { TavilyApiConnectForm } from "./components/tavily-api-connect-form"; import { TavilyApiConnectForm } from "./components/tavily-api-connect-form";
export interface ConnectFormProps { export interface ConnectFormProps {
@ -41,8 +40,6 @@ export function getConnectFormComponent(connectorType: string): ConnectFormCompo
switch (connectorType) { switch (connectorType) {
case "TAVILY_API": case "TAVILY_API":
return TavilyApiConnectForm; return TavilyApiConnectForm;
case "SEARXNG_API":
return SearxngConnectForm;
case "LINKUP_API": case "LINKUP_API":
return LinkupApiConnectForm; return LinkupApiConnectForm;
case "BAIDU_SEARCH_API": case "BAIDU_SEARCH_API":

View file

@ -19,7 +19,6 @@ import { LinkupApiConfig } from "./components/linkup-api-config";
import { LumaConfig } from "./components/luma-config"; import { LumaConfig } from "./components/luma-config";
import { MCPConfig } from "./components/mcp-config"; import { MCPConfig } from "./components/mcp-config";
import { ObsidianConfig } from "./components/obsidian-config"; import { ObsidianConfig } from "./components/obsidian-config";
import { SearxngConfig } from "./components/searxng-config";
import { SlackConfig } from "./components/slack-config"; import { SlackConfig } from "./components/slack-config";
import { TavilyApiConfig } from "./components/tavily-api-config"; import { TavilyApiConfig } from "./components/tavily-api-config";
import { TeamsConfig } from "./components/teams-config"; import { TeamsConfig } from "./components/teams-config";
@ -45,8 +44,6 @@ export function getConnectorConfigComponent(
return GoogleDriveConfig; return GoogleDriveConfig;
case "TAVILY_API": case "TAVILY_API":
return TavilyApiConfig; return TavilyApiConfig;
case "SEARXNG_API":
return SearxngConfig;
case "LINKUP_API": case "LINKUP_API":
return LinkupApiConfig; return LinkupApiConfig;
case "BAIDU_SEARCH_API": case "BAIDU_SEARCH_API":

View file

@ -11,7 +11,6 @@ import { getConnectFormComponent } from "../../connect-forms";
const FORM_ID_MAP: Record<string, string> = { const FORM_ID_MAP: Record<string, string> = {
TAVILY_API: "tavily-connect-form", TAVILY_API: "tavily-connect-form",
SEARXNG_API: "searxng-connect-form",
LINKUP_API: "linkup-api-connect-form", LINKUP_API: "linkup-api-connect-form",
BAIDU_SEARCH_API: "baidu-search-api-connect-form", BAIDU_SEARCH_API: "baidu-search-api-connect-form",
ELASTICSEARCH_CONNECTOR: "elasticsearch-connect-form", ELASTICSEARCH_CONNECTOR: "elasticsearch-connect-form",

View file

@ -136,12 +136,6 @@ export const OTHER_CONNECTORS = [
description: "Search with Tavily", description: "Search with Tavily",
connectorType: EnumConnectorName.TAVILY_API, connectorType: EnumConnectorName.TAVILY_API,
}, },
{
id: "searxng",
title: "SearxNG",
description: "Search with SearxNG",
connectorType: EnumConnectorName.SEARXNG_API,
},
{ {
id: "linkup-api", id: "linkup-api",
title: "Linkup API", title: "Linkup API",

View file

@ -1,8 +1,7 @@
"use client"; "use client";
import { useAtomValue } from "jotai"; import { useAtomValue, useSetAtom } from "jotai";
import { AlertTriangle, Settings, Upload } from "lucide-react"; import { AlertTriangle, Settings } from "lucide-react";
import Link from "next/link";
import { import {
createContext, createContext,
type FC, type FC,
@ -17,6 +16,7 @@ import {
llmPreferencesAtom, llmPreferencesAtom,
} from "@/atoms/new-llm-config/new-llm-config-query.atoms"; } from "@/atoms/new-llm-config/new-llm-config-query.atoms";
import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms"; import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms";
import { searchSpaceSettingsDialogAtom } from "@/atoms/settings/settings-dialog.atoms";
import { DocumentUploadTab } from "@/components/sources/DocumentUploadTab"; import { DocumentUploadTab } from "@/components/sources/DocumentUploadTab";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@ -91,6 +91,7 @@ const DocumentUploadPopupContent: FC<{
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
}> = ({ isOpen, onOpenChange }) => { }> = ({ isOpen, onOpenChange }) => {
const searchSpaceId = useAtomValue(activeSearchSpaceIdAtom); const searchSpaceId = useAtomValue(activeSearchSpaceIdAtom);
const setSearchSpaceSettingsDialog = useSetAtom(searchSpaceSettingsDialogAtom);
const { data: preferences = {}, isFetching: preferencesLoading } = const { data: preferences = {}, isFetching: preferencesLoading } =
useAtomValue(llmPreferencesAtom); useAtomValue(llmPreferencesAtom);
const { data: globalConfigs = [], isFetching: globalConfigsLoading } = const { data: globalConfigs = [], isFetching: globalConfigsLoading } =
@ -157,11 +158,19 @@ const DocumentUploadPopupContent: FC<{
? "Auto mode is selected but no global LLM configurations are available. Please configure a custom LLM in Settings to process and summarize your uploaded documents." ? "Auto mode is selected but no global LLM configurations are available. Please configure a custom LLM in Settings to process and summarize your uploaded documents."
: "You need to configure a Document Summary LLM before uploading files. This LLM is used to process and summarize your uploaded documents."} : "You need to configure a Document Summary LLM before uploading files. This LLM is used to process and summarize your uploaded documents."}
</p> </p>
<Button asChild size="sm" variant="outline"> <Button
<Link href={`/dashboard/${searchSpaceId}/settings?tab=models`}> size="sm"
variant="outline"
onClick={() => {
onOpenChange(false);
setSearchSpaceSettingsDialog({
open: true,
initialTab: "models",
});
}}
>
<Settings className="mr-2 h-4 w-4" /> <Settings className="mr-2 h-4 w-4" />
Go to Settings Go to Settings
</Link>
</Button> </Button>
</AlertDescription> </AlertDescription>
</Alert> </Alert>

View file

@ -9,7 +9,7 @@ export const ThreadScrollToBottom: FC = () => {
<TooltipIconButton <TooltipIconButton
tooltip="Scroll to bottom" tooltip="Scroll to bottom"
variant="outline" variant="outline"
className="aui-thread-scroll-to-bottom -top-12 absolute z-10 self-center rounded-full p-4 disabled:invisible dark:bg-background dark:hover:bg-accent" className="aui-thread-scroll-to-bottom -top-12 absolute z-10 self-center rounded-full p-4 disabled:invisible dark:bg-main-panel dark:hover:bg-accent"
> >
<ArrowDownIcon /> <ArrowDownIcon />
</TooltipIconButton> </TooltipIconButton>

View file

@ -19,6 +19,7 @@ import {
ChevronRightIcon, ChevronRightIcon,
CopyIcon, CopyIcon,
DownloadIcon, DownloadIcon,
Globe,
Plus, Plus,
RefreshCwIcon, RefreshCwIcon,
Settings2, Settings2,
@ -27,13 +28,13 @@ import {
Upload, Upload,
X, X,
} from "lucide-react"; } from "lucide-react";
import { AnimatePresence, motion } from "motion/react";
import { useParams } from "next/navigation"; import { useParams } from "next/navigation";
import { type FC, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react"; import { type FC, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { import {
agentToolsAtom, agentToolsAtom,
disabledToolsAtom, disabledToolsAtom,
enabledToolCountAtom,
hydrateDisabledToolsAtom, hydrateDisabledToolsAtom,
toggleToolAtom, toggleToolAtom,
} from "@/atoms/agent-tools/agent-tools.atoms"; } from "@/atoms/agent-tools/agent-tools.atoms";
@ -87,6 +88,7 @@ import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { getConnectorIcon } from "@/contracts/enums/connectorIcons"; import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
import { getToolIcon } from "@/contracts/enums/toolIcons";
import type { Document } from "@/contracts/types/document.types"; import type { Document } from "@/contracts/types/document.types";
import { useBatchCommentsPreload } from "@/hooks/use-comments"; import { useBatchCommentsPreload } from "@/hooks/use-comments";
import { useCommentsElectric } from "@/hooks/use-comments-electric"; import { useCommentsElectric } from "@/hooks/use-comments-electric";
@ -118,7 +120,7 @@ export const Thread: FC<ThreadProps> = ({ messageThinkingSteps = new Map() }) =>
const ThreadContent: FC = () => { const ThreadContent: FC = () => {
return ( return (
<ThreadPrimitive.Root <ThreadPrimitive.Root
className="aui-root aui-thread-root @container flex h-full min-h-0 flex-col bg-background" className="aui-root aui-thread-root @container flex h-full min-h-0 flex-col bg-main-panel"
style={{ style={{
["--thread-max-width" as string]: "44rem", ["--thread-max-width" as string]: "44rem",
}} }}
@ -140,7 +142,7 @@ const ThreadContent: FC = () => {
/> />
<ThreadPrimitive.ViewportFooter <ThreadPrimitive.ViewportFooter
className="aui-thread-viewport-footer sticky bottom-0 z-10 mx-auto mt-auto flex w-full max-w-(--thread-max-width) flex-col gap-4 overflow-visible rounded-t-3xl bg-background pb-4 md:pb-6" className="aui-thread-viewport-footer sticky bottom-0 z-10 mx-auto mt-auto flex w-full max-w-(--thread-max-width) flex-col gap-4 overflow-visible rounded-t-3xl bg-main-panel pb-4 md:pb-6"
style={{ paddingBottom: "max(1rem, env(safe-area-inset-bottom))" }} style={{ paddingBottom: "max(1rem, env(safe-area-inset-bottom))" }}
> >
<ThreadScrollToBottom /> <ThreadScrollToBottom />
@ -161,7 +163,7 @@ const ThreadScrollToBottom: FC = () => {
<TooltipIconButton <TooltipIconButton
tooltip="Scroll to bottom" tooltip="Scroll to bottom"
variant="outline" variant="outline"
className="aui-thread-scroll-to-bottom -top-12 absolute z-10 self-center rounded-full p-4 disabled:invisible dark:bg-background dark:hover:bg-accent" className="aui-thread-scroll-to-bottom -top-12 absolute z-10 self-center rounded-full p-4 disabled:invisible dark:bg-main-panel dark:hover:bg-accent"
> >
<ArrowDownIcon /> <ArrowDownIcon />
</TooltipIconButton> </TooltipIconButton>
@ -232,7 +234,9 @@ const ThreadWelcome: FC = () => {
<div className="aui-thread-welcome-root mx-auto flex w-full max-w-(--thread-max-width) grow flex-col items-center px-4 relative"> <div className="aui-thread-welcome-root mx-auto flex w-full max-w-(--thread-max-width) grow flex-col items-center px-4 relative">
{/* Greeting positioned above the composer */} {/* Greeting positioned above the composer */}
<div className="aui-thread-welcome-message absolute bottom-[calc(50%+5rem)] left-0 right-0 flex flex-col items-center text-center"> <div className="aui-thread-welcome-message absolute bottom-[calc(50%+5rem)] left-0 right-0 flex flex-col items-center text-center">
<h1 className="aui-thread-welcome-message-inner text-3xl md:text-5xl">{greeting}</h1> <h1 className="aui-thread-welcome-message-inner text-3xl md:text-5xl select-none">
{greeting}
</h1>
</div> </div>
{/* Composer - top edge fixed, expands downward only */} {/* Composer - top edge fixed, expands downward only */}
<div className="w-full flex items-start justify-center absolute top-[calc(50%-3.5rem)] left-0 right-0"> <div className="w-full flex items-start justify-center absolute top-[calc(50%-3.5rem)] left-0 right-0">
@ -271,33 +275,39 @@ const ConnectToolsBanner: FC = () => {
}; };
return ( return (
<div className="md:hidden border-t border-border/50 bg-muted-foreground/[0.04]"> <div className="border-t border-border/50">
<div className="flex w-full items-center gap-2.5 px-4 py-2.5">
<button <button
type="button" type="button"
className="flex w-full items-center gap-2.5 px-4 py-2.5 text-left transition-colors hover:bg-muted-foreground/[0.06] active:bg-muted-foreground/[0.1]" className="flex flex-1 items-center gap-2.5 text-left cursor-pointer"
onClick={() => setConnectorDialogOpen(true)} onClick={() => setConnectorDialogOpen(true)}
> >
<Unplug className="size-4 text-muted-foreground/70 shrink-0" /> <Unplug className="size-4 text-muted-foreground shrink-0" />
<span className="text-[13px] text-muted-foreground/80 flex-1">Connect your tools</span> <span className="text-[13px] text-muted-foreground/80 flex-1">Connect your tools</span>
<AvatarGroup className="shrink-0"> <AvatarGroup className="shrink-0">
{BANNER_CONNECTORS.map(({ type, label }, i) => ( {BANNER_CONNECTORS.map(({ type }, i) => (
<Avatar key={type} className="size-6" style={{ zIndex: BANNER_CONNECTORS.length - i }}> <Avatar
key={type}
className="size-6"
style={{ zIndex: BANNER_CONNECTORS.length - i }}
>
<AvatarFallback className="bg-muted text-[10px]"> <AvatarFallback className="bg-muted text-[10px]">
{getConnectorIcon(type, "size-3.5")} {getConnectorIcon(type, "size-3.5")}
</AvatarFallback> </AvatarFallback>
</Avatar> </Avatar>
))} ))}
</AvatarGroup> </AvatarGroup>
</button>
<button <button
type="button" type="button"
onClick={handleDismiss} onClick={handleDismiss}
className="shrink-0 ml-0.5 p-0.5 text-muted-foreground/40 hover:text-foreground transition-colors" className="shrink-0 ml-0.5 p-1.5 -mr-1 text-muted-foreground/40 hover:text-foreground transition-colors cursor-pointer"
aria-label="Dismiss" aria-label="Dismiss"
> >
<X className="size-3.5" /> <X className="size-3.5 text-muted-foreground" />
</button>
</button> </button>
</div> </div>
</div>
); );
}; };
@ -589,7 +599,46 @@ const ComposerAction: FC<ComposerActionProps> = ({ isBlockedByOtherUser = false
const disabledTools = useAtomValue(disabledToolsAtom); const disabledTools = useAtomValue(disabledToolsAtom);
const toggleTool = useSetAtom(toggleToolAtom); const toggleTool = useSetAtom(toggleToolAtom);
const hydrateDisabled = useSetAtom(hydrateDisabledToolsAtom); const hydrateDisabled = useSetAtom(hydrateDisabledToolsAtom);
const enabledCount = useAtomValue(enabledToolCountAtom);
const hasWebSearchTool = agentTools?.some((t) => t.name === "web_search") ?? false;
const isWebSearchEnabled = hasWebSearchTool && !disabledTools.includes("web_search");
const filteredTools = useMemo(
() => agentTools?.filter((t) => t.name !== "web_search"),
[agentTools]
);
const filteredEnabledCount = useMemo(() => {
if (!filteredTools) return 0;
return (
filteredTools.length -
disabledTools.filter((d) => filteredTools.some((t) => t.name === d)).length
);
}, [filteredTools, disabledTools]);
const groupedTools = useMemo(() => {
if (!filteredTools) return [];
const toolsByName = new Map(filteredTools.map((t) => [t.name, t]));
const result: { label: string; tools: typeof filteredTools }[] = [];
const placed = new Set<string>();
for (const group of TOOL_GROUPS) {
const matched = group.tools.flatMap((name) => {
const tool = toolsByName.get(name);
if (!tool) return [];
placed.add(name);
return [tool];
});
if (matched.length > 0) {
result.push({ label: group.label, tools: matched });
}
}
const ungrouped = filteredTools.filter((t) => !placed.has(t.name));
if (ungrouped.length > 0) {
result.push({ label: "Other", tools: ungrouped });
}
return result;
}, [filteredTools]);
useEffect(() => { useEffect(() => {
hydrateDisabled(); hydrateDisabled();
@ -625,7 +674,7 @@ const ComposerAction: FC<ComposerActionProps> = ({ isBlockedByOtherUser = false
<Plus className="size-4" /> <Plus className="size-4" />
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent side="top" align="start" sideOffset={8}> <DropdownMenuContent side="bottom" align="start" sideOffset={8}>
<DropdownMenuItem onSelect={() => setToolsPopoverOpen(true)}> <DropdownMenuItem onSelect={() => setToolsPopoverOpen(true)}>
<Settings2 className="size-4" /> <Settings2 className="size-4" />
Manage Tools Manage Tools
@ -642,17 +691,24 @@ const ComposerAction: FC<ComposerActionProps> = ({ isBlockedByOtherUser = false
<div className="flex items-center justify-between px-4 py-2"> <div className="flex items-center justify-between px-4 py-2">
<DrawerTitle className="text-sm font-medium">Agent Tools</DrawerTitle> <DrawerTitle className="text-sm font-medium">Agent Tools</DrawerTitle>
<span className="text-xs text-muted-foreground"> <span className="text-xs text-muted-foreground">
{enabledCount}/{agentTools?.length ?? 0} enabled {filteredEnabledCount}/{filteredTools?.length ?? 0} enabled
</span> </span>
</div> </div>
<div className="overflow-y-auto pb-6" onScroll={handleToolsScroll}> <div className="overflow-y-auto pb-6" onScroll={handleToolsScroll}>
{agentTools?.map((tool) => { {groupedTools.map((group) => (
<div key={group.label}>
<div className="px-4 pt-3 pb-1 text-xs text-muted-foreground/80 font-medium select-none">
{group.label}
</div>
{group.tools.map((tool) => {
const isDisabled = disabledTools.includes(tool.name); const isDisabled = disabledTools.includes(tool.name);
const ToolIcon = getToolIcon(tool.name);
return ( return (
<div <div
key={tool.name} key={tool.name}
className="flex w-full items-center gap-3 px-4 py-2 hover:bg-muted-foreground/10 transition-colors" className="flex w-full items-center gap-3 px-4 py-2 hover:bg-muted-foreground/10 transition-colors"
> >
<ToolIcon className="size-4 shrink-0 text-muted-foreground" />
<span className="flex-1 min-w-0 text-sm font-medium truncate"> <span className="flex-1 min-w-0 text-sm font-medium truncate">
{formatToolName(tool.name)} {formatToolName(tool.name)}
</span> </span>
@ -664,7 +720,9 @@ const ComposerAction: FC<ComposerActionProps> = ({ isBlockedByOtherUser = false
</div> </div>
); );
})} })}
{!agentTools?.length && ( </div>
))}
{!filteredTools?.length && (
<div className="px-4 py-6 text-center text-sm text-muted-foreground"> <div className="px-4 py-6 text-center text-sm text-muted-foreground">
Loading tools... Loading tools...
</div> </div>
@ -688,6 +746,7 @@ const ComposerAction: FC<ComposerActionProps> = ({ isBlockedByOtherUser = false
<TooltipIconButton <TooltipIconButton
tooltip="Manage tools" tooltip="Manage tools"
side="bottom" side="bottom"
disableTooltip={toolsPopoverOpen}
variant="ghost" variant="ghost"
size="icon" size="icon"
className="size-[34px] rounded-full p-1 font-semibold text-xs hover:bg-muted-foreground/15 dark:border-muted-foreground/15 dark:hover:bg-muted-foreground/30" className="size-[34px] rounded-full p-1 font-semibold text-xs hover:bg-muted-foreground/15 dark:border-muted-foreground/15 dark:hover:bg-muted-foreground/30"
@ -707,7 +766,7 @@ const ComposerAction: FC<ComposerActionProps> = ({ isBlockedByOtherUser = false
<div className="flex items-center justify-between px-2.5 py-2 sm:px-3 sm:py-2.5 border-b"> <div className="flex items-center justify-between px-2.5 py-2 sm:px-3 sm:py-2.5 border-b">
<span className="text-xs sm:text-sm font-medium">Agent Tools</span> <span className="text-xs sm:text-sm font-medium">Agent Tools</span>
<span className="text-[10px] sm:text-xs text-muted-foreground"> <span className="text-[10px] sm:text-xs text-muted-foreground">
{enabledCount}/{agentTools?.length ?? 0} enabled {filteredEnabledCount}/{filteredTools?.length ?? 0} enabled
</span> </span>
</div> </div>
<div <div
@ -718,10 +777,17 @@ const ComposerAction: FC<ComposerActionProps> = ({ isBlockedByOtherUser = false
WebkitMaskImage: `linear-gradient(to bottom, ${toolsScrollPos === "top" ? "black" : "transparent"}, black 16px, black calc(100% - 16px), ${toolsScrollPos === "bottom" ? "black" : "transparent"})`, WebkitMaskImage: `linear-gradient(to bottom, ${toolsScrollPos === "top" ? "black" : "transparent"}, black 16px, black calc(100% - 16px), ${toolsScrollPos === "bottom" ? "black" : "transparent"})`,
}} }}
> >
{agentTools?.map((tool) => { {groupedTools.map((group) => (
<div key={group.label}>
<div className="px-2.5 sm:px-3 pt-2 pb-0.5 text-[10px] sm:text-xs text-muted-foreground/80 font-normal select-none">
{group.label}
</div>
{group.tools.map((tool) => {
const isDisabled = disabledTools.includes(tool.name); const isDisabled = disabledTools.includes(tool.name);
const ToolIcon = getToolIcon(tool.name);
const row = ( const row = (
<div className="flex w-full items-center gap-2 sm:gap-3 px-2.5 sm:px-3 py-1 sm:py-1.5 hover:bg-muted-foreground/10 transition-colors"> <div className="flex w-full items-center gap-2 sm:gap-3 px-2.5 sm:px-3 py-1 sm:py-1.5 hover:bg-muted-foreground/10 transition-colors">
<ToolIcon className="size-3.5 sm:size-4 shrink-0 text-muted-foreground" />
<span className="flex-1 min-w-0 text-xs sm:text-sm font-medium truncate"> <span className="flex-1 min-w-0 text-xs sm:text-sm font-medium truncate">
{formatToolName(tool.name)} {formatToolName(tool.name)}
</span> </span>
@ -741,7 +807,9 @@ const ComposerAction: FC<ComposerActionProps> = ({ isBlockedByOtherUser = false
</Tooltip> </Tooltip>
); );
})} })}
{!agentTools?.length && ( </div>
))}
{!filteredTools?.length && (
<div className="px-3 py-4 text-center text-xs text-muted-foreground"> <div className="px-3 py-4 text-center text-xs text-muted-foreground">
Loading tools... Loading tools...
</div> </div>
@ -750,6 +818,46 @@ const ComposerAction: FC<ComposerActionProps> = ({ isBlockedByOtherUser = false
</PopoverContent> </PopoverContent>
</Popover> </Popover>
)} )}
{hasWebSearchTool && (
<button
type="button"
onClick={() => toggleTool("web_search")}
className={cn(
"rounded-full transition-all flex items-center gap-1 px-2 py-1 border h-8 select-none",
isWebSearchEnabled
? "bg-sky-500/15 border-sky-500/60 text-sky-500"
: "bg-transparent border-transparent text-muted-foreground hover:text-foreground"
)}
>
<motion.div
animate={{
rotate: isWebSearchEnabled ? 360 : 0,
scale: isWebSearchEnabled ? 1.1 : 1,
}}
whileHover={{
rotate: isWebSearchEnabled ? 360 : 15,
scale: 1.1,
transition: { type: "spring", stiffness: 300, damping: 10 },
}}
transition={{ type: "spring", stiffness: 260, damping: 25 }}
>
<Globe className="size-4" />
</motion.div>
<AnimatePresence>
{isWebSearchEnabled && (
<motion.span
initial={{ width: 0, opacity: 0 }}
animate={{ width: "auto", opacity: 1 }}
exit={{ width: 0, opacity: 0 }}
transition={{ duration: 0.2 }}
className="text-xs overflow-hidden whitespace-nowrap"
>
Search
</motion.span>
)}
</AnimatePresence>
</button>
)}
{sidebarDocs.length > 0 && ( {sidebarDocs.length > 0 && (
<button <button
type="button" type="button"
@ -823,6 +931,21 @@ function formatToolName(name: string): string {
.join(" "); .join(" ");
} }
const TOOL_GROUPS: { label: string; tools: string[] }[] = [
{
label: "Research",
tools: ["search_knowledge_base", "search_surfsense_docs", "scrape_webpage", "link_preview"],
},
{
label: "Generate",
tools: ["generate_podcast", "generate_report", "generate_image", "display_image"],
},
{
label: "Memory",
tools: ["save_memory", "recall_memory"],
},
];
const MessageError: FC = () => { const MessageError: FC = () => {
return ( return (
<MessagePrimitive.Error> <MessagePrimitive.Error>

View file

@ -4,17 +4,22 @@ import { Slottable } from "@radix-ui/react-slot";
import { type ComponentPropsWithRef, forwardRef, type ReactNode } from "react"; import { type ComponentPropsWithRef, forwardRef, type ReactNode } from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { useMediaQuery } from "@/hooks/use-media-query";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
export type TooltipIconButtonProps = ComponentPropsWithRef<typeof Button> & { export type TooltipIconButtonProps = ComponentPropsWithRef<typeof Button> & {
tooltip: ReactNode; tooltip: ReactNode;
side?: "top" | "bottom" | "left" | "right"; side?: "top" | "bottom" | "left" | "right";
disableTooltip?: boolean;
}; };
export const TooltipIconButton = forwardRef<HTMLButtonElement, TooltipIconButtonProps>( export const TooltipIconButton = forwardRef<HTMLButtonElement, TooltipIconButtonProps>(
({ children, tooltip, side = "bottom", className, ...rest }, ref) => { ({ children, tooltip, side = "bottom", className, disableTooltip, ...rest }, ref) => {
const isTouchDevice = useMediaQuery("(pointer: coarse)");
const suppressTooltip = disableTooltip || isTouchDevice;
return ( return (
<Tooltip> <Tooltip open={suppressTooltip ? false : undefined}>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Button <Button
variant="ghost" variant="ghost"

View file

@ -0,0 +1,295 @@
"use client";
import { useAtomValue, useSetAtom } from "jotai";
import { AlertCircle, XIcon } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import { toast } from "sonner";
import { closeEditorPanelAtom, editorPanelAtom } from "@/atoms/editor/editor-panel.atom";
import { PlateEditor } from "@/components/editor/plate-editor";
import { Button } from "@/components/ui/button";
import { Drawer, DrawerContent, DrawerHandle, DrawerTitle } from "@/components/ui/drawer";
import { useMediaQuery } from "@/hooks/use-media-query";
import { authenticatedFetch, getBearerToken, redirectToLogin } from "@/lib/auth-utils";
interface EditorContent {
document_id: number;
title: string;
document_type?: string;
source_markdown: string;
}
function EditorPanelSkeleton() {
return (
<div className="space-y-6 p-6">
<div className="h-6 w-3/4 rounded-md bg-muted/60 animate-pulse" />
<div className="space-y-2.5">
<div className="h-3 w-full rounded-md bg-muted/60 animate-pulse" />
<div className="h-3 w-[95%] rounded-md bg-muted/60 animate-pulse [animation-delay:100ms]" />
<div className="h-3 w-[88%] rounded-md bg-muted/60 animate-pulse [animation-delay:200ms]" />
<div className="h-3 w-[60%] rounded-md bg-muted/60 animate-pulse [animation-delay:300ms]" />
</div>
<div className="h-5 w-2/5 rounded-md bg-muted/60 animate-pulse [animation-delay:400ms]" />
<div className="space-y-2.5">
<div className="h-3 w-full rounded-md bg-muted/60 animate-pulse [animation-delay:500ms]" />
<div className="h-3 w-[92%] rounded-md bg-muted/60 animate-pulse [animation-delay:600ms]" />
<div className="h-3 w-[75%] rounded-md bg-muted/60 animate-pulse [animation-delay:700ms]" />
</div>
</div>
);
}
export function EditorPanelContent({
documentId,
searchSpaceId,
title,
onClose,
}: {
documentId: number;
searchSpaceId: number;
title: string | null;
onClose?: () => void;
}) {
const [editorDoc, setEditorDoc] = useState<EditorContent | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const [editedMarkdown, setEditedMarkdown] = useState<string | null>(null);
const markdownRef = useRef<string>("");
const initialLoadDone = useRef(false);
const changeCountRef = useRef(0);
const [displayTitle, setDisplayTitle] = useState(title || "Untitled");
useEffect(() => {
let cancelled = false;
setIsLoading(true);
setError(null);
setEditorDoc(null);
setEditedMarkdown(null);
initialLoadDone.current = false;
changeCountRef.current = 0;
const fetchContent = async () => {
const token = getBearerToken();
if (!token) {
redirectToLogin();
return;
}
try {
const response = await authenticatedFetch(
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/search-spaces/${searchSpaceId}/documents/${documentId}/editor-content`,
{ method: "GET" }
);
if (cancelled) return;
if (!response.ok) {
const errorData = await response
.json()
.catch(() => ({ detail: "Failed to fetch document" }));
throw new Error(errorData.detail || "Failed to fetch document");
}
const data = await response.json();
if (data.source_markdown === undefined || data.source_markdown === null) {
setError(
"This document does not have editable content. Please re-upload to enable editing."
);
setIsLoading(false);
return;
}
markdownRef.current = data.source_markdown;
setDisplayTitle(data.title || title || "Untitled");
setEditorDoc(data);
initialLoadDone.current = true;
} catch (err) {
if (cancelled) return;
console.error("Error fetching document:", err);
setError(err instanceof Error ? err.message : "Failed to fetch document");
} finally {
if (!cancelled) setIsLoading(false);
}
};
fetchContent();
return () => {
cancelled = true;
};
}, [documentId, searchSpaceId, title]);
const handleMarkdownChange = useCallback((md: string) => {
markdownRef.current = md;
if (!initialLoadDone.current) return;
changeCountRef.current += 1;
if (changeCountRef.current <= 1) return;
setEditedMarkdown(md);
}, []);
const handleSave = useCallback(async () => {
const token = getBearerToken();
if (!token) {
toast.error("Please login to save");
redirectToLogin();
return;
}
setSaving(true);
try {
const response = await authenticatedFetch(
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/search-spaces/${searchSpaceId}/documents/${documentId}/save`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ source_markdown: markdownRef.current }),
}
);
if (!response.ok) {
const errorData = await response
.json()
.catch(() => ({ detail: "Failed to save document" }));
throw new Error(errorData.detail || "Failed to save document");
}
setEditorDoc((prev) => (prev ? { ...prev, source_markdown: markdownRef.current } : prev));
setEditedMarkdown(null);
toast.success("Document saved! Reindexing in background...");
} catch (err) {
console.error("Error saving document:", err);
toast.error(err instanceof Error ? err.message : "Failed to save document");
} finally {
setSaving(false);
}
}, [documentId, searchSpaceId]);
return (
<>
<div className="flex items-center justify-between px-4 py-2 shrink-0 border-b">
<div className="flex-1 min-w-0">
<h2 className="text-sm font-semibold truncate">{displayTitle}</h2>
{editedMarkdown !== null && (
<p className="text-[10px] text-muted-foreground">Unsaved changes</p>
)}
</div>
{onClose && (
<Button variant="ghost" size="icon" onClick={onClose} className="size-7 shrink-0">
<XIcon className="size-4" />
<span className="sr-only">Close editor panel</span>
</Button>
)}
</div>
<div className="flex-1 overflow-hidden">
{isLoading ? (
<EditorPanelSkeleton />
) : error || !editorDoc ? (
<div className="flex flex-1 flex-col items-center justify-center gap-3 p-6 text-center">
<AlertCircle className="size-8 text-destructive" />
<div>
<p className="font-medium text-foreground">Failed to load document</p>
<p className="text-sm text-red-500 mt-1">{error || "An unknown error occurred"}</p>
</div>
</div>
) : (
<PlateEditor
key={documentId}
preset="full"
markdown={editorDoc.source_markdown}
onMarkdownChange={handleMarkdownChange}
readOnly={false}
placeholder="Start writing..."
editorVariant="default"
onSave={handleSave}
hasUnsavedChanges={editedMarkdown !== null}
isSaving={saving}
defaultEditing={true}
className="[&_[role=toolbar]]:!bg-sidebar"
/>
)}
</div>
</>
);
}
function DesktopEditorPanel() {
const panelState = useAtomValue(editorPanelAtom);
const closePanel = useSetAtom(closeEditorPanelAtom);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") closePanel();
};
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [closePanel]);
if (!panelState.isOpen || !panelState.documentId || !panelState.searchSpaceId) return null;
return (
<div className="flex w-[50%] max-w-[700px] min-w-[380px] flex-col border-l bg-sidebar text-sidebar-foreground animate-in slide-in-from-right-4 duration-300 ease-out">
<EditorPanelContent
documentId={panelState.documentId}
searchSpaceId={panelState.searchSpaceId}
title={panelState.title}
onClose={closePanel}
/>
</div>
);
}
function MobileEditorDrawer() {
const panelState = useAtomValue(editorPanelAtom);
const closePanel = useSetAtom(closeEditorPanelAtom);
if (!panelState.documentId || !panelState.searchSpaceId) return null;
return (
<Drawer
open={panelState.isOpen}
onOpenChange={(open) => {
if (!open) closePanel();
}}
shouldScaleBackground={false}
>
<DrawerContent
className="h-[95vh] max-h-[95vh] z-80 bg-sidebar overflow-hidden"
overlayClassName="z-80"
>
<DrawerHandle />
<DrawerTitle className="sr-only">{panelState.title || "Editor"}</DrawerTitle>
<div className="min-h-0 flex-1 flex flex-col overflow-hidden">
<EditorPanelContent
documentId={panelState.documentId}
searchSpaceId={panelState.searchSpaceId}
title={panelState.title}
/>
</div>
</DrawerContent>
</Drawer>
);
}
export function EditorPanel() {
const panelState = useAtomValue(editorPanelAtom);
const isDesktop = useMediaQuery("(min-width: 1024px)");
if (!panelState.isOpen || !panelState.documentId) return null;
if (isDesktop) {
return <DesktopEditorPanel />;
}
return <MobileEditorDrawer />;
}
export function MobileEditorPanel() {
const panelState = useAtomValue(editorPanelAtom);
const isDesktop = useMediaQuery("(min-width: 1024px)");
if (isDesktop || !panelState.isOpen || !panelState.documentId) return null;
return <MobileEditorDrawer />;
}

View file

@ -272,17 +272,19 @@ function NavbarGitHubStars({
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className={cn( className={cn(
"group flex items-center gap-1.5 rounded-full px-3 py-1.5 hover:bg-gray-100 dark:hover:bg-neutral-800 transition-colors", "group flex items-center gap-1.5 rounded-lg px-1 py-1 hover:bg-gray-100 dark:hover:bg-neutral-800/50 transition-colors",
className className
)} )}
> >
<IconBrandGithub className="h-5 w-5 text-neutral-600 dark:text-neutral-300 shrink-0" /> <IconBrandGithub className="h-5 w-5 text-neutral-600 dark:text-neutral-300 shrink-0" />
<span className="rounded-lg bg-[#282828] px-2 py-0 group-hover:bg-neutral-800/80 transition-colors inline-flex items-center">
<AnimatedStarCount <AnimatedStarCount
value={isLoading ? 10000 : stars} value={isLoading ? 10000 : stars}
itemSize={ITEM_SIZE} itemSize={ITEM_SIZE}
isRolling={isLoading} isRolling={isLoading}
className="text-sm font-semibold tabular-nums text-neutral-600 dark:text-neutral-400 group-hover:text-neutral-800 dark:group-hover:text-neutral-200 transition-colors" className="text-sm font-semibold tabular-nums text-neutral-500 group-hover:text-neutral-400 transition-colors"
/> />
</span>
</a> </a>
); );
} }

View file

@ -3,6 +3,7 @@
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useAtom, useAtomValue, useSetAtom } from "jotai"; import { useAtom, useAtomValue, useSetAtom } from "jotai";
import { AlertTriangle, Inbox, Megaphone, SquareLibrary } from "lucide-react"; import { AlertTriangle, Inbox, Megaphone, SquareLibrary } from "lucide-react";
import { Spinner } from "@/components/ui/spinner";
import { useParams, usePathname, useRouter } from "next/navigation"; import { useParams, usePathname, useRouter } from "next/navigation";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useTheme } from "next-themes"; import { useTheme } from "next-themes";
@ -14,6 +15,12 @@ import { statusInboxItemsAtom } from "@/atoms/inbox/status-inbox.atom";
import { rightPanelCollapsedAtom } from "@/atoms/layout/right-panel.atom"; import { rightPanelCollapsedAtom } from "@/atoms/layout/right-panel.atom";
import { deleteSearchSpaceMutationAtom } from "@/atoms/search-spaces/search-space-mutation.atoms"; import { deleteSearchSpaceMutationAtom } from "@/atoms/search-spaces/search-space-mutation.atoms";
import { searchSpacesAtom } from "@/atoms/search-spaces/search-space-query.atoms"; import { searchSpacesAtom } from "@/atoms/search-spaces/search-space-query.atoms";
import {
morePagesDialogAtom,
searchSpaceSettingsDialogAtom,
teamDialogAtom,
userSettingsDialogAtom,
} from "@/atoms/settings/settings-dialog.atoms";
import { currentUserAtom } from "@/atoms/user/user-query.atoms"; import { currentUserAtom } from "@/atoms/user/user-query.atoms";
import { import {
AlertDialog, AlertDialog,
@ -35,7 +42,7 @@ import {
DialogTitle, DialogTitle,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { isPageLimitExceededMetadata } from "@/contracts/types/inbox.types";
import { useAnnouncements } from "@/hooks/use-announcements"; import { useAnnouncements } from "@/hooks/use-announcements";
import { useDocumentsProcessing } from "@/hooks/use-documents-processing"; import { useDocumentsProcessing } from "@/hooks/use-documents-processing";
import { useInbox } from "@/hooks/use-inbox"; import { useInbox } from "@/hooks/use-inbox";
@ -47,6 +54,10 @@ import { deleteThread, fetchThreads, updateThread } from "@/lib/chat/thread-pers
import { cleanupElectric } from "@/lib/electric/client"; import { cleanupElectric } from "@/lib/electric/client";
import { resetUser, trackLogout } from "@/lib/posthog/events"; import { resetUser, trackLogout } from "@/lib/posthog/events";
import { cacheKeys } from "@/lib/query-client/cache-keys"; import { cacheKeys } from "@/lib/query-client/cache-keys";
import { MorePagesDialog } from "@/components/settings/more-pages-dialog";
import { SearchSpaceSettingsDialog } from "@/components/settings/search-space-settings-dialog";
import { TeamDialog } from "@/components/settings/team-dialog";
import { UserSettingsDialog } from "@/components/settings/user-settings-dialog";
import type { ChatItem, NavItem, SearchSpace } from "../types/layout.types"; import type { ChatItem, NavItem, SearchSpace } from "../types/layout.types";
import { CreateSearchSpaceDialog } from "../ui/dialogs"; import { CreateSearchSpaceDialog } from "../ui/dialogs";
import { LayoutShell } from "../ui/shell"; import { LayoutShell } from "../ui/shell";
@ -193,6 +204,8 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
const seenPageLimitNotifications = useRef<Set<number>>(new Set()); const seenPageLimitNotifications = useRef<Set<number>>(new Set());
const isInitialLoad = useRef(true); const isInitialLoad = useRef(true);
const setMorePagesOpen = useSetAtom(morePagesDialogAtom);
// Effect to show toast for new page_limit_exceeded notifications // Effect to show toast for new page_limit_exceeded notifications
useEffect(() => { useEffect(() => {
if (statusInbox.loading) return; if (statusInbox.loading) return;
@ -216,21 +229,17 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
for (const notification of newNotifications) { for (const notification of newNotifications) {
seenPageLimitNotifications.current.add(notification.id); seenPageLimitNotifications.current.add(notification.id);
const actionUrl = isPageLimitExceededMetadata(notification.metadata)
? notification.metadata.action_url
: `/dashboard/${searchSpaceId}/more-pages`;
toast.error(notification.title, { toast.error(notification.title, {
description: notification.message, description: notification.message,
duration: 8000, duration: 8000,
icon: <AlertTriangle className="h-5 w-5 text-amber-500" />, icon: <AlertTriangle className="h-5 w-5 text-amber-500" />,
action: { action: {
label: "View Plans", label: "View Plans",
onClick: () => router.push(actionUrl), onClick: () => setMorePagesOpen(true),
}, },
}); });
} }
}, [statusInbox.inboxItems, statusInbox.loading, searchSpaceId, router]); }, [statusInbox.inboxItems, statusInbox.loading, searchSpaceId, setMorePagesOpen]);
// Delete dialogs state // Delete dialogs state
const [showDeleteChatDialog, setShowDeleteChatDialog] = useState(false); const [showDeleteChatDialog, setShowDeleteChatDialog] = useState(false);
@ -390,15 +399,19 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
setIsCreateSearchSpaceDialogOpen(true); setIsCreateSearchSpaceDialogOpen(true);
}, []); }, []);
const setUserSettingsDialog = useSetAtom(userSettingsDialogAtom);
const setSearchSpaceSettingsDialog = useSetAtom(searchSpaceSettingsDialogAtom);
const setTeamDialogOpen = useSetAtom(teamDialogAtom);
const handleUserSettings = useCallback(() => { const handleUserSettings = useCallback(() => {
router.push(`/dashboard/${searchSpaceId}/user-settings?tab=profile`); setUserSettingsDialog({ open: true, initialTab: "profile" });
}, [router, searchSpaceId]); }, [setUserSettingsDialog]);
const handleSearchSpaceSettings = useCallback( const handleSearchSpaceSettings = useCallback(
(space: SearchSpace) => { (_space: SearchSpace) => {
router.push(`/dashboard/${space.id}/settings?tab=general`); setSearchSpaceSettingsDialog({ open: true, initialTab: "general" });
}, },
[router] [setSearchSpaceSettingsDialog]
); );
const handleSearchSpaceDeleteClick = useCallback((space: SearchSpace) => { const handleSearchSpaceDeleteClick = useCallback((space: SearchSpace) => {
@ -582,12 +595,12 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
); );
const handleSettings = useCallback(() => { const handleSettings = useCallback(() => {
router.push(`/dashboard/${searchSpaceId}/settings?tab=general`); setSearchSpaceSettingsDialog({ open: true, initialTab: "general" });
}, [router, searchSpaceId]); }, [setSearchSpaceSettingsDialog]);
const handleManageMembers = useCallback(() => { const handleManageMembers = useCallback(() => {
router.push(`/dashboard/${searchSpaceId}/team`); setTeamDialogOpen(true);
}, [router, searchSpaceId]); }, [setTeamDialogOpen]);
const handleLogout = useCallback(async () => { const handleLogout = useCallback(async () => {
try { try {
@ -616,17 +629,25 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
}, [router]); }, [router]);
const handleViewAllSharedChats = useCallback(() => { const handleViewAllSharedChats = useCallback(() => {
setIsAllSharedChatsSidebarOpen(true); setIsAllSharedChatsSidebarOpen((prev) => {
if (!prev) {
setIsAllPrivateChatsSidebarOpen(false); setIsAllPrivateChatsSidebarOpen(false);
setIsInboxSidebarOpen(false); setIsInboxSidebarOpen(false);
setIsAnnouncementsSidebarOpen(false); setIsAnnouncementsSidebarOpen(false);
}
return !prev;
});
}, []); }, []);
const handleViewAllPrivateChats = useCallback(() => { const handleViewAllPrivateChats = useCallback(() => {
setIsAllPrivateChatsSidebarOpen(true); setIsAllPrivateChatsSidebarOpen((prev) => {
if (!prev) {
setIsAllSharedChatsSidebarOpen(false); setIsAllSharedChatsSidebarOpen(false);
setIsInboxSidebarOpen(false); setIsInboxSidebarOpen(false);
setIsAnnouncementsSidebarOpen(false); setIsAnnouncementsSidebarOpen(false);
}
return !prev;
});
}, []); }, []);
// Delete handlers // Delete handlers
@ -803,7 +824,7 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
> >
{isDeletingChat ? ( {isDeletingChat ? (
<> <>
<span className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" /> <Spinner size="sm" />
{t("deleting")} {t("deleting")}
</> </>
) : ( ) : (
@ -934,6 +955,12 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
open={isCreateSearchSpaceDialogOpen} open={isCreateSearchSpaceDialogOpen}
onOpenChange={setIsCreateSearchSpaceDialogOpen} onOpenChange={setIsCreateSearchSpaceDialogOpen}
/> />
{/* Settings Dialogs */}
<SearchSpaceSettingsDialog searchSpaceId={Number(searchSpaceId)} />
<UserSettingsDialog />
<TeamDialog searchSpaceId={Number(searchSpaceId)} />
<MorePagesDialog />
</> </>
); );
} }

View file

@ -54,7 +54,7 @@ export function Header({ mobileMenuTrigger }: HeaderProps) {
const showExpandButton = !isMobile && collapsed && hasRightPanelContent; const showExpandButton = !isMobile && collapsed && hasRightPanelContent;
return ( return (
<header className="sticky top-0 z-10 flex h-14 shrink-0 items-center gap-2 bg-background/95 backdrop-blur supports-backdrop-filter:bg-background/60 px-4"> <header className="sticky top-0 z-10 flex h-14 shrink-0 items-center gap-2 bg-main-panel/95 backdrop-blur supports-backdrop-filter:bg-main-panel/60 px-4">
{/* Left side - Mobile menu trigger + Model selector */} {/* Left side - Mobile menu trigger + Model selector */}
<div className="flex flex-1 items-center gap-2 min-w-0"> <div className="flex flex-1 items-center gap-2 min-w-0">
{mobileMenuTrigger} {mobileMenuTrigger}

View file

@ -5,7 +5,9 @@ import { PanelRight, PanelRightClose } from "lucide-react";
import { startTransition, useEffect } from "react"; import { startTransition, useEffect } from "react";
import { closeReportPanelAtom, reportPanelAtom } from "@/atoms/chat/report-panel.atom"; import { closeReportPanelAtom, reportPanelAtom } from "@/atoms/chat/report-panel.atom";
import { documentsSidebarOpenAtom } from "@/atoms/documents/ui.atoms"; import { documentsSidebarOpenAtom } from "@/atoms/documents/ui.atoms";
import { closeEditorPanelAtom, editorPanelAtom } from "@/atoms/editor/editor-panel.atom";
import { rightPanelCollapsedAtom, rightPanelTabAtom } from "@/atoms/layout/right-panel.atom"; import { rightPanelCollapsedAtom, rightPanelTabAtom } from "@/atoms/layout/right-panel.atom";
import { EditorPanelContent } from "@/components/editor-panel/editor-panel";
import { ReportPanelContent } from "@/components/report-panel/report-panel"; import { ReportPanelContent } from "@/components/report-panel/report-panel";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
@ -41,8 +43,10 @@ export function RightPanelExpandButton() {
const [collapsed, setCollapsed] = useAtom(rightPanelCollapsedAtom); const [collapsed, setCollapsed] = useAtom(rightPanelCollapsedAtom);
const documentsOpen = useAtomValue(documentsSidebarOpenAtom); const documentsOpen = useAtomValue(documentsSidebarOpenAtom);
const reportState = useAtomValue(reportPanelAtom); const reportState = useAtomValue(reportPanelAtom);
const editorState = useAtomValue(editorPanelAtom);
const reportOpen = reportState.isOpen && !!reportState.reportId; const reportOpen = reportState.isOpen && !!reportState.reportId;
const hasContent = documentsOpen || reportOpen; const editorOpen = editorState.isOpen && !!editorState.documentId;
const hasContent = documentsOpen || reportOpen || editorOpen;
if (!collapsed || !hasContent) return null; if (!collapsed || !hasContent) return null;
@ -66,34 +70,42 @@ export function RightPanelExpandButton() {
); );
} }
const PANEL_WIDTHS = { sources: 420, report: 640 } as const; const PANEL_WIDTHS = { sources: 420, report: 640, editor: 640 } as const;
export function RightPanel({ documentsPanel }: RightPanelProps) { export function RightPanel({ documentsPanel }: RightPanelProps) {
const [activeTab] = useAtom(rightPanelTabAtom); const [activeTab] = useAtom(rightPanelTabAtom);
const reportState = useAtomValue(reportPanelAtom); const reportState = useAtomValue(reportPanelAtom);
const closeReport = useSetAtom(closeReportPanelAtom); const closeReport = useSetAtom(closeReportPanelAtom);
const editorState = useAtomValue(editorPanelAtom);
const closeEditor = useSetAtom(closeEditorPanelAtom);
const [collapsed, setCollapsed] = useAtom(rightPanelCollapsedAtom); const [collapsed, setCollapsed] = useAtom(rightPanelCollapsedAtom);
const documentsOpen = documentsPanel?.open ?? false; const documentsOpen = documentsPanel?.open ?? false;
const reportOpen = reportState.isOpen && !!reportState.reportId; const reportOpen = reportState.isOpen && !!reportState.reportId;
const editorOpen = editorState.isOpen && !!editorState.documentId;
useEffect(() => { useEffect(() => {
if (!reportOpen) return; if (!reportOpen && !editorOpen) return;
const handleKeyDown = (e: KeyboardEvent) => { const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") closeReport(); if (e.key === "Escape") {
if (editorOpen) closeEditor();
else if (reportOpen) closeReport();
}
}; };
document.addEventListener("keydown", handleKeyDown); document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown); return () => document.removeEventListener("keydown", handleKeyDown);
}, [reportOpen, closeReport]); }, [reportOpen, editorOpen, closeReport, closeEditor]);
const isVisible = (documentsOpen || reportOpen) && !collapsed; const isVisible = (documentsOpen || reportOpen || editorOpen) && !collapsed;
const effectiveTab = let effectiveTab = activeTab;
activeTab === "report" && !reportOpen if (effectiveTab === "editor" && !editorOpen) {
? "sources" effectiveTab = reportOpen ? "report" : "sources";
: activeTab === "sources" && !documentsOpen } else if (effectiveTab === "report" && !reportOpen) {
? "report" effectiveTab = editorOpen ? "editor" : "sources";
: activeTab; } else if (effectiveTab === "sources" && !documentsOpen) {
effectiveTab = editorOpen ? "editor" : reportOpen ? "report" : "sources";
}
const targetWidth = PANEL_WIDTHS[effectiveTab]; const targetWidth = PANEL_WIDTHS[effectiveTab];
const collapseButton = <CollapseButton onClick={() => setCollapsed(true)} />; const collapseButton = <CollapseButton onClick={() => setCollapsed(true)} />;
@ -103,7 +115,7 @@ export function RightPanel({ documentsPanel }: RightPanelProps) {
return ( return (
<aside <aside
style={{ width: targetWidth }} style={{ width: targetWidth }}
className="flex h-full shrink-0 flex-col border-l bg-background overflow-hidden transition-[width] duration-200 ease-out" className="flex h-full shrink-0 flex-col rounded-xl border bg-sidebar text-sidebar-foreground overflow-hidden transition-[width] duration-200 ease-out"
> >
<div className="relative flex-1 min-h-0 overflow-hidden"> <div className="relative flex-1 min-h-0 overflow-hidden">
{effectiveTab === "sources" && documentsOpen && documentsPanel && ( {effectiveTab === "sources" && documentsOpen && documentsPanel && (
@ -117,15 +129,25 @@ export function RightPanel({ documentsPanel }: RightPanelProps) {
</div> </div>
)} )}
{effectiveTab === "report" && reportOpen && ( {effectiveTab === "report" && reportOpen && (
<div className="h-full"> <div className="h-full flex flex-col">
<ReportPanelContent <ReportPanelContent
reportId={reportState.reportId!} reportId={reportState.reportId as number}
title={reportState.title || "Report"} title={reportState.title || "Report"}
onClose={closeReport} onClose={closeReport}
shareToken={reportState.shareToken} shareToken={reportState.shareToken}
/> />
</div> </div>
)} )}
{effectiveTab === "editor" && editorOpen && (
<div className="h-full flex flex-col">
<EditorPanelContent
documentId={editorState.documentId as number}
searchSpaceId={editorState.searchSpaceId as number}
title={editorState.title}
onClose={closeEditor}
/>
</div>
)}
</div> </div>
</aside> </aside>
); );

View file

@ -160,7 +160,7 @@ export function LayoutShell({
return ( return (
<SidebarProvider value={sidebarContextValue}> <SidebarProvider value={sidebarContextValue}>
<TooltipProvider delayDuration={0}> <TooltipProvider delayDuration={0}>
<div className={cn("flex h-screen w-full flex-col bg-background", className)}> <div className={cn("flex h-screen w-full flex-col bg-main-panel", className)}>
<Header <Header
mobileMenuTrigger={<MobileSidebarTrigger onClick={() => setMobileMenuOpen(true)} />} mobileMenuTrigger={<MobileSidebarTrigger onClick={() => setMobileMenuOpen(true)} />}
/> />
@ -187,6 +187,8 @@ export function LayoutShell({
onChatArchive={onChatArchive} onChatArchive={onChatArchive}
onViewAllSharedChats={onViewAllSharedChats} onViewAllSharedChats={onViewAllSharedChats}
onViewAllPrivateChats={onViewAllPrivateChats} onViewAllPrivateChats={onViewAllPrivateChats}
isSharedChatsPanelOpen={allSharedChatsPanel?.open}
isPrivateChatsPanelOpen={allPrivateChatsPanel?.open}
user={user} user={user}
onSettings={onSettings} onSettings={onSettings}
onManageMembers={onManageMembers} onManageMembers={onManageMembers}
@ -256,6 +258,12 @@ export function LayoutShell({
); );
} }
const anySlideOutOpen =
inbox?.isOpen ||
announcementsPanel?.open ||
allSharedChatsPanel?.open ||
allPrivateChatsPanel?.open;
// Desktop layout // Desktop layout
return ( return (
<SidebarProvider value={sidebarContextValue}> <SidebarProvider value={sidebarContextValue}>
@ -274,8 +282,13 @@ export function LayoutShell({
/> />
</div> </div>
{/* Main container with sidebar and content - relative for inbox positioning */} {/* Sidebar + slide-out panels share one container; overflow visible so panels can overlay main content */}
<div className="relative flex flex-1 rounded-xl border bg-background overflow-hidden"> <div
className={cn(
"relative hidden md:flex shrink-0 border bg-sidebar z-20 transition-[border-radius,border-color] duration-200",
anySlideOutOpen ? "rounded-l-xl border-r-0 delay-0" : "rounded-xl delay-150"
)}
>
<Sidebar <Sidebar
searchSpace={searchSpace} searchSpace={searchSpace}
isCollapsed={isCollapsed} isCollapsed={isCollapsed}
@ -292,6 +305,8 @@ export function LayoutShell({
onChatArchive={onChatArchive} onChatArchive={onChatArchive}
onViewAllSharedChats={onViewAllSharedChats} onViewAllSharedChats={onViewAllSharedChats}
onViewAllPrivateChats={onViewAllPrivateChats} onViewAllPrivateChats={onViewAllPrivateChats}
isSharedChatsPanelOpen={allSharedChatsPanel?.open}
isPrivateChatsPanelOpen={allPrivateChatsPanel?.open}
user={user} user={user}
onSettings={onSettings} onSettings={onSettings}
onManageMembers={onManageMembers} onManageMembers={onManageMembers}
@ -300,32 +315,16 @@ export function LayoutShell({
pageUsage={pageUsage} pageUsage={pageUsage}
theme={theme} theme={theme}
setTheme={setTheme} setTheme={setTheme}
className="hidden md:flex border-r shrink-0" className={cn(
"flex shrink-0 transition-[border-radius] duration-200",
anySlideOutOpen ? "rounded-l-xl delay-0" : "rounded-xl delay-150"
)}
isLoadingChats={isLoadingChats} isLoadingChats={isLoadingChats}
sidebarWidth={sidebarWidth} sidebarWidth={sidebarWidth}
onResizeMouseDown={onResizeMouseDown}
isResizing={isResizing} isResizing={isResizing}
/> />
<main className="flex-1 flex flex-col min-w-0"> {/* Slide-out panels render as siblings next to the sidebar */}
<Header />
<div className={cn("flex-1", isChatPage ? "overflow-hidden" : "overflow-auto")}>
{children}
</div>
</main>
{/* Right panel — tabbed Sources/Report (desktop only) */}
{documentsPanel && (
<RightPanel
documentsPanel={{
open: documentsPanel.open,
onOpenChange: documentsPanel.onOpenChange,
}}
/>
)}
{/* Inbox Sidebar - slide-out panel */}
{inbox && ( {inbox && (
<InboxSidebar <InboxSidebar
open={inbox.isOpen} open={inbox.isOpen}
@ -336,7 +335,6 @@ export function LayoutShell({
/> />
)} )}
{/* Announcements Sidebar */}
{announcementsPanel && ( {announcementsPanel && (
<AnnouncementsSidebar <AnnouncementsSidebar
open={announcementsPanel.open} open={announcementsPanel.open}
@ -344,7 +342,6 @@ export function LayoutShell({
/> />
)} )}
{/* All Shared Chats - slide-out panel */}
{allSharedChatsPanel && ( {allSharedChatsPanel && (
<AllSharedChatsSidebar <AllSharedChatsSidebar
open={allSharedChatsPanel.open} open={allSharedChatsPanel.open}
@ -353,7 +350,6 @@ export function LayoutShell({
/> />
)} )}
{/* All Private Chats - slide-out panel */}
{allPrivateChatsPanel && ( {allPrivateChatsPanel && (
<AllPrivateChatsSidebar <AllPrivateChatsSidebar
open={allPrivateChatsPanel.open} open={allPrivateChatsPanel.open}
@ -362,6 +358,40 @@ export function LayoutShell({
/> />
)} )}
</div> </div>
{/* Resize handle — negative margins eat the flex gap so spacing stays unchanged */}
{!isCollapsed && (
<div
role="slider"
aria-label="Resize sidebar"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={50}
tabIndex={0}
onMouseDown={onResizeMouseDown}
className="hidden md:block h-full cursor-col-resize z-30 focus:outline-none"
style={{ width: 8, marginLeft: -8, marginRight: -8 }}
/>
)}
{/* Main content panel */}
<div className="relative flex flex-1 flex-col rounded-xl border bg-main-panel overflow-hidden min-w-0">
<Header />
<div className={cn("flex-1", isChatPage ? "overflow-hidden" : "overflow-auto")}>
{children}
</div>
</div>
{/* Right panel — tabbed Sources/Report (desktop only) */}
{documentsPanel && (
<RightPanel
documentsPanel={{
open: documentsPanel.open,
onOpenChange: documentsPanel.onOpenChange,
}}
/>
)}
</div> </div>
</TooltipProvider> </TooltipProvider>
</SidebarProvider> </SidebarProvider>

View file

@ -31,13 +31,12 @@ import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
DropdownMenuItem, DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { Spinner } from "@/components/ui/spinner"; import { Spinner } from "@/components/ui/spinner";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tabs, TabsList, TabsTrigger } from "@/components/ui/animated-tabs";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { useDebouncedValue } from "@/hooks/use-debounced-value"; import { useDebouncedValue } from "@/hooks/use-debounced-value";
import { useLongPress } from "@/hooks/use-long-press"; import { useLongPress } from "@/hooks/use-long-press";
@ -300,14 +299,11 @@ export function AllPrivateChatsSidebar({
<Tabs <Tabs
value={showArchived ? "archived" : "active"} value={showArchived ? "archived" : "active"}
onValueChange={(value) => setShowArchived(value === "archived")} onValueChange={(value) => setShowArchived(value === "archived")}
className="shrink-0 mx-4" className="shrink-0 mx-4 mt-2"
> >
<TabsList className="w-full h-auto p-0 bg-transparent rounded-none border-b"> <TabsList stretch showBottomBorder size="sm">
<TabsTrigger <TabsTrigger value="active">
value="active" <span className="inline-flex items-center gap-1.5">
className="flex-1 rounded-none border-b-2 border-transparent px-1 py-2 text-xs font-medium data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none"
>
<span className="w-full inline-flex items-center justify-center gap-1.5 px-3 py-1.5 rounded-lg hover:bg-muted transition-colors">
<MessageCircleMore className="h-4 w-4" /> <MessageCircleMore className="h-4 w-4" />
<span>Active</span> <span>Active</span>
<span className="inline-flex items-center justify-center min-w-5 h-5 px-1.5 rounded-full bg-primary/20 text-muted-foreground text-xs font-medium"> <span className="inline-flex items-center justify-center min-w-5 h-5 px-1.5 rounded-full bg-primary/20 text-muted-foreground text-xs font-medium">
@ -315,11 +311,8 @@ export function AllPrivateChatsSidebar({
</span> </span>
</span> </span>
</TabsTrigger> </TabsTrigger>
<TabsTrigger <TabsTrigger value="archived">
value="archived" <span className="inline-flex items-center gap-1.5">
className="flex-1 rounded-none border-b-2 border-transparent px-1 py-2 text-xs font-medium data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none"
>
<span className="w-full inline-flex items-center justify-center gap-1.5 px-3 py-1.5 rounded-lg hover:bg-muted transition-colors">
<ArchiveIcon className="h-4 w-4" /> <ArchiveIcon className="h-4 w-4" />
<span>Archived</span> <span>Archived</span>
<span className="inline-flex items-center justify-center min-w-5 h-5 px-1.5 rounded-full bg-primary/20 text-muted-foreground text-xs font-medium"> <span className="inline-flex items-center justify-center min-w-5 h-5 px-1.5 rounded-full bg-primary/20 text-muted-foreground text-xs font-medium">
@ -334,8 +327,11 @@ export function AllPrivateChatsSidebar({
<div className="flex-1 overflow-y-auto overflow-x-hidden p-2"> <div className="flex-1 overflow-y-auto overflow-x-hidden p-2">
{isLoading ? ( {isLoading ? (
<div className="space-y-1"> <div className="space-y-1">
{[75, 90, 55, 80, 65, 85].map((titleWidth, i) => ( {[75, 90, 55, 80, 65, 85].map((titleWidth) => (
<div key={`skeleton-${i}`} className="flex items-center gap-2 rounded-md px-2 py-1.5"> <div
key={`skeleton-${titleWidth}`}
className="flex items-center gap-2 rounded-md px-2 py-1.5"
>
<Skeleton className="h-4 w-4 shrink-0 rounded" /> <Skeleton className="h-4 w-4 shrink-0 rounded" />
<Skeleton className="h-4 rounded" style={{ width: `${titleWidth}%` }} /> <Skeleton className="h-4 rounded" style={{ width: `${titleWidth}%` }} />
</div> </div>
@ -380,7 +376,6 @@ export function AllPrivateChatsSidebar({
disabled={isBusy} disabled={isBusy}
className="flex items-center gap-2 flex-1 min-w-0 text-left overflow-hidden" className="flex items-center gap-2 flex-1 min-w-0 text-left overflow-hidden"
> >
<MessageCircleMore className="h-4 w-4 shrink-0 text-muted-foreground" />
<span className="truncate">{thread.title || "New Chat"}</span> <span className="truncate">{thread.title || "New Chat"}</span>
</button> </button>
) : ( ) : (
@ -392,7 +387,6 @@ export function AllPrivateChatsSidebar({
disabled={isBusy} disabled={isBusy}
className="flex items-center gap-2 flex-1 min-w-0 text-left overflow-hidden" className="flex items-center gap-2 flex-1 min-w-0 text-left overflow-hidden"
> >
<MessageCircleMore className="h-4 w-4 shrink-0 text-muted-foreground" />
<span className="truncate">{thread.title || "New Chat"}</span> <span className="truncate">{thread.title || "New Chat"}</span>
</button> </button>
</TooltipTrigger> </TooltipTrigger>
@ -455,7 +449,6 @@ export function AllPrivateChatsSidebar({
</> </>
)} )}
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => handleDeleteThread(thread.id)}> <DropdownMenuItem onClick={() => handleDeleteThread(thread.id)}>
<Trash2 className="mr-2 h-4 w-4" /> <Trash2 className="mr-2 h-4 w-4" />
<span>{t("delete") || "Delete"}</span> <span>{t("delete") || "Delete"}</span>

View file

@ -31,13 +31,12 @@ import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
DropdownMenuItem, DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { Spinner } from "@/components/ui/spinner"; import { Spinner } from "@/components/ui/spinner";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tabs, TabsList, TabsTrigger } from "@/components/ui/animated-tabs";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { useDebouncedValue } from "@/hooks/use-debounced-value"; import { useDebouncedValue } from "@/hooks/use-debounced-value";
import { useLongPress } from "@/hooks/use-long-press"; import { useLongPress } from "@/hooks/use-long-press";
@ -300,14 +299,11 @@ export function AllSharedChatsSidebar({
<Tabs <Tabs
value={showArchived ? "archived" : "active"} value={showArchived ? "archived" : "active"}
onValueChange={(value) => setShowArchived(value === "archived")} onValueChange={(value) => setShowArchived(value === "archived")}
className="shrink-0 mx-4" className="shrink-0 mx-4 mt-2"
> >
<TabsList className="w-full h-auto p-0 bg-transparent rounded-none border-b"> <TabsList stretch showBottomBorder size="sm">
<TabsTrigger <TabsTrigger value="active">
value="active" <span className="inline-flex items-center gap-1.5">
className="flex-1 rounded-none border-b-2 border-transparent px-1 py-2 text-xs font-medium data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none"
>
<span className="w-full inline-flex items-center justify-center gap-1.5 px-3 py-1.5 rounded-lg hover:bg-muted transition-colors">
<MessageCircleMore className="h-4 w-4" /> <MessageCircleMore className="h-4 w-4" />
<span>Active</span> <span>Active</span>
<span className="inline-flex items-center justify-center min-w-5 h-5 px-1.5 rounded-full bg-primary/20 text-muted-foreground text-xs font-medium"> <span className="inline-flex items-center justify-center min-w-5 h-5 px-1.5 rounded-full bg-primary/20 text-muted-foreground text-xs font-medium">
@ -315,11 +311,8 @@ export function AllSharedChatsSidebar({
</span> </span>
</span> </span>
</TabsTrigger> </TabsTrigger>
<TabsTrigger <TabsTrigger value="archived">
value="archived" <span className="inline-flex items-center gap-1.5">
className="flex-1 rounded-none border-b-2 border-transparent px-1 py-2 text-xs font-medium data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none"
>
<span className="w-full inline-flex items-center justify-center gap-1.5 px-3 py-1.5 rounded-lg hover:bg-muted transition-colors">
<ArchiveIcon className="h-4 w-4" /> <ArchiveIcon className="h-4 w-4" />
<span>Archived</span> <span>Archived</span>
<span className="inline-flex items-center justify-center min-w-5 h-5 px-1.5 rounded-full bg-primary/20 text-muted-foreground text-xs font-medium"> <span className="inline-flex items-center justify-center min-w-5 h-5 px-1.5 rounded-full bg-primary/20 text-muted-foreground text-xs font-medium">
@ -334,8 +327,11 @@ export function AllSharedChatsSidebar({
<div className="flex-1 overflow-y-auto overflow-x-hidden p-2"> <div className="flex-1 overflow-y-auto overflow-x-hidden p-2">
{isLoading ? ( {isLoading ? (
<div className="space-y-1"> <div className="space-y-1">
{[75, 90, 55, 80, 65, 85].map((titleWidth, i) => ( {[75, 90, 55, 80, 65, 85].map((titleWidth) => (
<div key={`skeleton-${i}`} className="flex items-center gap-2 rounded-md px-2 py-1.5"> <div
key={`skeleton-${titleWidth}`}
className="flex items-center gap-2 rounded-md px-2 py-1.5"
>
<Skeleton className="h-4 w-4 shrink-0 rounded" /> <Skeleton className="h-4 w-4 shrink-0 rounded" />
<Skeleton className="h-4 rounded" style={{ width: `${titleWidth}%` }} /> <Skeleton className="h-4 rounded" style={{ width: `${titleWidth}%` }} />
</div> </div>
@ -380,7 +376,6 @@ export function AllSharedChatsSidebar({
disabled={isBusy} disabled={isBusy}
className="flex items-center gap-2 flex-1 min-w-0 text-left overflow-hidden" className="flex items-center gap-2 flex-1 min-w-0 text-left overflow-hidden"
> >
<MessageCircleMore className="h-4 w-4 shrink-0 text-muted-foreground" />
<span className="truncate">{thread.title || "New Chat"}</span> <span className="truncate">{thread.title || "New Chat"}</span>
</button> </button>
) : ( ) : (
@ -392,7 +387,6 @@ export function AllSharedChatsSidebar({
disabled={isBusy} disabled={isBusy}
className="flex items-center gap-2 flex-1 min-w-0 text-left overflow-hidden" className="flex items-center gap-2 flex-1 min-w-0 text-left overflow-hidden"
> >
<MessageCircleMore className="h-4 w-4 shrink-0 text-muted-foreground" />
<span className="truncate">{thread.title || "New Chat"}</span> <span className="truncate">{thread.title || "New Chat"}</span>
</button> </button>
</TooltipTrigger> </TooltipTrigger>
@ -455,7 +449,6 @@ export function AllSharedChatsSidebar({
</> </>
)} )}
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => handleDeleteThread(thread.id)}> <DropdownMenuItem onClick={() => handleDeleteThread(thread.id)}>
<Trash2 className="mr-2 h-4 w-4" /> <Trash2 className="mr-2 h-4 w-4" />
<span>{t("delete") || "Delete"}</span> <span>{t("delete") || "Delete"}</span>

View file

@ -61,7 +61,7 @@ export function ChatListItem({
onClick={handleClick} onClick={handleClick}
{...(isMobile ? longPressHandlers : {})} {...(isMobile ? longPressHandlers : {})}
className={cn( className={cn(
"flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-sm text-left transition-colors", "flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-sm text-left",
"group-hover/item:bg-accent group-hover/item:text-accent-foreground", "group-hover/item:bg-accent group-hover/item:text-accent-foreground",
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring", "focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
isActive && "bg-accent text-accent-foreground" isActive && "bg-accent text-accent-foreground"

View file

@ -358,7 +358,6 @@ export function DocumentsSidebar({
onLoadMore={onLoadMore} onLoadMore={onLoadMore}
mentionedDocIds={mentionedDocIds} mentionedDocIds={mentionedDocIds}
onToggleChatMention={handleToggleChatMention} onToggleChatMention={handleToggleChatMention}
onEditNavigate={() => onOpenChange(false)}
isSearchMode={isSearchMode || activeTypes.length > 0} isSearchMode={isSearchMode || activeTypes.length > 0}
/> />
</div> </div>

View file

@ -856,9 +856,9 @@ export function InboxSidebar({
<TabsList className="w-full h-auto p-0 bg-transparent rounded-none border-b"> <TabsList className="w-full h-auto p-0 bg-transparent rounded-none border-b">
<TabsTrigger <TabsTrigger
value="comments" value="comments"
className="flex-1 rounded-none border-b-2 border-transparent px-1 py-2 text-xs font-medium data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none" className="group flex-1 rounded-none border-b-2 border-transparent px-1 py-2 text-xs font-medium data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none"
> >
<span className="w-full inline-flex items-center justify-center gap-1.5 px-3 py-1.5 rounded-lg hover:bg-muted transition-colors"> <span className="w-full inline-flex items-center justify-center gap-1.5 px-3 py-1.5 rounded-lg hover:bg-muted group-data-[state=active]:bg-muted transition-colors">
<MessageSquare className="h-4 w-4" /> <MessageSquare className="h-4 w-4" />
<span>{t("comments") || "Comments"}</span> <span>{t("comments") || "Comments"}</span>
<span className="inline-flex items-center justify-center min-w-5 h-5 px-1.5 rounded-full bg-primary/20 text-muted-foreground text-xs font-medium"> <span className="inline-flex items-center justify-center min-w-5 h-5 px-1.5 rounded-full bg-primary/20 text-muted-foreground text-xs font-medium">
@ -868,9 +868,9 @@ export function InboxSidebar({
</TabsTrigger> </TabsTrigger>
<TabsTrigger <TabsTrigger
value="status" value="status"
className="flex-1 rounded-none border-b-2 border-transparent px-1 py-2 text-xs font-medium data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none" className="group flex-1 rounded-none border-b-2 border-transparent px-1 py-2 text-xs font-medium data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none"
> >
<span className="w-full inline-flex items-center justify-center gap-1.5 px-3 py-1.5 rounded-lg hover:bg-muted transition-colors"> <span className="w-full inline-flex items-center justify-center gap-1.5 px-3 py-1.5 rounded-lg hover:bg-muted group-data-[state=active]:bg-muted transition-colors">
<History className="h-4 w-4" /> <History className="h-4 w-4" />
<span>{t("status") || "Status"}</span> <span>{t("status") || "Status"}</span>
<span className="inline-flex items-center justify-center min-w-5 h-5 px-1.5 rounded-full bg-primary/20 text-muted-foreground text-xs font-medium"> <span className="inline-flex items-center justify-center min-w-5 h-5 px-1.5 rounded-full bg-primary/20 text-muted-foreground text-xs font-medium">

View file

@ -14,8 +14,6 @@ interface MobileSidebarProps {
searchSpaces: SearchSpace[]; searchSpaces: SearchSpace[];
activeSearchSpaceId: number | null; activeSearchSpaceId: number | null;
onSearchSpaceSelect: (id: number) => void; onSearchSpaceSelect: (id: number) => void;
onSearchSpaceDelete?: (searchSpace: SearchSpace) => void;
onSearchSpaceSettings?: (searchSpace: SearchSpace) => void;
onAddSearchSpace: () => void; onAddSearchSpace: () => void;
searchSpace: SearchSpace | null; searchSpace: SearchSpace | null;
navItems: NavItem[]; navItems: NavItem[];
@ -30,6 +28,8 @@ interface MobileSidebarProps {
onChatArchive?: (chat: ChatItem) => void; onChatArchive?: (chat: ChatItem) => void;
onViewAllSharedChats?: () => void; onViewAllSharedChats?: () => void;
onViewAllPrivateChats?: () => void; onViewAllPrivateChats?: () => void;
isSharedChatsPanelOpen?: boolean;
isPrivateChatsPanelOpen?: boolean;
user: User; user: User;
onSettings?: () => void; onSettings?: () => void;
onManageMembers?: () => void; onManageMembers?: () => void;
@ -56,8 +56,7 @@ export function MobileSidebar({
searchSpaces, searchSpaces,
activeSearchSpaceId, activeSearchSpaceId,
onSearchSpaceSelect, onSearchSpaceSelect,
onSearchSpaceDelete,
onSearchSpaceSettings,
onAddSearchSpace, onAddSearchSpace,
searchSpace, searchSpace,
navItems, navItems,
@ -72,6 +71,8 @@ export function MobileSidebar({
onChatArchive, onChatArchive,
onViewAllSharedChats, onViewAllSharedChats,
onViewAllPrivateChats, onViewAllPrivateChats,
isSharedChatsPanelOpen = false,
isPrivateChatsPanelOpen = false,
user, user,
onSettings, onSettings,
onManageMembers, onManageMembers,
@ -165,6 +166,8 @@ export function MobileSidebar({
} }
: undefined : undefined
} }
isSharedChatsPanelOpen={isSharedChatsPanelOpen}
isPrivateChatsPanelOpen={isPrivateChatsPanelOpen}
user={user} user={user}
onSettings={ onSettings={
onSettings onSettings

View file

@ -74,9 +74,7 @@ export function NavSection({ items, onItemClick, isCollapsed = false }: NavSecti
const indicator = item.statusIndicator; const indicator = item.statusIndicator;
const joyrideAttr = const joyrideAttr =
item.title === "Documents" || item.title.toLowerCase().includes("documents") item.title === "Inbox" || item.title.toLowerCase().includes("inbox")
? { "data-joyride": "documents-sidebar" }
: item.title === "Inbox" || item.title.toLowerCase().includes("inbox")
? { "data-joyride": "inbox-sidebar" } ? { "data-joyride": "inbox-sidebar" }
: {}; : {};

View file

@ -1,8 +1,8 @@
"use client"; "use client";
import { useSetAtom } from "jotai";
import { Zap } from "lucide-react"; import { Zap } from "lucide-react";
import Link from "next/link"; import { morePagesDialogAtom } from "@/atoms/settings/settings-dialog.atoms";
import { useParams } from "next/navigation";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Progress } from "@/components/ui/progress"; import { Progress } from "@/components/ui/progress";
@ -12,8 +12,7 @@ interface PageUsageDisplayProps {
} }
export function PageUsageDisplay({ pagesUsed, pagesLimit }: PageUsageDisplayProps) { export function PageUsageDisplay({ pagesUsed, pagesLimit }: PageUsageDisplayProps) {
const params = useParams(); const setMorePagesOpen = useSetAtom(morePagesDialogAtom);
const searchSpaceId = params.search_space_id;
const usagePercentage = (pagesUsed / pagesLimit) * 100; const usagePercentage = (pagesUsed / pagesLimit) * 100;
return ( return (
@ -26,9 +25,10 @@ export function PageUsageDisplay({ pagesUsed, pagesLimit }: PageUsageDisplayProp
<span className="font-medium">{usagePercentage.toFixed(0)}%</span> <span className="font-medium">{usagePercentage.toFixed(0)}%</span>
</div> </div>
<Progress value={usagePercentage} className="h-1.5" /> <Progress value={usagePercentage} className="h-1.5" />
<Link <button
href={`/dashboard/${searchSpaceId}/more-pages`} type="button"
className="group flex items-center justify-between rounded-md px-1.5 py-1 -mx-1.5 transition-colors hover:bg-accent" onClick={() => setMorePagesOpen(true)}
className="group flex w-full items-center justify-between rounded-md px-1.5 py-1 -mx-1.5 transition-colors hover:bg-accent"
> >
<span className="flex items-center gap-1.5 text-xs text-muted-foreground group-hover:text-accent-foreground"> <span className="flex items-center gap-1.5 text-xs text-muted-foreground group-hover:text-accent-foreground">
<Zap className="h-3 w-3 shrink-0" /> <Zap className="h-3 w-3 shrink-0" />
@ -37,7 +37,7 @@ export function PageUsageDisplay({ pagesUsed, pagesLimit }: PageUsageDisplayProp
<Badge className="h-4 rounded px-1 text-[10px] font-semibold leading-none bg-emerald-600 text-white border-transparent hover:bg-emerald-600"> <Badge className="h-4 rounded px-1 text-[10px] font-semibold leading-none bg-emerald-600 text-white border-transparent hover:bg-emerald-600">
FREE FREE
</Badge> </Badge>
</Link> </button>
</div> </div>
</div> </div>
); );

View file

@ -1,6 +1,6 @@
"use client"; "use client";
import { FolderOpen, PenSquare } from "lucide-react"; import { PenSquare } from "lucide-react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useState } from "react"; import { useState } from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@ -42,6 +42,8 @@ interface SidebarProps {
onChatArchive?: (chat: ChatItem) => void; onChatArchive?: (chat: ChatItem) => void;
onViewAllSharedChats?: () => void; onViewAllSharedChats?: () => void;
onViewAllPrivateChats?: () => void; onViewAllPrivateChats?: () => void;
isSharedChatsPanelOpen?: boolean;
isPrivateChatsPanelOpen?: boolean;
user: User; user: User;
onSettings?: () => void; onSettings?: () => void;
onManageMembers?: () => void; onManageMembers?: () => void;
@ -54,7 +56,6 @@ interface SidebarProps {
isLoadingChats?: boolean; isLoadingChats?: boolean;
disableTooltips?: boolean; disableTooltips?: boolean;
sidebarWidth?: number; sidebarWidth?: number;
onResizeMouseDown?: (e: React.MouseEvent) => void;
isResizing?: boolean; isResizing?: boolean;
} }
@ -74,6 +75,8 @@ export function Sidebar({
onChatArchive, onChatArchive,
onViewAllSharedChats, onViewAllSharedChats,
onViewAllPrivateChats, onViewAllPrivateChats,
isSharedChatsPanelOpen = false,
isPrivateChatsPanelOpen = false,
user, user,
onSettings, onSettings,
onManageMembers, onManageMembers,
@ -86,7 +89,6 @@ export function Sidebar({
isLoadingChats = false, isLoadingChats = false,
disableTooltips = false, disableTooltips = false,
sidebarWidth = SIDEBAR_MIN_WIDTH, sidebarWidth = SIDEBAR_MIN_WIDTH,
onResizeMouseDown,
isResizing = false, isResizing = false,
}: SidebarProps) { }: SidebarProps) {
const t = useTranslations("sidebar"); const t = useTranslations("sidebar");
@ -102,19 +104,6 @@ export function Sidebar({
)} )}
style={!isCollapsed ? { width: sidebarWidth } : undefined} style={!isCollapsed ? { width: sidebarWidth } : undefined}
> >
{/* Resize handle on right border */}
{!isCollapsed && onResizeMouseDown && (
<div
role="slider"
aria-label="Resize sidebar"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={50}
tabIndex={0}
onMouseDown={onResizeMouseDown}
className="absolute right-0 top-0 h-full w-1 cursor-col-resize hover:bg-border active:bg-border z-10"
/>
)}
{/* Header - search space name or collapse button when collapsed */} {/* Header - search space name or collapse button when collapsed */}
{isCollapsed ? ( {isCollapsed ? (
<div className="flex h-14 shrink-0 items-center justify-center border-b"> <div className="flex h-14 shrink-0 items-center justify-center border-b">
@ -173,34 +162,16 @@ export function Sidebar({
defaultOpen={true} defaultOpen={true}
fillHeight={false} fillHeight={false}
className="shrink-0 max-h-[50%] flex flex-col" className="shrink-0 max-h-[50%] flex flex-col"
alwaysShowAction={!disableTooltips && isSharedChatsPanelOpen}
action={ action={
onViewAllSharedChats ? ( onViewAllSharedChats ? (
disableTooltips ? ( <button
<Button type="button"
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0 hover:bg-transparent hover:text-current focus-visible:ring-0"
onClick={onViewAllSharedChats} onClick={onViewAllSharedChats}
className="text-[10px] text-muted-foreground/70 hover:text-muted-foreground transition-colors whitespace-nowrap cursor-pointer bg-transparent border-none p-0 focus:outline-none"
> >
<FolderOpen className="h-4 w-4" /> {!disableTooltips && isSharedChatsPanelOpen ? t("hide") : t("show_all")}
</Button> </button>
) : (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0 hover:bg-transparent hover:text-current focus-visible:ring-0"
onClick={onViewAllSharedChats}
>
<FolderOpen className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">
{t("view_all_shared_chats") || "View all shared chats"}
</TooltipContent>
</Tooltip>
)
) : undefined ) : undefined
} }
> >
@ -247,34 +218,16 @@ export function Sidebar({
title={t("chats")} title={t("chats")}
defaultOpen={true} defaultOpen={true}
fillHeight={true} fillHeight={true}
alwaysShowAction={!disableTooltips && isPrivateChatsPanelOpen}
action={ action={
onViewAllPrivateChats ? ( onViewAllPrivateChats ? (
disableTooltips ? ( <button
<Button type="button"
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0 hover:bg-transparent hover:text-current focus-visible:ring-0"
onClick={onViewAllPrivateChats} onClick={onViewAllPrivateChats}
className="text-[10px] text-muted-foreground/70 hover:text-muted-foreground transition-colors whitespace-nowrap cursor-pointer bg-transparent border-none p-0 focus:outline-none"
> >
<FolderOpen className="h-4 w-4" /> {!disableTooltips && isPrivateChatsPanelOpen ? t("hide") : t("show_all")}
</Button> </button>
) : (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0 hover:bg-transparent hover:text-current focus-visible:ring-0"
onClick={onViewAllPrivateChats}
>
<FolderOpen className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">
{t("view_all_private_chats") || "View all private chats"}
</TooltipContent>
</Tooltip>
)
) : undefined ) : undefined
} }
> >

View file

@ -10,6 +10,7 @@ interface SidebarSectionProps {
defaultOpen?: boolean; defaultOpen?: boolean;
children: React.ReactNode; children: React.ReactNode;
action?: React.ReactNode; action?: React.ReactNode;
alwaysShowAction?: boolean;
persistentAction?: React.ReactNode; persistentAction?: React.ReactNode;
className?: string; className?: string;
fillHeight?: boolean; fillHeight?: boolean;
@ -20,6 +21,7 @@ export function SidebarSection({
defaultOpen = true, defaultOpen = true,
children, children,
action, action,
alwaysShowAction = false,
persistentAction, persistentAction,
className, className,
fillHeight = false, fillHeight = false,
@ -37,27 +39,32 @@ export function SidebarSection({
className className
)} )}
> >
<div className="flex items-center group/section shrink-0"> <div className="flex items-center group/section shrink-0 px-2 py-1.5">
<CollapsibleTrigger className="flex flex-1 items-center gap-1.5 px-2 py-1.5 text-xs font-medium text-muted-foreground hover:text-foreground transition-colors min-w-0"> <CollapsibleTrigger className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground hover:text-foreground transition-colors min-w-0">
<span className="truncate">{title}</span>
<ChevronRight <ChevronRight
className={cn( className={cn(
"h-3.5 w-3.5 shrink-0 transition-transform duration-200", "h-3.5 w-3.5 shrink-0 transition-transform duration-200",
isOpen && "rotate-90" isOpen && "rotate-90"
)} )}
/> />
<span className="uppercase tracking-wider truncate">{title}</span>
</CollapsibleTrigger> </CollapsibleTrigger>
{/* Action button - visible on hover (always visible on mobile) */}
{action && ( {action && (
<div className="shrink-0 opacity-100 md:opacity-0 md:group-hover/section:opacity-100 transition-opacity pr-1 flex items-center"> <div
className={cn(
"transition-opacity ml-1.5 flex items-center",
alwaysShowAction
? "opacity-100"
: "opacity-100 md:opacity-0 md:group-hover/section:opacity-100"
)}
>
{action} {action}
</div> </div>
)} )}
{/* Persistent action - always visible */}
{persistentAction && ( {persistentAction && (
<div className="shrink-0 pr-1 flex items-center">{persistentAction}</div> <div className="shrink-0 ml-auto flex items-center">{persistentAction}</div>
)} )}
</div> </div>

View file

@ -1,15 +1,11 @@
"use client"; "use client";
import { AnimatePresence, motion } from "motion/react"; import { AnimatePresence, motion } from "motion/react";
import { useEffect } from "react"; import { useCallback, useEffect } from "react";
import { useMediaQuery } from "@/hooks/use-media-query"; import { useMediaQuery } from "@/hooks/use-media-query";
import { cn } from "@/lib/utils";
import { useSidebarContextSafe } from "../../hooks";
export const SLIDEOUT_PANEL_OPENED_EVENT = "slideout-panel-opened"; export const SLIDEOUT_PANEL_OPENED_EVENT = "slideout-panel-opened";
const SIDEBAR_COLLAPSED_WIDTH = 60;
interface SidebarSlideOutPanelProps { interface SidebarSlideOutPanelProps {
open: boolean; open: boolean;
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
@ -19,11 +15,12 @@ interface SidebarSlideOutPanelProps {
} }
/** /**
* Reusable slide-out panel that appears from the right edge of the sidebar. * Reusable slide-out panel that extends from the sidebar.
* Used by InboxSidebar (floating mode), AllSharedChatsSidebar, and AllPrivateChatsSidebar.
* *
* Must be rendered inside a positioned container (the LayoutShell's relative flex container) * Desktop: absolutely positioned at the sidebar's right edge, overlaying the main
* and within the SidebarProvider context. * content with a blur backdrop. Does not push/shrink the main content.
*
* Mobile: full-width absolute overlay (unchanged).
*/ */
export function SidebarSlideOutPanel({ export function SidebarSlideOutPanel({
open, open,
@ -33,11 +30,6 @@ export function SidebarSlideOutPanel({
children, children,
}: SidebarSlideOutPanelProps) { }: SidebarSlideOutPanelProps) {
const isMobile = !useMediaQuery("(min-width: 640px)"); const isMobile = !useMediaQuery("(min-width: 640px)");
const sidebarContext = useSidebarContextSafe();
const isCollapsed = sidebarContext?.isCollapsed ?? false;
const sidebarWidth = isCollapsed
? SIDEBAR_COLLAPSED_WIDTH
: (sidebarContext?.sidebarWidth ?? 240);
useEffect(() => { useEffect(() => {
if (open) { if (open) {
@ -45,41 +37,30 @@ export function SidebarSlideOutPanel({
} }
}, [open]); }, [open]);
const handleEscape = useCallback(
(e: KeyboardEvent) => {
if (e.key === "Escape") onOpenChange(false);
},
[onOpenChange]
);
useEffect(() => {
if (!open) return;
document.addEventListener("keydown", handleEscape);
return () => document.removeEventListener("keydown", handleEscape);
}, [open, handleEscape]);
if (isMobile) {
return ( return (
<AnimatePresence> <AnimatePresence>
{open && ( {open && (
<> <div className="absolute left-0 inset-y-0 z-30 w-full overflow-hidden pointer-events-none">
{/* Backdrop overlay with blur — desktop only, covers main content area (right of sidebar) */}
{!isMobile && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15 }}
style={{ left: sidebarWidth }}
className="absolute inset-y-0 right-0 z-20 bg-black/30 backdrop-blur-sm"
onClick={() => onOpenChange(false)}
aria-hidden="true"
/>
)}
{/* Clip container - positioned at sidebar edge with overflow hidden */}
<div
style={{
left: isMobile ? 0 : sidebarWidth,
width: isMobile ? "100%" : width,
}}
className={cn("absolute z-30 overflow-hidden pointer-events-none", "inset-y-0")}
>
<motion.div <motion.div
initial={{ x: "-100%" }} initial={{ x: "-100%" }}
animate={{ x: 0 }} animate={{ x: 0 }}
exit={{ x: "-100%" }} exit={{ x: "-100%" }}
transition={{ type: "tween", duration: 0.2, ease: [0.4, 0, 0.2, 1] }} transition={{ type: "tween", duration: 0.2, ease: [0.4, 0, 0.2, 1] }}
className={cn( className="h-full w-full bg-sidebar text-sidebar-foreground flex flex-col pointer-events-auto select-none"
"h-full w-full bg-sidebar text-sidebar-foreground flex flex-col pointer-events-auto select-none",
"sm:border-r sm:shadow-xl"
)}
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
aria-label={ariaLabel} aria-label={ariaLabel}
@ -87,6 +68,45 @@ export function SidebarSlideOutPanel({
{children} {children}
</motion.div> </motion.div>
</div> </div>
)}
</AnimatePresence>
);
}
return (
<AnimatePresence initial={false}>
{open && (
<>
{/* Blur backdrop covering the main content area (right of sidebar) */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15 }}
className="absolute z-10 bg-black/30 backdrop-blur-sm rounded-xl"
style={{ top: -9, bottom: -9, left: "calc(100% + 1px)", width: "200vw" }}
onClick={() => onOpenChange(false)}
aria-hidden="true"
/>
{/* Panel extending from sidebar's right edge, flush with the wrapper border */}
<motion.div
initial={{ width: 0 }}
animate={{ width }}
exit={{ width: 0 }}
transition={{ type: "tween", duration: 0.2, ease: [0.4, 0, 0.2, 1] }}
className="absolute z-20 overflow-hidden"
style={{ left: "100%", top: -1, bottom: -1 }}
>
<div
style={{ width }}
className="h-full bg-sidebar text-sidebar-foreground flex flex-col select-none border rounded-r-xl shadow-xl"
role="dialog"
aria-label={ariaLabel}
>
{children}
</div>
</motion.div>
</> </>
)} )}
</AnimatePresence> </AnimatePresence>

View file

@ -122,7 +122,7 @@ function UserAvatar({
alt="User avatar" alt="User avatar"
width={32} width={32}
height={32} height={32}
className="h-8 w-8 shrink-0 rounded-lg object-cover" className="h-8 w-8 shrink-0 rounded-lg object-cover select-none"
referrerPolicy="no-referrer" referrerPolicy="no-referrer"
unoptimized unoptimized
/> />

View file

@ -3,12 +3,13 @@
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useAtomValue, useSetAtom } from "jotai"; import { useAtomValue, useSetAtom } from "jotai";
import { Earth, User, Users } from "lucide-react"; import { Earth, User, Users } from "lucide-react";
import { useParams, useRouter } from "next/navigation";
import { useCallback, useMemo, useState } from "react"; import { useCallback, useMemo, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { currentThreadAtom, setThreadVisibilityAtom } from "@/atoms/chat/current-thread.atom"; import { currentThreadAtom, setThreadVisibilityAtom } from "@/atoms/chat/current-thread.atom";
import { myAccessAtom } from "@/atoms/members/members-query.atoms"; import { myAccessAtom } from "@/atoms/members/members-query.atoms";
import { createPublicChatSnapshotMutationAtom } from "@/atoms/public-chat-snapshots/public-chat-snapshots-mutation.atoms"; import { createPublicChatSnapshotMutationAtom } from "@/atoms/public-chat-snapshots/public-chat-snapshots-mutation.atoms";
import { searchSpaceSettingsDialogAtom } from "@/atoms/settings/settings-dialog.atoms";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
@ -48,9 +49,8 @@ const visibilityOptions: {
export function ChatShareButton({ thread, onVisibilityChange, className }: ChatShareButtonProps) { export function ChatShareButton({ thread, onVisibilityChange, className }: ChatShareButtonProps) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const router = useRouter();
const params = useParams();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const setSearchSpaceSettingsDialog = useSetAtom(searchSpaceSettingsDialogAtom);
// Use Jotai atom for visibility (single source of truth) // Use Jotai atom for visibility (single source of truth)
const currentThreadState = useAtomValue(currentThreadAtom); const currentThreadState = useAtomValue(currentThreadAtom);
@ -148,7 +148,10 @@ export function ChatShareButton({ thread, onVisibilityChange, className }: ChatS
<button <button
type="button" type="button"
onClick={() => onClick={() =>
router.push(`/dashboard/${params.search_space_id}/settings?tab=public-links`) setSearchSpaceSettingsDialog({
open: true,
initialTab: "public-links",
})
} }
className="flex items-center justify-center h-8 w-8 rounded-md bg-muted/50 hover:bg-muted transition-colors" className="flex items-center justify-center h-8 w-8 rounded-md bg-muted/50 hover:bg-muted transition-colors"
> >

View file

@ -233,11 +233,14 @@ export function ModelSelector({
<Popover open={open} onOpenChange={setOpen}> <Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild> <PopoverTrigger asChild>
<Button <Button
variant="outline" variant="ghost"
size="sm" size="sm"
role="combobox" role="combobox"
aria-expanded={open} aria-expanded={open}
className={cn("h-8 gap-2 px-3 text-sm border-border/60 select-none", className)} className={cn(
"h-8 gap-2 px-3 text-sm bg-main-panel hover:bg-accent/50 dark:hover:bg-white/[0.06] border border-border/40 select-none",
className
)}
> >
{isLoading ? ( {isLoading ? (
<> <>
@ -281,12 +284,7 @@ export function ModelSelector({
)} )}
</> </>
)} )}
<ChevronDown <ChevronDown className="h-3.5 w-3.5 text-muted-foreground ml-1 shrink-0" />
className={cn(
"h-3.5 w-3.5 text-muted-foreground ml-1 shrink-0 transition-transform duration-200",
open && "rotate-180"
)}
/>
</Button> </Button>
</PopoverTrigger> </PopoverTrigger>

View file

@ -23,15 +23,15 @@ interface TourStep {
const TOUR_STEPS: TourStep[] = [ const TOUR_STEPS: TourStep[] = [
{ {
target: '[data-joyride="connector-icon"]', target: '[data-joyride="connector-icon"]',
title: "Connect your data sources", title: "Manage your tools",
content: "Connect and sync data from Gmail, Drive, Slack, Notion, Jira, Confluence, and more.", content: "Enable or disable AI tools and configure capabilities.",
placement: "bottom", placement: "bottom",
}, },
{ {
target: '[data-joyride="documents-sidebar"]', target: '[data-joyride="upload-button"]',
title: "Manage your documents", title: "Upload documents",
content: "Access and manage all your uploaded documents.", content: "Upload files to your search space.",
placement: "right", placement: "left",
}, },
{ {
target: '[data-joyride="inbox-sidebar"]', target: '[data-joyride="inbox-sidebar"]',
@ -100,7 +100,7 @@ function Spotlight({
}) { }) {
const rect = targetEl.getBoundingClientRect(); const rect = targetEl.getBoundingClientRect();
const padding = 6; const padding = 6;
const shadowColor = isDarkMode ? "#172554" : "#3b82f6"; const shadowColor = isDarkMode ? "#3f3f46" : "#3b82f6";
// Check if this is the connector icon step - verify both the selector matches AND the element matches // Check if this is the connector icon step - verify both the selector matches AND the element matches
// This prevents the shape from changing before targetEl updates // This prevents the shape from changing before targetEl updates
@ -187,7 +187,7 @@ function TourTooltip({
} }
}, [stepIndex]); }, [stepIndex]);
const bgColor = isDarkMode ? "#18181b" : "#ffffff"; const bgColor = isDarkMode ? "#27272a" : "#ffffff";
const textColor = isDarkMode ? "#ffffff" : "#18181b"; const textColor = isDarkMode ? "#ffffff" : "#18181b";
const mutedTextColor = isDarkMode ? "#a1a1aa" : "#71717a"; const mutedTextColor = isDarkMode ? "#a1a1aa" : "#71717a";
@ -195,15 +195,24 @@ function TourTooltip({
const getPointerStyles = (): React.CSSProperties => { const getPointerStyles = (): React.CSSProperties => {
const lineLength = 16; const lineLength = 16;
const dotSize = 6; const dotSize = 6;
// Check if this is the documents step (stepIndex === 1) or inbox step (stepIndex === 2) const isUploadStep = stepIndex === 1;
const isDocumentsStep = stepIndex === 1;
const isInboxStep = stepIndex === 2; const isInboxStep = stepIndex === 2;
if (position.pointerPosition === "left") { if (position.pointerPosition === "left") {
return { return {
position: "absolute", position: "absolute",
left: -lineLength - dotSize, left: -lineLength - dotSize,
top: isDocumentsStep || isInboxStep ? "calc(50% - 8px)" : "50%", top: isInboxStep ? "calc(50% - 8px)" : "50%",
transform: "translateY(-50%)",
display: "flex",
alignItems: "center",
};
}
if (position.pointerPosition === "right") {
return {
position: "absolute",
right: -lineLength - dotSize,
top: isUploadStep ? "calc(50% - 12px)" : "50%",
transform: "translateY(-50%)", transform: "translateY(-50%)",
display: "flex", display: "flex",
alignItems: "center", alignItems: "center",
@ -224,7 +233,7 @@ function TourTooltip({
}; };
const renderPointer = () => { const renderPointer = () => {
const lineColor = isDarkMode ? "#18181B" : "#ffffff"; const lineColor = isDarkMode ? "#27272a" : "#ffffff";
if (position.pointerPosition === "left") { if (position.pointerPosition === "left") {
return ( return (
@ -247,6 +256,27 @@ function TourTooltip({
</div> </div>
); );
} }
if (position.pointerPosition === "right") {
return (
<div style={getPointerStyles()}>
<div
style={{
width: 16,
height: 2,
backgroundColor: lineColor,
}}
/>
<div
style={{
width: 6,
height: 6,
borderRadius: "50%",
backgroundColor: lineColor,
}}
/>
</div>
);
}
if (position.pointerPosition === "top") { if (position.pointerPosition === "top") {
return ( return (
<div style={getPointerStyles()}> <div style={getPointerStyles()}>

View file

@ -1,13 +1,14 @@
"use client"; "use client";
import { Check, Copy, ExternalLink, MessageSquare, Trash2 } from "lucide-react"; import { Check, Copy, ExternalLink, MessageSquare, Trash2 } from "lucide-react";
import Image from "next/image";
import { useCallback, useRef, useState } from "react"; import { useCallback, useRef, useState } from "react";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import type { PublicChatSnapshotDetail } from "@/contracts/types/chat-threads.types"; import type { PublicChatSnapshotDetail } from "@/contracts/types/chat-threads.types";
import { useMediaQuery } from "@/hooks/use-media-query";
function getInitials(name: string): string { function getInitials(name: string): string {
const parts = name.trim().split(/\s+/); const parts = name.trim().split(/\s+/);
@ -36,6 +37,7 @@ export function PublicChatSnapshotRow({
}: PublicChatSnapshotRowProps) { }: PublicChatSnapshotRowProps) {
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
const copyTimeoutRef = useRef<ReturnType<typeof setTimeout>>(null); const copyTimeoutRef = useRef<ReturnType<typeof setTimeout>>(null);
const isDesktop = useMediaQuery("(min-width: 768px)");
const handleCopyClick = useCallback(() => { const handleCopyClick = useCallback(() => {
onCopy(snapshot); onCopy(snapshot);
@ -56,8 +58,8 @@ export function PublicChatSnapshotRow({
<Card className="group relative overflow-hidden transition-all duration-200 border-border/60 hover:shadow-md h-full"> <Card className="group relative overflow-hidden transition-all duration-200 border-border/60 hover:shadow-md h-full">
<CardContent className="p-4 flex flex-col gap-3 h-full"> <CardContent className="p-4 flex flex-col gap-3 h-full">
{/* Header: Title + Actions */} {/* Header: Title + Actions */}
<div className="flex items-start justify-between gap-2"> <div className="relative">
<div className="min-w-0 flex-1"> <div className="min-w-0 pr-16 sm:pr-0 sm:group-hover:pr-16">
<h4 <h4
className="text-sm font-semibold tracking-tight truncate" className="text-sm font-semibold tracking-tight truncate"
title={snapshot.thread_title} title={snapshot.thread_title}
@ -65,9 +67,9 @@ export function PublicChatSnapshotRow({
{snapshot.thread_title} {snapshot.thread_title}
</h4> </h4>
</div> </div>
<div className="flex items-center gap-0.5 shrink-0 sm:opacity-0 sm:group-hover:opacity-100 transition-opacity duration-150"> <div className="flex items-center gap-0.5 shrink-0 sm:hidden sm:group-hover:flex absolute right-0 top-0">
<TooltipProvider> <TooltipProvider>
<Tooltip> <Tooltip open={isDesktop ? undefined : false}>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Button <Button
variant="ghost" variant="ghost"
@ -85,7 +87,7 @@ export function PublicChatSnapshotRow({
</TooltipProvider> </TooltipProvider>
{canDelete && ( {canDelete && (
<TooltipProvider> <TooltipProvider>
<Tooltip> <Tooltip open={isDesktop ? undefined : false}>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Button <Button
variant="ghost" variant="ghost"
@ -126,7 +128,7 @@ export function PublicChatSnapshotRow({
</p> </p>
</div> </div>
<TooltipProvider> <TooltipProvider>
<Tooltip> <Tooltip open={isDesktop ? undefined : false}>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Button <Button
variant="ghost" variant="ghost"
@ -153,24 +155,17 @@ export function PublicChatSnapshotRow({
<> <>
<span className="text-muted-foreground/30">·</span> <span className="text-muted-foreground/30">·</span>
<TooltipProvider> <TooltipProvider>
<Tooltip> <Tooltip open={isDesktop ? undefined : false}>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<div className="flex items-center gap-1.5 cursor-default"> <div className="flex items-center gap-1.5 cursor-default">
{member.avatarUrl ? ( <Avatar className="size-4.5 shrink-0">
<Image {member.avatarUrl && (
src={member.avatarUrl} <AvatarImage src={member.avatarUrl} alt={member.name} />
alt={member.name}
width={18}
height={18}
className="h-4.5 w-4.5 rounded-full object-cover shrink-0"
/>
) : (
<div className="flex h-4.5 w-4.5 items-center justify-center rounded-full bg-gradient-to-br from-primary/20 to-primary/5 shrink-0">
<span className="text-[9px] font-semibold text-primary">
{getInitials(member.name)}
</span>
</div>
)} )}
<AvatarFallback className="text-[9px]">
{getInitials(member.name)}
</AvatarFallback>
</Avatar>
<span className="text-[11px] text-muted-foreground/60 truncate max-w-[120px]"> <span className="text-[11px] text-muted-foreground/60 truncate max-w-[120px]">
{member.name} {member.name}
</span> </span>

View file

@ -307,7 +307,7 @@ export function ReportPanelContent({
size="sm" size="sm"
onClick={handleCopy} onClick={handleCopy}
disabled={isLoading || !reportContent?.content} disabled={isLoading || !reportContent?.content}
className="h-8 min-w-[80px] px-3.5 py-4 text-[15px]" className="h-8 min-w-[80px] px-3.5 py-4 text-[15px] bg-sidebar select-none"
> >
{copied ? "Copied" : "Copy"} {copied ? "Copied" : "Copy"}
</Button> </Button>
@ -319,7 +319,7 @@ export function ReportPanelContent({
variant="outline" variant="outline"
size="sm" size="sm"
disabled={isLoading || !reportContent?.content} disabled={isLoading || !reportContent?.content}
className="h-8 px-3.5 py-4 text-[15px] gap-1.5" className="h-8 px-3.5 py-4 text-[15px] gap-1.5 bg-sidebar select-none"
> >
Export Export
<ChevronDownIcon className="size-3" /> <ChevronDownIcon className="size-3" />
@ -327,7 +327,7 @@ export function ReportPanelContent({
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent <DropdownMenuContent
align="start" align="start"
className={`min-w-[200px] dark:bg-neutral-900 dark:border dark:border-white/5${insideDrawer ? " z-[100]" : ""}`} className={`min-w-[200px] select-none${insideDrawer ? " z-[100]" : ""}`}
> >
{!shareToken && ( {!shareToken && (
<> <>
@ -398,14 +398,18 @@ export function ReportPanelContent({
{versions.length > 1 && ( {versions.length > 1 && (
<DropdownMenu modal={insideDrawer ? false : undefined}> <DropdownMenu modal={insideDrawer ? false : undefined}>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="h-8 px-3.5 py-4 text-[15px] gap-1.5"> <Button
variant="outline"
size="sm"
className="h-8 px-3.5 py-4 text-[15px] gap-1.5 bg-sidebar select-none"
>
v{activeVersionIndex + 1} v{activeVersionIndex + 1}
<ChevronDownIcon className="size-3" /> <ChevronDownIcon className="size-3" />
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent <DropdownMenuContent
align="start" align="start"
className={`min-w-[120px] dark:bg-neutral-900 dark:border dark:border-white/5${insideDrawer ? " z-[100]" : ""}`} className={`min-w-[120px] select-none${insideDrawer ? " z-[100]" : ""}`}
> >
{versions.map((v, i) => ( {versions.map((v, i) => (
<DropdownMenuItem <DropdownMenuItem
@ -455,6 +459,7 @@ export function ReportPanelContent({
onSave={handleSave} onSave={handleSave}
hasUnsavedChanges={editedMarkdown !== null} hasUnsavedChanges={editedMarkdown !== null}
isSaving={saving} isSaving={saving}
className="[&_[role=toolbar]]:!bg-sidebar"
/> />
) )
) : ( ) : (
@ -491,7 +496,7 @@ function DesktopReportPanel() {
return ( return (
<div <div
ref={panelRef} ref={panelRef}
className="flex w-[50%] max-w-[700px] min-w-[380px] flex-col border-l bg-background animate-in slide-in-from-right-4 duration-300 ease-out" className="flex w-[50%] max-w-[700px] min-w-[380px] flex-col border-l bg-sidebar text-sidebar-foreground animate-in slide-in-from-right-4 duration-300 ease-out"
> >
<ReportPanelContent <ReportPanelContent
reportId={panelState.reportId} reportId={panelState.reportId}
@ -521,7 +526,7 @@ function MobileReportDrawer() {
shouldScaleBackground={false} shouldScaleBackground={false}
> >
<DrawerContent <DrawerContent
className="h-[90vh] max-h-[90vh] z-80 !rounded-none border-none" className="h-[95vh] max-h-[95vh] z-80 bg-sidebar overflow-hidden"
overlayClassName="z-80" overlayClassName="z-80"
> >
<DrawerHandle /> <DrawerHandle />

View file

@ -2,7 +2,7 @@
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
import { Info, RotateCcw, Save } from "lucide-react"; import { Info } from "lucide-react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
@ -81,14 +81,6 @@ export function GeneralSettingsManager({ searchSpaceId }: GeneralSettingsManager
} }
}; };
const handleReset = () => {
if (searchSpace) {
setName(searchSpace.name || "");
setDescription(searchSpace.description || "");
setHasChanges(false);
}
};
if (loading) { if (loading) {
return ( return (
<div className="space-y-4 md:space-y-6"> <div className="space-y-4 md:space-y-6">
@ -161,37 +153,16 @@ export function GeneralSettingsManager({ searchSpaceId }: GeneralSettingsManager
</Card> </Card>
{/* Action Buttons */} {/* Action Buttons */}
<div className="flex items-center justify-between pt-3 md:pt-4 gap-2"> <div className="flex justify-end pt-3 md:pt-4">
<Button <Button
variant="outline" variant="outline"
onClick={handleReset}
disabled={!hasChanges || saving}
className="flex items-center gap-2 text-xs md:text-sm h-9 md:h-10"
>
<RotateCcw className="h-3.5 w-3.5 md:h-4 md:w-4" />
{t("general_reset")}
</Button>
<Button
onClick={handleSave} onClick={handleSave}
disabled={!hasChanges || saving || !name.trim()} disabled={!hasChanges || saving || !name.trim()}
className="flex items-center gap-2 text-xs md:text-sm h-9 md:h-10" className="gap-2 bg-white text-black hover:bg-neutral-100 dark:bg-white dark:text-black dark:hover:bg-neutral-200"
> >
<Save className="h-3.5 w-3.5 md:h-4 md:w-4" />
{saving ? t("general_saving") : t("general_save")} {saving ? t("general_saving") : t("general_save")}
</Button> </Button>
</div> </div>
{hasChanges && (
<Alert
variant="default"
className="bg-blue-50 dark:bg-blue-950/20 border-blue-200 dark:border-blue-800 py-3 md:py-4"
>
<Info className="h-3 w-3 md:h-4 md:w-4 text-blue-600 dark:text-blue-500 shrink-0" />
<AlertDescription className="text-blue-800 dark:text-blue-300 text-xs md:text-sm">
{t("general_unsaved_changes")}
</AlertDescription>
</Alert>
)}
</div> </div>
); );
} }

View file

@ -13,10 +13,9 @@ import {
Trash2, Trash2,
Wand2, Wand2,
} from "lucide-react"; } from "lucide-react";
import { AnimatePresence, motion } from "motion/react";
import Image from "next/image";
import { useCallback, useMemo, useState } from "react"; import { useCallback, useMemo, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { import {
createImageGenConfigMutationAtom, createImageGenConfigMutationAtom,
deleteImageGenConfigMutationAtom, deleteImageGenConfigMutationAtom,
@ -70,6 +69,7 @@ import { Separator } from "@/components/ui/separator";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { Spinner } from "@/components/ui/spinner"; import { Spinner } from "@/components/ui/spinner";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { useMediaQuery } from "@/hooks/use-media-query";
import { import {
getImageGenModelsByProvider, getImageGenModelsByProvider,
IMAGE_GEN_PROVIDERS, IMAGE_GEN_PROVIDERS,
@ -82,16 +82,6 @@ interface ImageModelManagerProps {
searchSpaceId: number; searchSpaceId: number;
} }
const container = {
hidden: { opacity: 0 },
show: { opacity: 1, transition: { staggerChildren: 0.05 } },
};
const item = {
hidden: { opacity: 0, y: 20 },
show: { opacity: 1, y: 0 },
};
function getInitials(name: string): string { function getInitials(name: string): string {
const parts = name.trim().split(/\s+/); const parts = name.trim().split(/\s+/);
if (parts.length >= 2) { if (parts.length >= 2) {
@ -101,6 +91,7 @@ function getInitials(name: string): string {
} }
export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) { export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) {
const isDesktop = useMediaQuery("(min-width: 768px)");
// Image gen config atoms // Image gen config atoms
const { const {
mutateAsync: createConfig, mutateAsync: createConfig,
@ -282,19 +273,20 @@ export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) {
{/* Header */} {/* Header */}
<div className="flex flex-col space-y-4 sm:flex-row sm:items-center sm:justify-between sm:space-y-0"> <div className="flex flex-col space-y-4 sm:flex-row sm:items-center sm:justify-between sm:space-y-0">
<Button <Button
variant="outline" variant="secondary"
size="sm" size="sm"
onClick={() => refreshConfigs()} onClick={() => refreshConfigs()}
disabled={isLoading} disabled={isLoading}
className="flex items-center gap-2 text-xs md:text-sm h-8 md:h-9" className="gap-2"
> >
<RefreshCw className={cn("h-3 w-3 md:h-4 md:w-4", configsLoading && "animate-spin")} /> <RefreshCw className={cn("h-3.5 w-3.5", configsLoading && "animate-spin")} />
Refresh Refresh
</Button> </Button>
{canCreate && ( {canCreate && (
<Button <Button
variant="outline"
onClick={openNewDialog} onClick={openNewDialog}
className="flex items-center gap-2 text-xs md:text-sm h-8 md:h-9" className="gap-2 bg-white text-black hover:bg-neutral-100 dark:bg-white dark:text-black dark:hover:bg-neutral-200"
> >
Add Image Model Add Image Model
</Button> </Button>
@ -302,25 +294,18 @@ export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) {
</div> </div>
{/* Errors */} {/* Errors */}
<AnimatePresence>
{errors.map((err) => ( {errors.map((err) => (
<motion.div <div key={err?.message}>
key={err?.message}
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
>
<Alert variant="destructive" className="py-3"> <Alert variant="destructive" className="py-3">
<AlertCircle className="h-3 w-3 md:h-4 md:w-4 shrink-0" /> <AlertCircle className="h-3 w-3 md:h-4 md:w-4 shrink-0" />
<AlertDescription className="text-xs md:text-sm">{err?.message}</AlertDescription> <AlertDescription className="text-xs md:text-sm">{err?.message}</AlertDescription>
</Alert> </Alert>
</motion.div> </div>
))} ))}
</AnimatePresence>
{/* Read-only / Limited permissions notice */} {/* Read-only / Limited permissions notice */}
{access && !isLoading && isReadOnly && ( {access && !isLoading && isReadOnly && (
<motion.div initial={{ opacity: 0, y: -10 }} animate={{ opacity: 1, y: 0 }}> <div>
<Alert className="bg-muted/50 py-3 md:py-4"> <Alert className="bg-muted/50 py-3 md:py-4">
<Info className="h-3 w-3 md:h-4 md:w-4 shrink-0" /> <Info className="h-3 w-3 md:h-4 md:w-4 shrink-0" />
<AlertDescription className="text-xs md:text-sm"> <AlertDescription className="text-xs md:text-sm">
@ -328,10 +313,10 @@ export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) {
configurations. Contact a space owner to request additional permissions. configurations. Contact a space owner to request additional permissions.
</AlertDescription> </AlertDescription>
</Alert> </Alert>
</motion.div> </div>
)} )}
{access && !isLoading && !isReadOnly && (!canCreate || !canDelete) && ( {access && !isLoading && !isReadOnly && (!canCreate || !canDelete) && (
<motion.div initial={{ opacity: 0, y: -10 }} animate={{ opacity: 1, y: 0 }}> <div>
<Alert className="bg-muted/50 py-3 md:py-4"> <Alert className="bg-muted/50 py-3 md:py-4">
<Info className="h-3 w-3 md:h-4 md:w-4 shrink-0" /> <Info className="h-3 w-3 md:h-4 md:w-4 shrink-0" />
<AlertDescription className="text-xs md:text-sm"> <AlertDescription className="text-xs md:text-sm">
@ -343,7 +328,7 @@ export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) {
{!canDelete && ", but cannot delete them"}. {!canDelete && ", but cannot delete them"}.
</AlertDescription> </AlertDescription>
</Alert> </Alert>
</motion.div> </div>
)} )}
{/* Global info */} {/* Global info */}
@ -429,23 +414,12 @@ export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) {
</CardContent> </CardContent>
</Card> </Card>
) : ( ) : (
<motion.div <div className="grid gap-3 grid-cols-1 sm:grid-cols-2 xl:grid-cols-3">
variants={container}
initial="hidden"
animate="show"
className="grid gap-3 grid-cols-1 sm:grid-cols-2 xl:grid-cols-3"
>
<AnimatePresence mode="popLayout">
{userConfigs?.map((config) => { {userConfigs?.map((config) => {
const member = config.user_id ? memberMap.get(config.user_id) : null; const member = config.user_id ? memberMap.get(config.user_id) : null;
return ( return (
<motion.div <div key={config.id}>
key={config.id}
variants={item}
layout
exit={{ opacity: 0, scale: 0.95 }}
>
<Card className="group relative overflow-hidden transition-all duration-200 border-border/60 hover:shadow-md h-full"> <Card className="group relative overflow-hidden transition-all duration-200 border-border/60 hover:shadow-md h-full">
<CardContent className="p-4 flex flex-col gap-3 h-full"> <CardContent className="p-4 flex flex-col gap-3 h-full">
{/* Header: Name + Actions */} {/* Header: Name + Actions */}
@ -464,7 +438,7 @@ export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) {
<div className="flex items-center gap-0.5 shrink-0 sm:opacity-0 sm:group-hover:opacity-100 transition-opacity duration-150"> <div className="flex items-center gap-0.5 shrink-0 sm:opacity-0 sm:group-hover:opacity-100 transition-opacity duration-150">
{canUpdate && ( {canUpdate && (
<TooltipProvider> <TooltipProvider>
<Tooltip> <Tooltip open={isDesktop ? undefined : false}>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Button <Button
variant="ghost" variant="ghost"
@ -481,7 +455,7 @@ export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) {
)} )}
{canDelete && ( {canDelete && (
<TooltipProvider> <TooltipProvider>
<Tooltip> <Tooltip open={isDesktop ? undefined : false}>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Button <Button
variant="ghost" variant="ghost"
@ -521,24 +495,17 @@ export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) {
<> <>
<span className="text-muted-foreground/30">·</span> <span className="text-muted-foreground/30">·</span>
<TooltipProvider> <TooltipProvider>
<Tooltip> <Tooltip open={isDesktop ? undefined : false}>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<div className="flex items-center gap-1.5 cursor-default"> <div className="flex items-center gap-1.5 cursor-default">
{member.avatarUrl ? ( <Avatar className="size-4.5 shrink-0">
<Image {member.avatarUrl && (
src={member.avatarUrl} <AvatarImage src={member.avatarUrl} alt={member.name} />
alt={member.name}
width={18}
height={18}
className="h-4.5 w-4.5 rounded-full object-cover shrink-0"
/>
) : (
<div className="flex h-4.5 w-4.5 items-center justify-center rounded-full bg-gradient-to-br from-primary/20 to-primary/5 shrink-0">
<span className="text-[9px] font-semibold text-primary">
{getInitials(member.name)}
</span>
</div>
)} )}
<AvatarFallback className="text-[9px]">
{getInitials(member.name)}
</AvatarFallback>
</Avatar>
<span className="text-[11px] text-muted-foreground/60 truncate max-w-[120px]"> <span className="text-[11px] text-muted-foreground/60 truncate max-w-[120px]">
{member.name} {member.name}
</span> </span>
@ -554,11 +521,10 @@ export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) {
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
</motion.div> </div>
); );
})} })}
</AnimatePresence> </div>
</motion.div>
)} )}
</div> </div>
)} )}
@ -733,10 +699,9 @@ export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) {
)} )}
{/* Actions */} {/* Actions */}
<div className="flex gap-3 pt-4 border-t"> <div className="flex justify-end gap-3 pt-4 border-t">
<Button <Button
variant="outline" variant="secondary"
className="flex-1"
onClick={() => { onClick={() => {
setIsDialogOpen(false); setIsDialogOpen(false);
setEditingConfig(null); setEditingConfig(null);
@ -746,7 +711,6 @@ export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) {
Cancel Cancel
</Button> </Button>
<Button <Button
className="flex-1"
onClick={handleFormSubmit} onClick={handleFormSubmit}
disabled={ disabled={
isSubmitting || isSubmitting ||

View file

@ -13,7 +13,6 @@ import {
Save, Save,
Shuffle, Shuffle,
} from "lucide-react"; } from "lucide-react";
import { AnimatePresence, motion } from "motion/react";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { import {
@ -229,13 +228,13 @@ export function LLMRoleManager({ searchSpaceId }: LLMRoleManagerProps) {
{/* Header actions */} {/* Header actions */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<Button <Button
variant="outline" variant="secondary"
size="sm" size="sm"
onClick={() => refreshConfigs()} onClick={() => refreshConfigs()}
disabled={isLoading} disabled={isLoading}
className="flex items-center gap-2 text-xs md:text-sm h-8 md:h-9" className="gap-2"
> >
<RefreshCw className="h-3 w-3 md:h-4 md:w-4" /> <RefreshCw className="h-3.5 w-3.5" />
Refresh Refresh
</Button> </Button>
{isAssignmentComplete && !isLoading && !hasError && ( {isAssignmentComplete && !isLoading && !hasError && (
@ -250,14 +249,8 @@ export function LLMRoleManager({ searchSpaceId }: LLMRoleManagerProps) {
</div> </div>
{/* Error Alert */} {/* Error Alert */}
<AnimatePresence>
{hasError && ( {hasError && (
<motion.div <div>
key="error-alert"
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
>
<Alert variant="destructive" className="py-3 md:py-4"> <Alert variant="destructive" className="py-3 md:py-4">
<AlertCircle className="h-3 w-3 md:h-4 md:w-4 shrink-0" /> <AlertCircle className="h-3 w-3 md:h-4 md:w-4 shrink-0" />
<AlertDescription className="text-xs md:text-sm"> <AlertDescription className="text-xs md:text-sm">
@ -266,9 +259,8 @@ export function LLMRoleManager({ searchSpaceId }: LLMRoleManagerProps) {
(globalConfigsError?.message ?? "Failed to load global configurations")} (globalConfigsError?.message ?? "Failed to load global configurations")}
</AlertDescription> </AlertDescription>
</Alert> </Alert>
</motion.div> </div>
)} )}
</AnimatePresence>
{/* Loading Skeleton */} {/* Loading Skeleton */}
{isLoading && ( {isLoading && (
@ -322,13 +314,8 @@ export function LLMRoleManager({ searchSpaceId }: LLMRoleManagerProps) {
{/* Role Assignment Cards */} {/* Role Assignment Cards */}
{!isLoading && !hasError && hasAnyConfigs && ( {!isLoading && !hasError && hasAnyConfigs && (
<motion.div <div className="grid gap-4 grid-cols-1 lg:grid-cols-2">
initial={{ opacity: 0 }} {Object.entries(ROLE_DESCRIPTIONS).map(([key, role]) => {
animate={{ opacity: 1 }}
transition={{ duration: 0.3 }}
className="grid gap-4 grid-cols-1 lg:grid-cols-2"
>
{Object.entries(ROLE_DESCRIPTIONS).map(([key, role], index) => {
const IconComponent = role.icon; const IconComponent = role.icon;
const isImageRole = role.configType === "image"; const isImageRole = role.configType === "image";
const currentAssignment = assignments[role.prefKey as keyof typeof assignments]; const currentAssignment = assignments[role.prefKey as keyof typeof assignments];
@ -349,12 +336,7 @@ export function LLMRoleManager({ searchSpaceId }: LLMRoleManagerProps) {
assignedConfig && "is_auto_mode" in assignedConfig && assignedConfig.is_auto_mode; assignedConfig && "is_auto_mode" in assignedConfig && assignedConfig.is_auto_mode;
return ( return (
<motion.div <div key={key}>
key={key}
initial={{ opacity: 0, y: 15 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: index * 0.08, duration: 0.3 }}
>
<Card className="group relative overflow-hidden transition-all duration-200 border-border/60 hover:shadow-md h-full"> <Card className="group relative overflow-hidden transition-all duration-200 border-border/60 hover:shadow-md h-full">
<CardContent className="p-4 md:p-5 space-y-4"> <CardContent className="p-4 md:p-5 space-y-4">
{/* Role Header */} {/* Role Header */}
@ -542,22 +524,15 @@ export function LLMRoleManager({ searchSpaceId }: LLMRoleManagerProps) {
)} )}
</CardContent> </CardContent>
</Card> </Card>
</motion.div> </div>
); );
})} })}
</motion.div> </div>
)} )}
{/* Save / Reset Bar */} {/* Save / Reset Bar */}
<AnimatePresence>
{hasChanges && ( {hasChanges && (
<motion.div <div className="flex items-center justify-between gap-3 rounded-lg border border-border bg-muted/50 p-3 md:p-4">
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 10 }}
transition={{ duration: 0.2 }}
className="flex items-center justify-between gap-3 rounded-lg border border-border bg-muted/50 p-3 md:p-4"
>
<p className="text-xs md:text-sm text-muted-foreground">You have unsaved changes</p> <p className="text-xs md:text-sm text-muted-foreground">You have unsaved changes</p>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Button <Button
@ -580,9 +555,8 @@ export function LLMRoleManager({ searchSpaceId }: LLMRoleManagerProps) {
{isSaving ? "Saving…" : "Save Changes"} {isSaving ? "Saving…" : "Save Changes"}
</Button> </Button>
</div> </div>
</motion.div> </div>
)} )}
</AnimatePresence>
</div> </div>
); );
} }

View file

@ -12,9 +12,8 @@ import {
Trash2, Trash2,
Wand2, Wand2,
} from "lucide-react"; } from "lucide-react";
import { AnimatePresence, motion } from "motion/react";
import Image from "next/image";
import { useCallback, useMemo, useState } from "react"; import { useCallback, useMemo, useState } from "react";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { membersAtom, myAccessAtom } from "@/atoms/members/members-query.atoms"; import { membersAtom, myAccessAtom } from "@/atoms/members/members-query.atoms";
import { import {
createNewLLMConfigMutationAtom, createNewLLMConfigMutationAtom,
@ -51,6 +50,7 @@ import { Skeleton } from "@/components/ui/skeleton";
import { Spinner } from "@/components/ui/spinner"; import { Spinner } from "@/components/ui/spinner";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import type { NewLLMConfig } from "@/contracts/types/new-llm-config.types"; import type { NewLLMConfig } from "@/contracts/types/new-llm-config.types";
import { useMediaQuery } from "@/hooks/use-media-query";
import { getProviderIcon } from "@/lib/provider-icons"; import { getProviderIcon } from "@/lib/provider-icons";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@ -58,21 +58,6 @@ interface ModelConfigManagerProps {
searchSpaceId: number; searchSpaceId: number;
} }
const container = {
hidden: { opacity: 0 },
show: {
opacity: 1,
transition: {
staggerChildren: 0.05,
},
},
};
const item = {
hidden: { opacity: 0, y: 20 },
show: { opacity: 1, y: 0 },
};
function getInitials(name: string): string { function getInitials(name: string): string {
const parts = name.trim().split(/\s+/); const parts = name.trim().split(/\s+/);
if (parts.length >= 2) { if (parts.length >= 2) {
@ -82,6 +67,7 @@ function getInitials(name: string): string {
} }
export function ModelConfigManager({ searchSpaceId }: ModelConfigManagerProps) { export function ModelConfigManager({ searchSpaceId }: ModelConfigManagerProps) {
const isDesktop = useMediaQuery("(min-width: 768px)");
// Mutations // Mutations
const { mutateAsync: createConfig, isPending: isCreating } = useAtomValue( const { mutateAsync: createConfig, isPending: isCreating } = useAtomValue(
createNewLLMConfigMutationAtom createNewLLMConfigMutationAtom
@ -195,20 +181,20 @@ export function ModelConfigManager({ searchSpaceId }: ModelConfigManagerProps) {
{/* Header actions */} {/* Header actions */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<Button <Button
variant="outline" variant="secondary"
size="sm" size="sm"
onClick={() => refreshConfigs()} onClick={() => refreshConfigs()}
disabled={isLoading} disabled={isLoading}
className="flex items-center gap-2 text-xs md:text-sm h-8 md:h-9" className="gap-2"
> >
<RefreshCw className={cn("h-3 w-3 md:h-4 md:w-4", isLoading && "animate-spin")} /> <RefreshCw className={cn("h-3.5 w-3.5", isLoading && "animate-spin")} />
Refresh Refresh
</Button> </Button>
{canCreate && ( {canCreate && (
<Button <Button
variant="outline"
onClick={openNewDialog} onClick={openNewDialog}
size="sm" className="gap-2 bg-white text-black hover:bg-neutral-100 dark:bg-white dark:text-black dark:hover:bg-neutral-200"
className="flex items-center gap-2 text-xs md:text-sm h-8 md:h-9"
> >
Add Configuration Add Configuration
</Button> </Button>
@ -216,27 +202,20 @@ export function ModelConfigManager({ searchSpaceId }: ModelConfigManagerProps) {
</div> </div>
{/* Fetch Error Alert */} {/* Fetch Error Alert */}
<AnimatePresence>
{fetchError && ( {fetchError && (
<motion.div <div>
key="fetch-error"
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
>
<Alert variant="destructive" className="py-3 md:py-4"> <Alert variant="destructive" className="py-3 md:py-4">
<AlertCircle className="h-3 w-3 md:h-4 md:w-4 shrink-0" /> <AlertCircle className="h-3 w-3 md:h-4 md:w-4 shrink-0" />
<AlertDescription className="text-xs md:text-sm"> <AlertDescription className="text-xs md:text-sm">
{fetchError?.message ?? "Failed to load configurations"} {fetchError?.message ?? "Failed to load configurations"}
</AlertDescription> </AlertDescription>
</Alert> </Alert>
</motion.div> </div>
)} )}
</AnimatePresence>
{/* Read-only / Limited permissions notice */} {/* Read-only / Limited permissions notice */}
{access && !isLoading && isReadOnly && ( {access && !isLoading && isReadOnly && (
<motion.div initial={{ opacity: 0, y: -10 }} animate={{ opacity: 1, y: 0 }}> <div>
<Alert className="bg-muted/50 py-3 md:py-4"> <Alert className="bg-muted/50 py-3 md:py-4">
<Info className="h-3 w-3 md:h-4 md:w-4 shrink-0" /> <Info className="h-3 w-3 md:h-4 md:w-4 shrink-0" />
<AlertDescription className="text-xs md:text-sm"> <AlertDescription className="text-xs md:text-sm">
@ -244,10 +223,10 @@ export function ModelConfigManager({ searchSpaceId }: ModelConfigManagerProps) {
Contact a space owner to request additional permissions. Contact a space owner to request additional permissions.
</AlertDescription> </AlertDescription>
</Alert> </Alert>
</motion.div> </div>
)} )}
{access && !isLoading && !isReadOnly && (!canCreate || !canUpdate || !canDelete) && ( {access && !isLoading && !isReadOnly && (!canCreate || !canUpdate || !canDelete) && (
<motion.div initial={{ opacity: 0, y: -10 }} animate={{ opacity: 1, y: 0 }}> <div>
<Alert className="bg-muted/50 py-3 md:py-4"> <Alert className="bg-muted/50 py-3 md:py-4">
<Info className="h-3 w-3 md:h-4 md:w-4 shrink-0" /> <Info className="h-3 w-3 md:h-4 md:w-4 shrink-0" />
<AlertDescription className="text-xs md:text-sm"> <AlertDescription className="text-xs md:text-sm">
@ -259,12 +238,12 @@ export function ModelConfigManager({ searchSpaceId }: ModelConfigManagerProps) {
{!canDelete && ", but cannot delete them"}. {!canDelete && ", but cannot delete them"}.
</AlertDescription> </AlertDescription>
</Alert> </Alert>
</motion.div> </div>
)} )}
{/* Global Configs Info */} {/* Global Configs Info */}
{globalConfigs.length > 0 && ( {globalConfigs.length > 0 && (
<motion.div initial={{ opacity: 0, y: -10 }} animate={{ opacity: 1, y: 0 }}> <div>
<Alert className="bg-muted/50 py-3 md:py-4"> <Alert className="bg-muted/50 py-3 md:py-4">
<Info className="h-3 w-3 md:h-4 md:w-4 shrink-0" /> <Info className="h-3 w-3 md:h-4 md:w-4 shrink-0" />
<AlertDescription className="text-xs md:text-sm"> <AlertDescription className="text-xs md:text-sm">
@ -275,7 +254,7 @@ export function ModelConfigManager({ searchSpaceId }: ModelConfigManagerProps) {
</span> </span>
</AlertDescription> </AlertDescription>
</Alert> </Alert>
</motion.div> </div>
)} )}
{/* Loading Skeleton */} {/* Loading Skeleton */}
@ -317,7 +296,7 @@ export function ModelConfigManager({ searchSpaceId }: ModelConfigManagerProps) {
{!isLoading && ( {!isLoading && (
<div className="space-y-4"> <div className="space-y-4">
{configs?.length === 0 ? ( {configs?.length === 0 ? (
<motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }}> <div>
<Card className="border-dashed border-2 border-muted-foreground/25"> <Card className="border-dashed border-2 border-muted-foreground/25">
<CardContent className="flex flex-col items-center justify-center py-10 md:py-16 text-center"> <CardContent className="flex flex-col items-center justify-center py-10 md:py-16 text-center">
<div className="rounded-full bg-gradient-to-br from-violet-500/10 to-purple-500/10 p-4 md:p-6 mb-4 md:mb-6"> <div className="rounded-full bg-gradient-to-br from-violet-500/10 to-purple-500/10 p-4 md:p-6 mb-4 md:mb-6">
@ -343,25 +322,14 @@ export function ModelConfigManager({ searchSpaceId }: ModelConfigManagerProps) {
)} )}
</CardContent> </CardContent>
</Card> </Card>
</motion.div> </div>
) : ( ) : (
<motion.div <div className="grid gap-3 grid-cols-1 sm:grid-cols-2 xl:grid-cols-3">
variants={container}
initial="hidden"
animate="show"
className="grid gap-3 grid-cols-1 sm:grid-cols-2 xl:grid-cols-3"
>
<AnimatePresence mode="popLayout">
{configs?.map((config) => { {configs?.map((config) => {
const member = config.user_id ? memberMap.get(config.user_id) : null; const member = config.user_id ? memberMap.get(config.user_id) : null;
return ( return (
<motion.div <div key={config.id}>
key={config.id}
variants={item}
layout
exit={{ opacity: 0, scale: 0.95 }}
>
<Card className="group relative overflow-hidden transition-all duration-200 border-border/60 hover:shadow-md h-full"> <Card className="group relative overflow-hidden transition-all duration-200 border-border/60 hover:shadow-md h-full">
<CardContent className="p-4 flex flex-col gap-3 h-full"> <CardContent className="p-4 flex flex-col gap-3 h-full">
{/* Header: Name + Actions */} {/* Header: Name + Actions */}
@ -380,7 +348,7 @@ export function ModelConfigManager({ searchSpaceId }: ModelConfigManagerProps) {
<div className="flex items-center gap-0.5 shrink-0 sm:opacity-0 sm:group-hover:opacity-100 transition-opacity duration-150"> <div className="flex items-center gap-0.5 shrink-0 sm:opacity-0 sm:group-hover:opacity-100 transition-opacity duration-150">
{canUpdate && ( {canUpdate && (
<TooltipProvider> <TooltipProvider>
<Tooltip> <Tooltip open={isDesktop ? undefined : false}>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Button <Button
variant="ghost" variant="ghost"
@ -397,7 +365,7 @@ export function ModelConfigManager({ searchSpaceId }: ModelConfigManagerProps) {
)} )}
{canDelete && ( {canDelete && (
<TooltipProvider> <TooltipProvider>
<Tooltip> <Tooltip open={isDesktop ? undefined : false}>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Button <Button
variant="ghost" variant="ghost"
@ -460,24 +428,17 @@ export function ModelConfigManager({ searchSpaceId }: ModelConfigManagerProps) {
<> <>
<span className="text-muted-foreground/30">·</span> <span className="text-muted-foreground/30">·</span>
<TooltipProvider> <TooltipProvider>
<Tooltip> <Tooltip open={isDesktop ? undefined : false}>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<div className="flex items-center gap-1.5 cursor-default"> <div className="flex items-center gap-1.5 cursor-default">
{member.avatarUrl ? ( <Avatar className="size-4.5 shrink-0">
<Image {member.avatarUrl && (
src={member.avatarUrl} <AvatarImage src={member.avatarUrl} alt={member.name} />
alt={member.name}
width={18}
height={18}
className="h-4.5 w-4.5 rounded-full object-cover shrink-0"
/>
) : (
<div className="flex h-4.5 w-4.5 items-center justify-center rounded-full bg-gradient-to-br from-primary/20 to-primary/5 shrink-0">
<span className="text-[9px] font-semibold text-primary">
{getInitials(member.name)}
</span>
</div>
)} )}
<AvatarFallback className="text-[9px]">
{getInitials(member.name)}
</AvatarFallback>
</Avatar>
<span className="text-[11px] text-muted-foreground/60 truncate max-w-[120px]"> <span className="text-[11px] text-muted-foreground/60 truncate max-w-[120px]">
{member.name} {member.name}
</span> </span>
@ -493,11 +454,10 @@ export function ModelConfigManager({ searchSpaceId }: ModelConfigManagerProps) {
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
</motion.div> </div>
); );
})} })}
</AnimatePresence> </div>
</motion.div>
)} )}
</div> </div>
)} )}

View file

@ -0,0 +1,206 @@
"use client";
import { IconCalendar, IconMailFilled } from "@tabler/icons-react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Check, ExternalLink, Gift, Mail, Star, Zap } from "lucide-react";
import Link from "next/link";
import { useEffect } from "react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Separator } from "@/components/ui/separator";
import { Skeleton } from "@/components/ui/skeleton";
import { Spinner } from "@/components/ui/spinner";
import type { IncentiveTaskInfo } from "@/contracts/types/incentive-tasks.types";
import { incentiveTasksApiService } from "@/lib/apis/incentive-tasks-api.service";
import {
trackIncentiveContactOpened,
trackIncentivePageViewed,
trackIncentiveTaskClicked,
trackIncentiveTaskCompleted,
} from "@/lib/posthog/events";
import { cn } from "@/lib/utils";
export function MorePagesContent() {
const queryClient = useQueryClient();
useEffect(() => {
trackIncentivePageViewed();
}, []);
const { data, isLoading } = useQuery({
queryKey: ["incentive-tasks"],
queryFn: () => incentiveTasksApiService.getTasks(),
});
const completeMutation = useMutation({
mutationFn: incentiveTasksApiService.completeTask,
onSuccess: (response, taskType) => {
if (response.success) {
toast.success(response.message);
const task = data?.tasks.find((t) => t.task_type === taskType);
if (task) {
trackIncentiveTaskCompleted(taskType, task.pages_reward);
}
queryClient.invalidateQueries({ queryKey: ["incentive-tasks"] });
queryClient.invalidateQueries({ queryKey: ["user"] });
}
},
onError: () => {
toast.error("Failed to complete task. Please try again.");
},
});
const handleTaskClick = (task: IncentiveTaskInfo) => {
if (!task.completed) {
trackIncentiveTaskClicked(task.task_type);
completeMutation.mutate(task.task_type);
}
};
return (
<div className="w-full space-y-6">
<div className="text-center">
<Gift className="mx-auto mb-3 h-8 w-8 text-primary" />
<h2 className="text-xl font-bold tracking-tight">Get More Pages</h2>
<p className="mt-1 text-sm text-muted-foreground">
Complete tasks to earn additional pages
</p>
</div>
{isLoading ? (
<Card>
<CardContent className="flex items-center gap-3 p-3">
<Skeleton className="h-9 w-9 rounded-full bg-muted" />
<div className="flex-1 space-y-2">
<Skeleton className="h-4 w-3/4 bg-muted" />
<Skeleton className="h-3 w-1/4 bg-muted" />
</div>
<Skeleton className="h-8 w-16 bg-muted" />
</CardContent>
</Card>
) : (
<div className="space-y-2">
{data?.tasks.map((task) => (
<Card
key={task.task_type}
className={cn("transition-colors bg-transparent", task.completed && "bg-muted/50")}
>
<CardContent className="flex items-center gap-3 p-3">
<div
className={cn(
"flex h-9 w-9 shrink-0 items-center justify-center rounded-full",
task.completed ? "bg-primary text-primary-foreground" : "bg-muted"
)}
>
{task.completed ? <Check className="h-4 w-4" /> : <Star className="h-4 w-4" />}
</div>
<div className="min-w-0 flex-1">
<p
className={cn(
"text-sm font-medium",
task.completed && "text-muted-foreground line-through"
)}
>
{task.title}
</p>
<p className="text-xs text-muted-foreground">+{task.pages_reward} pages</p>
</div>
<Button
variant="ghost"
size="sm"
disabled={task.completed || completeMutation.isPending}
onClick={() => handleTaskClick(task)}
asChild={!task.completed}
className="text-muted-foreground hover:text-foreground"
>
{task.completed ? (
<span>Done</span>
) : (
<a
href={task.action_url}
target="_blank"
rel="noopener noreferrer"
className="gap-1"
>
{completeMutation.isPending ? (
<Spinner size="xs" />
) : (
<ExternalLink className="h-3 w-3" />
)}
</a>
)}
</Button>
</CardContent>
</Card>
))}
</div>
)}
<Separator />
<Card className="overflow-hidden border-emerald-500/20 bg-transparent">
<CardHeader className="pb-2">
<div className="flex items-center gap-2">
<Zap className="h-4 w-4 text-emerald-500" />
<CardTitle className="text-base">Upgrade to PRO</CardTitle>
<Badge className="bg-emerald-600 text-white border-transparent hover:bg-emerald-600">
FREE
</Badge>
</div>
<CardDescription>
For a limited time, get{" "}
<span className="font-semibold text-foreground">6,000 additional pages</span> at no
cost. Contact us and we&apos;ll upgrade your account instantly.
</CardDescription>
</CardHeader>
<CardFooter className="pt-2">
<Dialog onOpenChange={(open) => open && trackIncentiveContactOpened()}>
<DialogTrigger asChild>
<Button className="w-full bg-emerald-600 text-white hover:bg-emerald-700">
<Mail className="h-4 w-4" />
Contact Us to Upgrade
</Button>
</DialogTrigger>
<DialogContent className="select-none sm:max-w-sm">
<DialogHeader>
<DialogTitle>Get in Touch</DialogTitle>
<DialogDescription>Pick the option that works best for you.</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-2">
<Button asChild>
<Link href="https://cal.com/mod-rohan" target="_blank" rel="noopener noreferrer">
<IconCalendar className="h-4 w-4" />
Schedule a Meeting
</Link>
</Button>
<Button variant="outline" asChild>
<Link href="mailto:rohan@surfsense.com">
<IconMailFilled className="h-4 w-4" />
rohan@surfsense.com
</Link>
</Button>
</div>
</DialogContent>
</Dialog>
</CardFooter>
</Card>
</div>
);
}

View file

@ -0,0 +1,24 @@
"use client";
import { useAtom } from "jotai";
import { morePagesDialogAtom } from "@/atoms/settings/settings-dialog.atoms";
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
import { MorePagesContent } from "./more-pages-content";
export function MorePagesDialog() {
const [open, setOpen] = useAtom(morePagesDialogAtom);
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent
className="select-none max-w-md w-[95vw] max-h-[90vh] flex flex-col p-0 gap-0 overflow-hidden"
onOpenAutoFocus={(e) => e.preventDefault()}
>
<DialogTitle className="sr-only">Get More Pages</DialogTitle>
<div className="flex-1 overflow-y-auto p-6">
<MorePagesContent />
</div>
</DialogContent>
</Dialog>
);
}

View file

@ -1,7 +1,7 @@
"use client"; "use client";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { AlertTriangle, Info, RotateCcw, Save } from "lucide-react"; import { AlertTriangle, Info } from "lucide-react";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { Alert, AlertDescription } from "@/components/ui/alert"; import { Alert, AlertDescription } from "@/components/ui/alert";
@ -83,13 +83,6 @@ export function PromptConfigManager({ searchSpaceId }: PromptConfigManagerProps)
} }
}; };
const handleReset = () => {
if (searchSpace) {
setCustomInstructions(searchSpace.qna_custom_instructions || "");
setHasChanges(false);
}
};
if (loading) { if (loading) {
return ( return (
<div className="space-y-4 md:space-y-6"> <div className="space-y-4 md:space-y-6">
@ -184,37 +177,16 @@ export function PromptConfigManager({ searchSpaceId }: PromptConfigManagerProps)
</Card> </Card>
{/* Action Buttons */} {/* Action Buttons */}
<div className="flex items-center justify-between pt-3 md:pt-4 gap-2"> <div className="flex justify-end pt-3 md:pt-4">
<Button <Button
variant="outline" variant="outline"
onClick={handleReset}
disabled={!hasChanges || saving}
className="flex items-center gap-2 text-xs md:text-sm h-9 md:h-10"
>
<RotateCcw className="h-3.5 w-3.5 md:h-4 md:w-4" />
Reset Changes
</Button>
<Button
onClick={handleSave} onClick={handleSave}
disabled={!hasChanges || saving} disabled={!hasChanges || saving}
className="flex items-center gap-2 text-xs md:text-sm h-9 md:h-10" className="gap-2 bg-white text-black hover:bg-neutral-100 dark:bg-white dark:text-black dark:hover:bg-neutral-200"
> >
<Save className="h-3.5 w-3.5 md:h-4 md:w-4" />
{saving ? "Saving" : "Save Instructions"} {saving ? "Saving" : "Save Instructions"}
</Button> </Button>
</div> </div>
{hasChanges && (
<Alert
variant="default"
className="bg-blue-50 dark:bg-blue-950/20 border-blue-200 dark:border-blue-800 py-3 md:py-4"
>
<Info className="h-3 w-3 md:h-4 md:w-4 text-blue-600 dark:text-blue-500 shrink-0" />
<AlertDescription className="text-blue-800 dark:text-blue-300 text-xs md:text-sm">
You have unsaved changes. Click "Save Instructions" to apply them.
</AlertDescription>
</Alert>
)}
</div> </div>
); );
} }

View file

@ -14,13 +14,11 @@ import {
Mic, Mic,
MoreHorizontal, MoreHorizontal,
Plug, Plug,
Plus,
Settings, Settings,
Shield, Shield,
Trash2, Trash2,
Users, Users,
} from "lucide-react"; } from "lucide-react";
import { motion } from "motion/react";
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { myAccessAtom } from "@/atoms/members/members-query.atoms"; import { myAccessAtom } from "@/atoms/members/members-query.atoms";
@ -477,12 +475,7 @@ function RolesContent({
const editingRole = editingRoleId !== null ? roles.find((r) => r.id === editingRoleId) : null; const editingRole = editingRoleId !== null ? roles.find((r) => r.id === editingRoleId) : null;
return ( return (
<motion.div <div className="space-y-6">
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
className="space-y-6"
>
{canCreate && ( {canCreate && (
<div className="flex justify-end"> <div className="flex justify-end">
<Button <Button
@ -490,7 +483,6 @@ function RolesContent({
onClick={() => setShowCreateRole(true)} onClick={() => setShowCreateRole(true)}
className="gap-2 bg-white text-black hover:bg-neutral-100 dark:bg-white dark:text-black dark:hover:bg-neutral-200" className="gap-2 bg-white text-black hover:bg-neutral-100 dark:bg-white dark:text-black dark:hover:bg-neutral-200"
> >
<Plus className="h-4 w-4" />
Create Custom Role Create Custom Role
</Button> </Button>
</div> </div>
@ -516,13 +508,8 @@ function RolesContent({
)} )}
<div className="space-y-3"> <div className="space-y-3">
{roles.map((role, index) => ( {roles.map((role) => (
<motion.div <div key={role.id}>
key={role.id}
initial={{ opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: index * 0.04 }}
>
<RolePermissionsDialog permissions={role.permissions} roleName={role.name}> <RolePermissionsDialog permissions={role.permissions} roleName={role.name}>
<button <button
type="button" type="button"
@ -610,10 +597,10 @@ function RolesContent({
)} )}
</button> </button>
</RolePermissionsDialog> </RolePermissionsDialog>
</motion.div> </div>
))} ))}
</div> </div>
</motion.div> </div>
); );
} }
@ -695,17 +682,10 @@ function PermissionsEditor({
return ( return (
<div key={category} className="rounded-lg border border-border/60 overflow-hidden"> <div key={category} className="rounded-lg border border-border/60 overflow-hidden">
<div <button
role="button" type="button"
tabIndex={0}
className="w-full flex items-center justify-between px-3 py-2.5 cursor-pointer hover:bg-muted/40 transition-colors" className="w-full flex items-center justify-between px-3 py-2.5 cursor-pointer hover:bg-muted/40 transition-colors"
onClick={() => toggleCategoryExpanded(category)} onClick={() => toggleCategoryExpanded(category)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
toggleCategoryExpanded(category);
}
}}
> >
<div className="flex items-center gap-2.5"> <div className="flex items-center gap-2.5">
<IconComponent className="h-4 w-4 text-muted-foreground shrink-0" /> <IconComponent className="h-4 w-4 text-muted-foreground shrink-0" />
@ -721,9 +701,8 @@ function PermissionsEditor({
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
aria-label={`Select all ${config.label} permissions`} aria-label={`Select all ${config.label} permissions`}
/> />
<motion.div <div
animate={{ rotate: isExpanded ? 180 : 0 }} className={cn("transition-transform duration-200", isExpanded && "rotate-180")}
transition={{ duration: 0.2 }}
> >
<svg <svg
className="h-4 w-4 text-muted-foreground" className="h-4 w-4 text-muted-foreground"
@ -740,18 +719,12 @@ function PermissionsEditor({
d="M19 9l-7 7-7-7" d="M19 9l-7 7-7-7"
/> />
</svg> </svg>
</motion.div>
</div> </div>
</div> </div>
</button>
{isExpanded && ( {isExpanded && (
<motion.div <div className="border-t border-border/60">
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.2 }}
className="border-t border-border/60"
>
<div className="p-2 space-y-0.5"> <div className="p-2 space-y-0.5">
{perms.map((perm) => { {perms.map((perm) => {
const action = perm.value.split(":")[1]; const action = perm.value.split(":")[1];
@ -759,21 +732,14 @@ function PermissionsEditor({
const isSelected = selectedPermissions.includes(perm.value); const isSelected = selectedPermissions.includes(perm.value);
return ( return (
<div <button
key={perm.value} key={perm.value}
role="button" type="button"
tabIndex={0}
className={cn( className={cn(
"w-full flex items-center justify-between gap-3 px-2.5 py-2 rounded-md cursor-pointer transition-colors", "w-full flex items-center justify-between gap-3 px-2.5 py-2 rounded-md cursor-pointer transition-colors",
isSelected ? "bg-muted/60 hover:bg-muted/80" : "hover:bg-muted/40" isSelected ? "bg-muted/60 hover:bg-muted/80" : "hover:bg-muted/40"
)} )}
onClick={() => onTogglePermission(perm.value)} onClick={() => onTogglePermission(perm.value)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onTogglePermission(perm.value);
}
}}
> >
<div className="flex-1 min-w-0 text-left"> <div className="flex-1 min-w-0 text-left">
<span className="text-sm font-medium">{actionLabel}</span> <span className="text-sm font-medium">{actionLabel}</span>
@ -787,11 +753,11 @@ function PermissionsEditor({
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
className="shrink-0" className="shrink-0"
/> />
</div> </button>
); );
})} })}
</div> </div>
</motion.div> </div>
)} )}
</div> </div>
); );
@ -965,7 +931,7 @@ function CreateRoleDialog({
</div> </div>
</div> </div>
<div className="flex items-center justify-end gap-3 px-5 py-3 shrink-0"> <div className="flex items-center justify-end gap-3 px-5 py-3 shrink-0">
<Button variant="outline" onClick={handleClose}> <Button variant="secondary" onClick={handleClose}>
Cancel Cancel
</Button> </Button>
<Button onClick={handleCreate} disabled={creating || !name.trim()}> <Button onClick={handleCreate} disabled={creating || !name.trim()}>
@ -1123,7 +1089,7 @@ function EditRoleDialog({
</div> </div>
</div> </div>
<div className="flex items-center justify-end gap-3 px-5 py-3 border-t shrink-0"> <div className="flex items-center justify-end gap-3 px-5 py-3 border-t shrink-0">
<Button variant="outline" onClick={() => onOpenChange(false)}> <Button variant="secondary" onClick={() => onOpenChange(false)}>
Cancel Cancel
</Button> </Button>
<Button onClick={handleSave} disabled={saving || !name.trim()}> <Button onClick={handleSave} disabled={saving || !name.trim()}>

View file

@ -0,0 +1,65 @@
"use client";
import { useAtom } from "jotai";
import { Bot, Brain, FileText, Globe, ImageIcon, MessageSquare, Shield } from "lucide-react";
import { useTranslations } from "next-intl";
import type React from "react";
import { searchSpaceSettingsDialogAtom } from "@/atoms/settings/settings-dialog.atoms";
import { PublicChatSnapshotsManager } from "@/components/public-chat-snapshots/public-chat-snapshots-manager";
import { GeneralSettingsManager } from "@/components/settings/general-settings-manager";
import { ImageModelManager } from "@/components/settings/image-model-manager";
import { LLMRoleManager } from "@/components/settings/llm-role-manager";
import { ModelConfigManager } from "@/components/settings/model-config-manager";
import { PromptConfigManager } from "@/components/settings/prompt-config-manager";
import { RolesManager } from "@/components/settings/roles-manager";
import { SettingsDialog } from "@/components/settings/settings-dialog";
interface SearchSpaceSettingsDialogProps {
searchSpaceId: number;
}
export function SearchSpaceSettingsDialog({ searchSpaceId }: SearchSpaceSettingsDialogProps) {
const t = useTranslations("searchSpaceSettings");
const [state, setState] = useAtom(searchSpaceSettingsDialogAtom);
const navItems = [
{ value: "general", label: t("nav_general"), icon: <FileText className="h-4 w-4" /> },
{ value: "models", label: t("nav_agent_configs"), icon: <Bot className="h-4 w-4" /> },
{ value: "roles", label: t("nav_role_assignments"), icon: <Brain className="h-4 w-4" /> },
{
value: "image-models",
label: t("nav_image_models"),
icon: <ImageIcon className="h-4 w-4" />,
},
{ value: "team-roles", label: t("nav_team_roles"), icon: <Shield className="h-4 w-4" /> },
{
value: "prompts",
label: t("nav_system_instructions"),
icon: <MessageSquare className="h-4 w-4" />,
},
{ value: "public-links", label: t("nav_public_links"), icon: <Globe className="h-4 w-4" /> },
];
const content: Record<string, React.ReactNode> = {
general: <GeneralSettingsManager searchSpaceId={searchSpaceId} />,
models: <ModelConfigManager searchSpaceId={searchSpaceId} />,
roles: <LLMRoleManager searchSpaceId={searchSpaceId} />,
"image-models": <ImageModelManager searchSpaceId={searchSpaceId} />,
"team-roles": <RolesManager searchSpaceId={searchSpaceId} />,
prompts: <PromptConfigManager searchSpaceId={searchSpaceId} />,
"public-links": <PublicChatSnapshotsManager searchSpaceId={searchSpaceId} />,
};
return (
<SettingsDialog
open={state.open}
onOpenChange={(open) => setState((prev) => ({ ...prev, open }))}
title={t("title")}
navItems={navItems}
activeItem={state.initialTab}
onItemChange={(tab) => setState((prev) => ({ ...prev, initialTab: tab }))}
>
<div className="pt-4">{content[state.initialTab]}</div>
</SettingsDialog>
);
}

View file

@ -0,0 +1,126 @@
"use client";
import type * as React from "react";
import { useCallback, useRef, useState } from "react";
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
import { Separator } from "@/components/ui/separator";
import { cn } from "@/lib/utils";
interface NavItem {
value: string;
label: string;
icon: React.ReactNode;
}
interface SettingsDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
title: string;
navItems: NavItem[];
activeItem: string;
onItemChange: (value: string) => void;
children: React.ReactNode;
}
export function SettingsDialog({
open,
onOpenChange,
title,
navItems,
activeItem,
onItemChange,
children,
}: SettingsDialogProps) {
const activeRef = useRef<HTMLButtonElement>(null);
const [tabScrollPos, setTabScrollPos] = useState<"start" | "middle" | "end">("start");
const handleTabScroll = useCallback((e: React.UIEvent<HTMLDivElement>) => {
const el = e.currentTarget;
const atStart = el.scrollLeft <= 2;
const atEnd = el.scrollWidth - el.scrollLeft - el.clientWidth <= 2;
setTabScrollPos(atStart ? "start" : atEnd ? "end" : "middle");
}, []);
const handleItemChange = (value: string) => {
onItemChange(value);
activeRef.current?.scrollIntoView({ inline: "center", block: "nearest", behavior: "smooth" });
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="select-none max-w-[900px] w-[95vw] md:w-[90vw] h-[90vh] md:h-[80vh] max-h-[640px] flex flex-col md:flex-row p-0 gap-0 overflow-hidden [--card:var(--background)] dark:[--card:oklch(0.205_0_0)] dark:[--background:oklch(0.205_0_0)]">
<DialogTitle className="sr-only">{title}</DialogTitle>
{/* Desktop: Left sidebar */}
<nav className="hidden md:flex w-[220px] shrink-0 flex-col border-r border-border p-3 pt-6">
<div className="flex flex-col gap-0.5">
{navItems.map((item) => (
<button
key={item.value}
type="button"
onClick={() => onItemChange(item.value)}
className={cn(
"flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-colors text-left focus:outline-none focus-visible:outline-none",
activeItem === item.value
? "bg-accent text-accent-foreground"
: "text-muted-foreground hover:bg-accent/50 hover:text-foreground"
)}
>
{item.icon}
{item.label}
</button>
))}
</div>
</nav>
{/* Mobile: Top header + horizontal tabs */}
<div className="flex md:hidden flex-col shrink-0">
<div className="px-4 pt-4 pb-2">
<h2 className="text-base font-semibold">{title}</h2>
</div>
<div
className="overflow-x-auto scrollbar-hide border-b border-border"
onScroll={handleTabScroll}
style={{
maskImage: `linear-gradient(to right, ${tabScrollPos === "start" ? "black" : "transparent"}, black 24px, black calc(100% - 24px), ${tabScrollPos === "end" ? "black" : "transparent"})`,
WebkitMaskImage: `linear-gradient(to right, ${tabScrollPos === "start" ? "black" : "transparent"}, black 24px, black calc(100% - 24px), ${tabScrollPos === "end" ? "black" : "transparent"})`,
}}
>
<div className="flex gap-1 px-4 pb-2">
{navItems.map((item) => (
<button
key={item.value}
ref={activeItem === item.value ? activeRef : undefined}
type="button"
onClick={() => handleItemChange(item.value)}
className={cn(
"flex items-center gap-2 whitespace-nowrap rounded-full px-3 py-1.5 text-xs font-medium transition-colors shrink-0 focus:outline-none focus-visible:outline-none",
activeItem === item.value
? "bg-accent text-accent-foreground"
: "text-muted-foreground hover:bg-accent/50 hover:text-foreground"
)}
>
{item.icon}
{item.label}
</button>
))}
</div>
</div>
</div>
{/* Content area */}
<div className="flex flex-1 flex-col overflow-hidden min-w-0">
<div className="hidden md:block px-8 pt-6 pb-2">
<h2 className="text-lg font-semibold">
{navItems.find((i) => i.value === activeItem)?.label ?? title}
</h2>
<Separator className="mt-4" />
</div>
<div className="flex-1 overflow-y-auto overflow-x-hidden">
<div className="px-4 md:px-8 pb-6 pt-4 md:pt-0 min-w-0">{children}</div>
</div>
</div>
</DialogContent>
</Dialog>
);
}

View file

@ -0,0 +1,32 @@
"use client";
import { useAtom } from "jotai";
import { useTranslations } from "next-intl";
import { teamDialogAtom } from "@/atoms/settings/settings-dialog.atoms";
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
import { TeamContent } from "@/app/dashboard/[search_space_id]/team/team-content";
interface TeamDialogProps {
searchSpaceId: number;
}
export function TeamDialog({ searchSpaceId }: TeamDialogProps) {
const t = useTranslations("sidebar");
const [open, setOpen] = useAtom(teamDialogAtom);
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent
className="select-none max-w-[900px] w-[95vw] md:w-[90vw] h-[90vh] md:h-[80vh] max-h-[640px] flex flex-col p-0 gap-0 overflow-hidden [--card:var(--background)] dark:[--card:oklch(0.205_0_0)] dark:[--background:oklch(0.205_0_0)]"
onOpenAutoFocus={(e) => e.preventDefault()}
>
<DialogTitle className="sr-only">{t("manage_members")}</DialogTitle>
<div className="flex-1 overflow-y-auto overflow-x-hidden">
<div className="px-6 md:px-8 py-6 min-w-0">
<TeamContent searchSpaceId={searchSpaceId} />
</div>
</div>
</DialogContent>
</Dialog>
);
}

View file

@ -0,0 +1,39 @@
"use client";
import { useAtom } from "jotai";
import { KeyRound, User } from "lucide-react";
import { useTranslations } from "next-intl";
import { userSettingsDialogAtom } from "@/atoms/settings/settings-dialog.atoms";
import { ApiKeyContent } from "@/app/dashboard/[search_space_id]/user-settings/components/ApiKeyContent";
import { ProfileContent } from "@/app/dashboard/[search_space_id]/user-settings/components/ProfileContent";
import { SettingsDialog } from "@/components/settings/settings-dialog";
export function UserSettingsDialog() {
const t = useTranslations("userSettings");
const [state, setState] = useAtom(userSettingsDialogAtom);
const navItems = [
{ value: "profile", label: t("profile_nav_label"), icon: <User className="h-4 w-4" /> },
{
value: "api-key",
label: t("api_key_nav_label"),
icon: <KeyRound className="h-4 w-4" />,
},
];
return (
<SettingsDialog
open={state.open}
onOpenChange={(open) => setState((prev) => ({ ...prev, open }))}
title={t("title")}
navItems={navItems}
activeItem={state.initialTab}
onItemChange={(tab) => setState((prev) => ({ ...prev, initialTab: tab }))}
>
<div className="pt-4">
{state.initialTab === "profile" && <ProfileContent />}
{state.initialTab === "api-key" && <ApiKeyContent />}
</div>
</SettingsDialog>
);
}

View file

@ -565,7 +565,7 @@ export function LLMConfigForm({
{onCancel && ( {onCancel && (
<Button <Button
type="button" type="button"
variant="outline" variant="secondary"
onClick={onCancel} onClick={onCancel}
disabled={isSubmitting} disabled={isSubmitting}
className="text-xs sm:text-sm h-9 sm:h-10" className="text-xs sm:text-sm h-9 sm:h-10"

View file

@ -4,10 +4,11 @@ import { makeAssistantToolUI } from "@assistant-ui/react";
import { useAtomValue, useSetAtom } from "jotai"; import { useAtomValue, useSetAtom } from "jotai";
import { Dot, FileTextIcon } from "lucide-react"; import { Dot, FileTextIcon } from "lucide-react";
import { useParams, usePathname } from "next/navigation"; import { useParams, usePathname } from "next/navigation";
import { useEffect, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { z } from "zod"; import { z } from "zod";
import { openReportPanelAtom, reportPanelAtom } from "@/atoms/chat/report-panel.atom"; import { openReportPanelAtom, reportPanelAtom } from "@/atoms/chat/report-panel.atom";
import { TextShimmerLoader } from "@/components/prompt-kit/loader"; import { TextShimmerLoader } from "@/components/prompt-kit/loader";
import { useMediaQuery } from "@/hooks/use-media-query";
import { baseApiService } from "@/lib/apis/base-api.service"; import { baseApiService } from "@/lib/apis/base-api.service";
/** /**
@ -110,15 +111,20 @@ function ReportCard({
title, title,
wordCount, wordCount,
shareToken, shareToken,
autoOpen = false,
}: { }: {
reportId: number; reportId: number;
title: string; title: string;
wordCount?: number; wordCount?: number;
/** When set, uses public endpoint for fetching report data */ /** When set, uses public endpoint for fetching report data */
shareToken?: string | null; shareToken?: string | null;
/** When true, auto-opens the report panel on desktop after metadata loads */
autoOpen?: boolean;
}) { }) {
const openPanel = useSetAtom(openReportPanelAtom); const openPanel = useSetAtom(openReportPanelAtom);
const panelState = useAtomValue(reportPanelAtom); const panelState = useAtomValue(reportPanelAtom);
const isDesktop = useMediaQuery("(min-width: 768px)");
const autoOpenedRef = useRef(false);
const [metadata, setMetadata] = useState<{ const [metadata, setMetadata] = useState<{
title: string; title: string;
wordCount: number | null; wordCount: number | null;
@ -154,13 +160,21 @@ function ReportCard({
versionLabel = `version ${idx + 1}`; versionLabel = `version ${idx + 1}`;
} }
} }
setMetadata({ const resolvedTitle = parsed.data.title || title;
title: parsed.data.title || title, const resolvedWordCount = parsed.data.report_metadata?.word_count ?? wordCount ?? null;
wordCount: parsed.data.report_metadata?.word_count ?? wordCount ?? null, setMetadata({ title: resolvedTitle, wordCount: resolvedWordCount, versionLabel });
versionLabel,
if (autoOpen && isDesktop && !autoOpenedRef.current) {
autoOpenedRef.current = true;
openPanel({
reportId,
title: resolvedTitle,
wordCount: resolvedWordCount ?? undefined,
shareToken,
}); });
} }
} }
}
} catch { } catch {
if (!cancelled) setError("No report found"); if (!cancelled) setError("No report found");
} finally { } finally {
@ -171,7 +185,7 @@ function ReportCard({
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [reportId, title, wordCount, shareToken]); }, [reportId, title, wordCount, shareToken, autoOpen, isDesktop, openPanel]);
// Show non-clickable error card for any error (failed status, not found, etc.) // Show non-clickable error card for any error (failed status, not found, etc.)
if (!isLoading && error) { if (!isLoading && error) {
@ -243,6 +257,13 @@ export const GenerateReportToolUI = makeAssistantToolUI<GenerateReportArgs, Gene
const topic = args.topic || "Report"; const topic = args.topic || "Report";
// Track whether we witnessed the generation (running state).
// If we mount directly with a result, this stays false → it's a revisit.
const sawRunningRef = useRef(false);
if (status.type === "running" || status.type === "requires-action") {
sawRunningRef.current = true;
}
// Loading state - tool is still running (LLM generating report) // Loading state - tool is still running (LLM generating report)
if (status.type === "running" || status.type === "requires-action") { if (status.type === "running" || status.type === "requires-action") {
return <ReportGeneratingState topic={topic} />; return <ReportGeneratingState topic={topic} />;
@ -293,6 +314,7 @@ export const GenerateReportToolUI = makeAssistantToolUI<GenerateReportArgs, Gene
title={result.title || topic} title={result.title || topic}
wordCount={result.word_count ?? undefined} wordCount={result.word_count ?? undefined}
shareToken={shareToken} shareToken={shareToken}
autoOpen={sawRunningRef.current}
/> />
); );
} }

View file

@ -77,14 +77,14 @@ const XScrollable = forwardRef<
const dragging = useRef(false); const dragging = useRef(false);
const startX = useRef(0); const startX = useRef(0);
const startScrollLeft = useRef(0); const startScrollLeft = useRef(0);
const [scrollPos, setScrollPos] = useState<"start" | "middle" | "end">("start"); const [scrollPos, setScrollPos] = useState<"start" | "middle" | "end" | "none">("none");
const updateScrollPos = useCallback(() => { const updateScrollPos = useCallback(() => {
const el = scrollRef.current; const el = scrollRef.current;
if (!el) return; if (!el) return;
const canScroll = el.scrollWidth > el.clientWidth + 1; const canScroll = el.scrollWidth > el.clientWidth + 1;
if (!canScroll) { if (!canScroll) {
setScrollPos("start"); setScrollPos("none");
return; return;
} }
const atStart = el.scrollLeft <= 2; const atStart = el.scrollLeft <= 2;
@ -130,9 +130,12 @@ const XScrollable = forwardRef<
updateScrollPos(); updateScrollPos();
}, [updateScrollPos]); }, [updateScrollPos]);
const maskStart = scrollPos === "start" ? "black" : "transparent"; const needsMask = scrollPos !== "none";
const maskEnd = scrollPos === "end" ? "black" : "transparent"; const maskStart = scrollPos === "start" || scrollPos === "none" ? "black" : "transparent";
const maskImage = `linear-gradient(to right, ${maskStart}, black 24px, black calc(100% - 24px), ${maskEnd})`; const maskEnd = scrollPos === "end" || scrollPos === "none" ? "black" : "transparent";
const maskImage = needsMask
? `linear-gradient(to right, ${maskStart}, black 24px, black calc(100% - 24px), ${maskEnd})`
: undefined;
return ( return (
// biome-ignore lint/a11y/noStaticElementInteractions: drag-scroll container needs mouse events // biome-ignore lint/a11y/noStaticElementInteractions: drag-scroll container needs mouse events

View file

@ -45,7 +45,7 @@ function DrawerContent({
<DrawerOverlay className={overlayClassName} /> <DrawerOverlay className={overlayClassName} />
<DrawerPrimitive.Content <DrawerPrimitive.Content
className={cn( className={cn(
"fixed inset-x-0 bottom-0 z-50 mt-24 flex h-auto flex-col rounded-t-[10px] border bg-background", "fixed inset-x-0 bottom-0 z-50 mt-24 flex h-auto flex-col rounded-t-3xl bg-main-panel",
className className
)} )}
{...props} {...props}

View file

@ -14,7 +14,7 @@ export function FixedToolbar({
return ( return (
<Toolbar <Toolbar
className={cn( className={cn(
"scrollbar-hide sticky top-0 left-0 z-10 w-full justify-between overflow-x-auto rounded-t-lg border-b bg-background/95 p-1 backdrop-blur supports-backdrop-filter:bg-background/60", "scrollbar-hide sticky top-0 left-0 z-10 w-full justify-between overflow-x-auto border-b bg-background/95 p-1 backdrop-blur supports-backdrop-filter:bg-background/60",
className className
)} )}
{...props} {...props}

View file

@ -18,4 +18,9 @@ Practical guides to help you get the most out of SurfSense.
description="Invite teammates, share chats, and collaborate in realtime" description="Invite teammates, share chats, and collaborate in realtime"
href="/docs/how-to/realtime-collaboration" href="/docs/how-to/realtime-collaboration"
/> />
<Card
title="Web Search"
description="Configure SearXNG web search and residential proxies for production"
href="/docs/how-to/web-search"
/>
</Cards> </Cards>

View file

@ -1,6 +1,6 @@
{ {
"title": "How to", "title": "How to",
"pages": ["electric-sql", "realtime-collaboration"], "pages": ["electric-sql", "realtime-collaboration", "web-search"],
"icon": "Compass", "icon": "Compass",
"defaultOpen": false "defaultOpen": false
} }

View file

@ -0,0 +1,173 @@
---
title: Web Search
description: How SurfSense web search works and how to configure it for production with residential proxies
---
# Web Search
SurfSense uses [SearXNG](https://docs.searxng.org/) as a bundled meta-search engine to provide web search across all search spaces. SearXNG aggregates results from multiple search engines (Google, DuckDuckGo, Brave, Bing, and more) without requiring any API keys.
## How It Works
When a user triggers a web search in SurfSense:
1. The backend sends a query to the bundled SearXNG instance via its JSON API
2. SearXNG fans out the query to all enabled search engines simultaneously
3. Results are aggregated, deduplicated, and ranked by engine weight
4. The backend receives merged results and presents them to the user
SearXNG runs as a Docker container alongside the backend. It is never exposed to the internet. Only the backend communicates with it over the internal Docker network.
## Docker Setup
SearXNG is included in both `docker-compose.yml` and `docker-compose.dev.yml` and works out of the box with no configuration needed.
The backend connects to SearXNG automatically via the `SEARXNG_DEFAULT_HOST` environment variable (defaults to `http://searxng:8080`).
### Disabling SearXNG
If you don't need web search, you can skip the SearXNG container entirely:
```bash
docker compose up --scale searxng=0
```
### Using Your Own SearXNG Instance
To point SurfSense at an external SearXNG instance instead of the bundled one, set in your `docker/.env`:
```bash
SEARXNG_DEFAULT_HOST=http://your-searxng:8080
```
## Configuration
SearXNG is configured via `docker/searxng/settings.yml`. The key sections are:
### Engines
SearXNG queries multiple search engines in parallel. Each engine has a **weight** that influences how its results rank in the merged output:
| Engine | Weight | Notes |
|--------|--------|-------|
| Google | 1.2 | Highest priority, best general results |
| DuckDuckGo | 1.1 | Strong privacy-focused alternative |
| Brave | 1.0 | Independent search index |
| Bing | 0.9 | Different index from Google |
| Wikipedia | 0.8 | Encyclopedic results |
| StackOverflow | 0.7 | Technical/programming results |
| Yahoo | 0.7 | Powered by Bing's index |
| Wikidata | 0.6 | Structured data results |
| Currency | default | Currency conversion |
| DDG Definitions | default | Instant answers from DuckDuckGo |
All engines are free. SearXNG scrapes public search pages, no API keys required.
### Engine Suspension
When a search engine returns an error (CAPTCHA, rate limit, access denied), SearXNG suspends it for a configurable duration. After the suspension expires, the engine is automatically retried.
The default suspension times are tuned for use with rotating residential proxies (shorter bans since each retry goes through a different IP):
| Error Type | Suspension | Default (without override) |
|------------|-----------|---------------------------|
| Access Denied (403) | 1 hour | 24 hours |
| CAPTCHA | 1 hour | 24 hours |
| Too Many Requests (429) | 10 minutes | 1 hour |
| Cloudflare CAPTCHA | 2 hours | 15 days |
| Cloudflare Access Denied | 1 hour | 24 hours |
| reCAPTCHA | 2 hours | 7 days |
### Timeouts
| Setting | Value | Description |
|---------|-------|-------------|
| `request_timeout` | 12s | Default timeout per engine request |
| `max_request_timeout` | 20s | Maximum allowed timeout (must be ≥ `request_timeout`) |
| `extra_proxy_timeout` | 10s | Extra seconds added when using a proxy |
| `retries` | 1 | Retries on HTTP error (uses a different proxy IP per retry) |
## Production: Residential Proxies
In production, search engines may rate-limit or block your server's IP. To avoid this, configure a residential proxy so SearXNG's outgoing requests appear to come from rotating residential IPs.
### Step 1: Build the Proxy URL
SurfSense uses [anonymous-proxies.net](https://anonymous-proxies.net/) style residential proxies where the password is a base64-encoded JSON object. Build the URL using your proxy credentials:
```bash
# Encode the password (replace with your actual values)
echo -n '{"p": "YOUR_PASSWORD", "l": "LOCATION", "t": PROXY_TYPE}' | base64
```
The full proxy URL format is:
```
http://<username>:<base64_password>@<hostname>:<port>/
```
### Step 2: Add to SearXNG Settings
In `docker/searxng/settings.yml`, add the proxy URL under `outgoing.proxies`:
```yaml
outgoing:
proxies:
all://:
- http://username:base64password@proxy-host:port/
```
The `all://:` key routes both HTTP and HTTPS requests through the proxy. If you have multiple proxy endpoints, list them and SearXNG will round-robin between them:
```yaml
proxies:
all://:
- http://user:pass@proxy1:port/
- http://user:pass@proxy2:port/
```
### Step 3: Restart SearXNG
```bash
docker compose restart searxng
```
### Verify
Check that SearXNG is healthy:
```bash
curl http://localhost:8888/healthz
```
## Troubleshooting
### SearXNG Fails to Start
**`ValueError: Invalid settings.yml`** - Check the error line above the traceback. Common causes:
- `extra_proxy_timeout` must be an integer (use `10`, not `10.0`)
- `KeyError: 'engine_name'` means an engine was removed but other engines reference its network. Remove all variants (e.g., removing `qwant` also requires removing `qwant news`, `qwant images`, `qwant videos`)
### Engines Getting Suspended
If an engine is suspended (visible in SearXNG logs as `suspended_time=N`), it will automatically recover after the suspension period. With residential proxies, the next request after recovery goes through a different IP and typically succeeds.
### No Web Search Results
1. Check SearXNG health: `curl http://localhost:8888/healthz`
2. Check SearXNG logs: `docker compose logs searxng`
3. Verify the backend can reach SearXNG: the `SEARXNG_DEFAULT_HOST` env var should point to `http://searxng:8080` (Docker) or `http://localhost:8888` (local dev)
### Proxy Not Working
- Verify the base64 password is correctly encoded
- Check that `extra_proxy_timeout` is set (proxies add latency)
- Ensure `max_request_timeout` is high enough to accommodate `request_timeout + extra_proxy_timeout`
## Environment Variables Reference
| Variable | Location | Description | Default |
|----------|----------|-------------|---------|
| `SEARXNG_DEFAULT_HOST` | `docker/.env` | URL of the SearXNG instance | `http://searxng:8080` |
| `SEARXNG_SECRET` | `docker/.env` | Secret key for SearXNG | `surfsense-searxng-secret` |
| `SEARXNG_PORT` | `docker/.env` | Port to expose SearXNG UI on the host | `8888` |

View file

@ -0,0 +1,32 @@
import {
BookOpen,
Brain,
Database,
FileText,
Globe,
ImageIcon,
Link2,
type LucideIcon,
Podcast,
ScanLine,
Sparkles,
Wrench,
} from "lucide-react";
const TOOL_ICONS: Record<string, LucideIcon> = {
search_knowledge_base: Database,
generate_podcast: Podcast,
generate_report: FileText,
link_preview: Link2,
display_image: ImageIcon,
generate_image: Sparkles,
scrape_webpage: ScanLine,
web_search: Globe,
search_surfsense_docs: BookOpen,
save_memory: Brain,
recall_memory: Brain,
};
export function getToolIcon(name: string): LucideIcon {
return TOOL_ICONS[name] ?? Wrench;
}

View file

@ -49,7 +49,12 @@ export const getSearchSpaceResponse = searchSpace.omit({ member_count: true, is_
export const updateSearchSpaceRequest = z.object({ export const updateSearchSpaceRequest = z.object({
id: z.number(), id: z.number(),
data: searchSpace data: searchSpace
.pick({ name: true, description: true, citations_enabled: true, qna_custom_instructions: true }) .pick({
name: true,
description: true,
citations_enabled: true,
qna_custom_instructions: true,
})
.partial(), .partial(),
}); });

View file

@ -655,6 +655,8 @@
"no_shared_chats": "No shared chats", "no_shared_chats": "No shared chats",
"view_all_shared_chats": "View all shared chats", "view_all_shared_chats": "View all shared chats",
"view_all_private_chats": "View all private chats", "view_all_private_chats": "View all private chats",
"show_all": "Show all",
"hide": "Hide",
"no_chats": "No chats yet", "no_chats": "No chats yet",
"start_new_chat_hint": "Start a new chat", "start_new_chat_hint": "Start a new chat",
"error_loading_chats": "Error loading chats", "error_loading_chats": "Error loading chats",
@ -757,7 +759,27 @@
"general_reset": "Reset Changes", "general_reset": "Reset Changes",
"general_save": "Save Changes", "general_save": "Save Changes",
"general_saving": "Saving", "general_saving": "Saving",
"general_unsaved_changes": "You have unsaved changes. Click \"Save Changes\" to apply them." "general_unsaved_changes": "You have unsaved changes. Click \"Save Changes\" to apply them.",
"nav_web_search": "Web Search",
"nav_web_search_desc": "Built-in web search settings",
"web_search_title": "Web Search",
"web_search_description": "Web search is powered by a built-in SearXNG instance. All queries are proxied through your server — no data is sent to third parties.",
"web_search_enabled_label": "Enable Web Search",
"web_search_enabled_description": "When enabled, the AI agent can search the web for real-time information like news, prices, and current events.",
"web_search_status_healthy": "Web search service is healthy",
"web_search_status_unhealthy": "Web search service is unavailable",
"web_search_status_not_configured": "Web search service is not configured",
"web_search_engines_label": "Search Engines",
"web_search_engines_placeholder": "google,brave,duckduckgo",
"web_search_engines_description": "Comma-separated list of SearXNG engines to use. Leave empty for defaults.",
"web_search_language_label": "Preferred Language",
"web_search_language_placeholder": "en",
"web_search_language_description": "IETF language tag (e.g. en, en-US). Leave empty for auto-detect.",
"web_search_safesearch_label": "SafeSearch Level",
"web_search_safesearch_description": "0 = off, 1 = moderate, 2 = strict",
"web_search_save": "Save Web Search Settings",
"web_search_saving": "Saving...",
"web_search_saved": "Web search settings saved"
}, },
"homepage": { "homepage": {
"hero_title_part1": "The AI Workspace", "hero_title_part1": "The AI Workspace",

View file

@ -655,6 +655,8 @@
"no_shared_chats": "No hay chats compartidos", "no_shared_chats": "No hay chats compartidos",
"view_all_shared_chats": "Ver todos los chats compartidos", "view_all_shared_chats": "Ver todos los chats compartidos",
"view_all_private_chats": "Ver todos los chats privados", "view_all_private_chats": "Ver todos los chats privados",
"show_all": "Ver todo",
"hide": "Ocultar",
"no_chats": "Aún no hay chats", "no_chats": "Aún no hay chats",
"start_new_chat_hint": "Iniciar un nuevo chat", "start_new_chat_hint": "Iniciar un nuevo chat",
"error_loading_chats": "Error al cargar chats", "error_loading_chats": "Error al cargar chats",

View file

@ -655,6 +655,8 @@
"no_shared_chats": "कोई साझा चैट नहीं", "no_shared_chats": "कोई साझा चैट नहीं",
"view_all_shared_chats": "सभी साझा चैट देखें", "view_all_shared_chats": "सभी साझा चैट देखें",
"view_all_private_chats": "सभी निजी चैट देखें", "view_all_private_chats": "सभी निजी चैट देखें",
"show_all": "सभी देखें",
"hide": "छिपाएँ",
"no_chats": "अभी तक कोई चैट नहीं", "no_chats": "अभी तक कोई चैट नहीं",
"start_new_chat_hint": "नई चैट शुरू करें", "start_new_chat_hint": "नई चैट शुरू करें",
"error_loading_chats": "चैट लोड करने में त्रुटि", "error_loading_chats": "चैट लोड करने में त्रुटि",

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