Merge pull request #866 from AnishSarkar22/fix/docker-dev

fix: enhance docker build CI pipeline, update docker ports & docker docs
This commit is contained in:
Rohan Verma 2026-03-10 14:00:27 -07:00 committed by GitHub
commit d41d1a1c7f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
90 changed files with 2481 additions and 2054 deletions

View file

@ -1,97 +0,0 @@
# Git
.git
.gitignore
.gitattributes
# Documentation
*.md
!README.md
docs/
CONTRIBUTING.md
CODE_OF_CONDUCT.md
LICENSE
# IDE
.vscode/
.idea/
*.swp
*.swo
.cursor/
# Node
**/node_modules/
**/.next/
**/dist/
**/.turbo/
**/.cache/
**/coverage/
# Python
**/__pycache__/
**/*.pyc
**/*.pyo
**/*.pyd
**/.Python
**/build/
**/develop-eggs/
**/downloads/
**/eggs/
**/.eggs/
# Python venv lib folders (but not frontend lib folders)
surfsense_backend/lib/
surfsense_backend/lib64/
**/parts/
**/sdist/
**/var/
**/wheels/
**/*.egg-info/
**/.installed.cfg
**/*.egg
**/pip-log.txt
**/.tox/
**/.coverage
**/htmlcov/
**/.pytest_cache/
**/nosetests.xml
**/coverage.xml
# Environment
**/.env
**/.env.*
!**/.env.example
**/*.local
# Docker
**/Dockerfile
**/docker-compose*.yml
**/.docker/
# Testing
**/tests/
**/test/
**/__tests__/
**/*.test.*
**/*.spec.*
# Logs
**/*.log
# Temporary files
**/tmp/
**/temp/
**/.tmp/
**/.temp/
# Build artifacts from backend
surfsense_backend/podcasts/
surfsense_backend/temp_audio/
surfsense_backend/*.bak
surfsense_backend/*.dat
surfsense_backend/*.dir
# GitHub
.github/
# Browser extension (not needed for main deployment)
surfsense_browser_extension/

View file

@ -26,6 +26,7 @@ permissions:
jobs: jobs:
tag_release: tag_release:
runs-on: ubuntu-latest runs-on: ubuntu-latest
if: github.ref == format('refs/heads/{0}', github.event.repository.default_branch) || github.event_name == 'workflow_dispatch'
outputs: outputs:
new_tag: ${{ steps.tag_version.outputs.next_version }} new_tag: ${{ steps.tag_version.outputs.next_version }}
steps: steps:
@ -86,6 +87,7 @@ jobs:
build: build:
needs: tag_release needs: tag_release
if: always() && (needs.tag_release.result == 'success' || needs.tag_release.result == 'skipped')
runs-on: ${{ matrix.os }} runs-on: ${{ matrix.os }}
permissions: permissions:
packages: write packages: write
@ -121,6 +123,12 @@ jobs:
id: image id: image
run: echo "name=${REGISTRY_IMAGE,,}" >> $GITHUB_OUTPUT run: echo "name=${REGISTRY_IMAGE,,}" >> $GITHUB_OUTPUT
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ steps.image.outputs.name }}
- name: Login to GitHub Container Registry - name: Login to GitHub Container Registry
uses: docker/login-action@v3 uses: docker/login-action@v3
with: with:
@ -139,14 +147,15 @@ jobs:
sudo rm -rf "$AGENT_TOOLSDIRECTORY" || true sudo rm -rf "$AGENT_TOOLSDIRECTORY" || true
docker system prune -af docker system prune -af
- name: Build and push ${{ matrix.name }} (${{ matrix.suffix }}) - name: Build and push by digest ${{ matrix.name }} (${{ matrix.suffix }})
id: build id: build
uses: docker/build-push-action@v6 uses: docker/build-push-action@v6
with: with:
context: ${{ matrix.context }} context: ${{ matrix.context }}
file: ${{ matrix.file }} file: ${{ matrix.file }}
push: true labels: ${{ steps.meta.outputs.labels }}
tags: ${{ steps.image.outputs.name }}:${{ needs.tag_release.outputs.new_tag }}-${{ matrix.suffix }} tags: ${{ steps.image.outputs.name }}
outputs: type=image,push-by-digest=true,name-canonical=true,push=true
platforms: ${{ matrix.platform }} platforms: ${{ matrix.platform }}
cache-from: type=gha,scope=${{ matrix.image }}-${{ matrix.suffix }} cache-from: type=gha,scope=${{ matrix.image }}-${{ matrix.suffix }}
cache-to: type=gha,mode=max,scope=${{ matrix.image }}-${{ matrix.suffix }} cache-to: type=gha,mode=max,scope=${{ matrix.image }}-${{ matrix.suffix }}
@ -159,9 +168,24 @@ jobs:
${{ matrix.image == 'web' && 'NEXT_PUBLIC_ELECTRIC_AUTH_MODE=__NEXT_PUBLIC_ELECTRIC_AUTH_MODE__' || '' }} ${{ matrix.image == 'web' && 'NEXT_PUBLIC_ELECTRIC_AUTH_MODE=__NEXT_PUBLIC_ELECTRIC_AUTH_MODE__' || '' }}
${{ matrix.image == 'web' && 'NEXT_PUBLIC_DEPLOYMENT_MODE=__NEXT_PUBLIC_DEPLOYMENT_MODE__' || '' }} ${{ matrix.image == 'web' && 'NEXT_PUBLIC_DEPLOYMENT_MODE=__NEXT_PUBLIC_DEPLOYMENT_MODE__' || '' }}
- name: Export digest
run: |
mkdir -p /tmp/digests
digest="${{ steps.build.outputs.digest }}"
touch "/tmp/digests/${digest#sha256:}"
- name: Upload digest
uses: actions/upload-artifact@v4
with:
name: digests-${{ matrix.image }}-${{ matrix.suffix }}
path: /tmp/digests/*
if-no-files-found: error
retention-days: 1
create_manifest: create_manifest:
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: [tag_release, build] needs: [tag_release, build]
if: always() && needs.build.result == 'success'
permissions: permissions:
packages: write packages: write
contents: read contents: read
@ -170,7 +194,9 @@ jobs:
matrix: matrix:
include: include:
- name: surfsense-backend - name: surfsense-backend
image: backend
- name: surfsense-web - name: surfsense-web
image: web
env: env:
REGISTRY_IMAGE: ghcr.io/${{ github.repository_owner }}/${{ matrix.name }} REGISTRY_IMAGE: ghcr.io/${{ github.repository_owner }}/${{ matrix.name }}
@ -179,6 +205,21 @@ jobs:
id: image id: image
run: echo "name=${REGISTRY_IMAGE,,}" >> $GITHUB_OUTPUT run: echo "name=${REGISTRY_IMAGE,,}" >> $GITHUB_OUTPUT
- name: Download amd64 digest
uses: actions/download-artifact@v4
with:
name: digests-${{ matrix.image }}-amd64
path: /tmp/digests
- name: Download arm64 digest
uses: actions/download-artifact@v4
with:
name: digests-${{ matrix.image }}-arm64
path: /tmp/digests
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to GitHub Container Registry - name: Login to GitHub Container Registry
uses: docker/login-action@v3 uses: docker/login-action@v3
with: with:
@ -186,35 +227,41 @@ jobs:
username: ${{ github.repository_owner }} username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }} password: ${{ secrets.GITHUB_TOKEN }}
- name: Create and push multi-arch manifest - name: Compute app version
id: appver
run: | run: |
VERSION_TAG="${{ needs.tag_release.outputs.new_tag }}" VERSION_TAG="${{ needs.tag_release.outputs.new_tag }}"
IMAGE="${{ steps.image.outputs.name }}" if [ -n "$VERSION_TAG" ]; then
APP_VERSION=$(echo "$VERSION_TAG" | rev | cut -d. -f2- | rev) APP_VERSION=$(echo "$VERSION_TAG" | rev | cut -d. -f2- | rev)
else
docker manifest create ${IMAGE}:${VERSION_TAG} \ APP_VERSION=""
${IMAGE}:${VERSION_TAG}-amd64 \
${IMAGE}:${VERSION_TAG}-arm64
docker manifest push ${IMAGE}:${VERSION_TAG}
if [[ "${{ github.ref }}" == "refs/heads/${{ github.event.repository.default_branch }}" ]] || [[ "${{ github.event.inputs.branch }}" == "${{ github.event.repository.default_branch }}" ]]; then
docker manifest create ${IMAGE}:${APP_VERSION} \
${IMAGE}:${VERSION_TAG}-amd64 \
${IMAGE}:${VERSION_TAG}-arm64
docker manifest push ${IMAGE}:${APP_VERSION}
docker manifest create ${IMAGE}:latest \
${IMAGE}:${VERSION_TAG}-amd64 \
${IMAGE}:${VERSION_TAG}-arm64
docker manifest push ${IMAGE}:latest
fi fi
echo "app_version=$APP_VERSION" >> $GITHUB_OUTPUT
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ steps.image.outputs.name }}
tags: |
type=raw,value=${{ needs.tag_release.outputs.new_tag }},enable=${{ needs.tag_release.outputs.new_tag != '' }}
type=raw,value=${{ steps.appver.outputs.app_version }},enable=${{ needs.tag_release.outputs.new_tag != '' && (github.ref == format('refs/heads/{0}', github.event.repository.default_branch) || github.event.inputs.branch == github.event.repository.default_branch) }}
type=ref,event=branch
type=sha,prefix=git-
flavor: |
latest=${{ github.ref == format('refs/heads/{0}', github.event.repository.default_branch) || github.event.inputs.branch == github.event.repository.default_branch }}
- name: Create manifest list and push
working-directory: /tmp/digests
run: |
docker buildx imagetools create \
$(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
$(printf '${{ steps.image.outputs.name }}@sha256:%s ' *)
- name: Inspect image
run: |
docker buildx imagetools inspect ${{ steps.image.outputs.name }}:${{ steps.meta.outputs.version }}
- name: Summary - name: Summary
run: | run: |
echo "Multi-arch manifest created for ${{ matrix.name }}!" echo "Multi-arch manifest created for ${{ matrix.name }}!"
echo "Versioned: ${{ steps.image.outputs.name }}:${{ needs.tag_release.outputs.new_tag }}" echo "Tags: $(jq -cr '.tags | join(", ")' <<< "$DOCKER_METADATA_OUTPUT_JSON")"
echo "App version: ${{ steps.image.outputs.name }}:$(echo '${{ needs.tag_release.outputs.new_tag }}' | rev | cut -d. -f2- | rev)"
echo "Latest: ${{ steps.image.outputs.name }}:latest"

View file

@ -33,9 +33,9 @@ EMBEDDING_MODEL=sentence-transformers/all-MiniLM-L6-v2
# Ports (change to avoid conflicts with other services on your machine) # Ports (change to avoid conflicts with other services on your machine)
# ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------
# BACKEND_PORT=8000 # BACKEND_PORT=8929
# FRONTEND_PORT=3000 # FRONTEND_PORT=3929
# ELECTRIC_PORT=5133 # ELECTRIC_PORT=5929
# FLOWER_PORT=5555 # FLOWER_PORT=5555
# ============================================================================== # ==============================================================================

View file

@ -8,7 +8,7 @@
# For production with prebuilt images, use docker/docker-compose.yml instead. # For production with prebuilt images, use docker/docker-compose.yml instead.
# ============================================================================= # =============================================================================
name: surfsense name: surfsense-dev
services: services:
db: db:
@ -162,8 +162,9 @@ services:
image: electricsql/electric:1.4.10 image: electricsql/electric:1.4.10
ports: ports:
- "${ELECTRIC_PORT:-5133}:3000" - "${ELECTRIC_PORT:-5133}:3000"
# depends_on: depends_on:
# - db db:
condition: service_healthy
environment: environment:
- DATABASE_URL=${ELECTRIC_DATABASE_URL:-postgresql://${ELECTRIC_DB_USER:-electric}:${ELECTRIC_DB_PASSWORD:-electric_password}@${DB_HOST:-db}:${DB_PORT:-5432}/${DB_NAME:-surfsense}?sslmode=${DB_SSLMODE:-disable}} - DATABASE_URL=${ELECTRIC_DATABASE_URL:-postgresql://${ELECTRIC_DB_USER:-electric}:${ELECTRIC_DB_PASSWORD:-electric_password}@${DB_HOST:-db}:${DB_PORT:-5432}/${DB_NAME:-surfsense}?sslmode=${DB_SSLMODE:-disable}}
- ELECTRIC_INSECURE=true - ELECTRIC_INSECURE=true
@ -197,10 +198,10 @@ services:
volumes: volumes:
postgres_data: postgres_data:
name: surfsense-postgres name: surfsense-dev-postgres
pgadmin_data: pgadmin_data:
name: surfsense-pgadmin name: surfsense-dev-pgadmin
redis_data: redis_data:
name: surfsense-redis name: surfsense-dev-redis
shared_temp: shared_temp:
name: surfsense-shared-temp name: surfsense-dev-shared-temp

View file

@ -45,7 +45,7 @@ services:
backend: backend:
image: ghcr.io/modsetter/surfsense-backend:${SURFSENSE_VERSION:-latest} image: ghcr.io/modsetter/surfsense-backend:${SURFSENSE_VERSION:-latest}
ports: ports:
- "${BACKEND_PORT:-8000}:8000" - "${BACKEND_PORT:-8929}:8000"
volumes: volumes:
- shared_temp:/shared_tmp - shared_temp:/shared_tmp
env_file: env_file:
@ -61,7 +61,7 @@ services:
UNSTRUCTURED_HAS_PATCHED_LOOP: "1" UNSTRUCTURED_HAS_PATCHED_LOOP: "1"
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:-3000}} NEXT_FRONTEND_URL: ${NEXT_FRONTEND_URL:-http://localhost:${FRONTEND_PORT:-3929}}
# 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:-}
@ -151,7 +151,7 @@ services:
electric: electric:
image: electricsql/electric:1.4.10 image: electricsql/electric:1.4.10
ports: ports:
- "${ELECTRIC_PORT:-5133}:3000" - "${ELECTRIC_PORT:-5929}:3000"
environment: environment:
DATABASE_URL: ${ELECTRIC_DATABASE_URL:-postgresql://${ELECTRIC_DB_USER:-electric}:${ELECTRIC_DB_PASSWORD:-electric_password}@${DB_HOST:-db}:${DB_PORT:-5432}/${DB_NAME:-surfsense}?sslmode=${DB_SSLMODE:-disable}} DATABASE_URL: ${ELECTRIC_DATABASE_URL:-postgresql://${ELECTRIC_DB_USER:-electric}:${ELECTRIC_DB_PASSWORD:-electric_password}@${DB_HOST:-db}:${DB_PORT:-5432}/${DB_NAME:-surfsense}?sslmode=${DB_SSLMODE:-disable}}
ELECTRIC_INSECURE: "true" ELECTRIC_INSECURE: "true"
@ -169,10 +169,10 @@ services:
frontend: frontend:
image: ghcr.io/modsetter/surfsense-web:${SURFSENSE_VERSION:-latest} image: ghcr.io/modsetter/surfsense-web:${SURFSENSE_VERSION:-latest}
ports: ports:
- "${FRONTEND_PORT:-3000}:3000" - "${FRONTEND_PORT:-3929}:3000"
environment: environment:
NEXT_PUBLIC_FASTAPI_BACKEND_URL: ${NEXT_PUBLIC_FASTAPI_BACKEND_URL:-http://localhost:${BACKEND_PORT:-8000}} NEXT_PUBLIC_FASTAPI_BACKEND_URL: ${NEXT_PUBLIC_FASTAPI_BACKEND_URL:-http://localhost:${BACKEND_PORT:-8929}}
NEXT_PUBLIC_ELECTRIC_URL: ${NEXT_PUBLIC_ELECTRIC_URL:-http://localhost:${ELECTRIC_PORT:-5133}} NEXT_PUBLIC_ELECTRIC_URL: ${NEXT_PUBLIC_ELECTRIC_URL:-http://localhost:${ELECTRIC_PORT:-5929}}
NEXT_PUBLIC_FASTAPI_BACKEND_AUTH_TYPE: ${AUTH_TYPE:-LOCAL} NEXT_PUBLIC_FASTAPI_BACKEND_AUTH_TYPE: ${AUTH_TYPE:-LOCAL}
NEXT_PUBLIC_ETL_SERVICE: ${ETL_SERVICE:-DOCLING} NEXT_PUBLIC_ETL_SERVICE: ${ETL_SERVICE:-DOCLING}
NEXT_PUBLIC_DEPLOYMENT_MODE: ${DEPLOYMENT_MODE:-self-hosted} NEXT_PUBLIC_DEPLOYMENT_MODE: ${DEPLOYMENT_MODE:-self-hosted}

View file

@ -321,9 +321,9 @@ Write-Host " OSS Alternative to NotebookLM for Teams [$versionDisplay]"
Write-Host ("=" * 62) -ForegroundColor Cyan Write-Host ("=" * 62) -ForegroundColor Cyan
Write-Host "" Write-Host ""
Write-Info " Frontend: http://localhost:3000" Write-Info " Frontend: http://localhost:3929"
Write-Info " Backend: http://localhost:8000" Write-Info " Backend: http://localhost:8929"
Write-Info " API Docs: http://localhost:8000/docs" Write-Info " API Docs: http://localhost:8929/docs"
Write-Info "" Write-Info ""
Write-Info " Config: $InstallDir\.env" Write-Info " Config: $InstallDir\.env"
Write-Info " Logs: cd $InstallDir; docker compose logs -f" Write-Info " Logs: cd $InstallDir; docker compose logs -f"

View file

@ -304,9 +304,9 @@ _version_display="${_version_display:-latest}"
printf " OSS Alternative to NotebookLM for Teams ${YELLOW}[%s]${NC}\n" "${_version_display}" printf " OSS Alternative to NotebookLM for Teams ${YELLOW}[%s]${NC}\n" "${_version_display}"
printf "${CYAN}══════════════════════════════════════════════════════════════${NC}\n\n" printf "${CYAN}══════════════════════════════════════════════════════════════${NC}\n\n"
info " Frontend: http://localhost:3000" info " Frontend: http://localhost:3929"
info " Backend: http://localhost:8000" info " Backend: http://localhost:8929"
info " API Docs: http://localhost:8000/docs" info " API Docs: http://localhost:8929/docs"
info "" info ""
info " Config: ${INSTALL_DIR}/.env" info " Config: ${INSTALL_DIR}/.env"
info " Logs: cd ${INSTALL_DIR} && ${DC} logs -f" info " Logs: cd ${INSTALL_DIR} && ${DC} logs -f"

View file

@ -9,7 +9,14 @@ import { useEffect } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
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, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"; import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@ -108,37 +115,26 @@ export default function MorePagesPage() {
<div <div
className={cn( className={cn(
"flex h-9 w-9 shrink-0 items-center justify-center rounded-full", "flex h-9 w-9 shrink-0 items-center justify-center rounded-full",
task.completed task.completed ? "bg-primary text-primary-foreground" : "bg-muted"
? "bg-primary text-primary-foreground"
: "bg-muted"
)} )}
> >
{task.completed ? ( {task.completed ? <Check className="h-4 w-4" /> : <Star className="h-4 w-4" />}
<Check className="h-4 w-4" />
) : (
<Star className="h-4 w-4" />
)}
</div> </div>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<p <p
className={cn( className={cn(
"text-sm font-medium", "text-sm font-medium",
task.completed && task.completed && "text-muted-foreground line-through"
"text-muted-foreground line-through"
)} )}
> >
{task.title} {task.title}
</p> </p>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">+{task.pages_reward} pages</p>
+{task.pages_reward} pages
</p>
</div> </div>
<Button <Button
variant={task.completed ? "ghost" : "outline"} variant={task.completed ? "ghost" : "outline"}
size="sm" size="sm"
disabled={ disabled={task.completed || completeMutation.isPending}
task.completed || completeMutation.isPending
}
onClick={() => handleTaskClick(task)} onClick={() => handleTaskClick(task)}
asChild={!task.completed} asChild={!task.completed}
> >
@ -181,8 +177,9 @@ export default function MorePagesPage() {
</Badge> </Badge>
</div> </div>
<CardDescription> <CardDescription>
For a limited time, get <span className="font-semibold text-foreground">6,000 additional pages</span> at For a limited time, get{" "}
no cost. Contact us and we&apos;ll upgrade your account instantly. <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> </CardDescription>
</CardHeader> </CardHeader>
<CardFooter className="pt-2"> <CardFooter className="pt-2">
@ -196,9 +193,7 @@ export default function MorePagesPage() {
<DialogContent className="select-none sm:max-w-sm"> <DialogContent className="select-none sm:max-w-sm">
<DialogHeader> <DialogHeader>
<DialogTitle>Get in Touch</DialogTitle> <DialogTitle>Get in Touch</DialogTitle>
<DialogDescription> <DialogDescription>Pick the option that works best for you.</DialogDescription>
Pick the option that works best for you.
</DialogDescription>
</DialogHeader> </DialogHeader>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Button asChild> <Button asChild>

View file

@ -32,7 +32,7 @@ import { closeReportPanelAtom } from "@/atoms/chat/report-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 { ReportPanel } 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";
import { GeneratePodcastToolUI } from "@/components/tool-ui/generate-podcast"; import { GeneratePodcastToolUI } from "@/components/tool-ui/generate-podcast";
@ -405,7 +405,6 @@ export default function NewChatPage() {
id: currentThread?.id ?? null, id: currentThread?.id ?? null,
visibility: currentThread?.visibility ?? null, visibility: currentThread?.visibility ?? null,
hasComments: currentThread?.has_comments ?? false, hasComments: currentThread?.has_comments ?? false,
addingCommentToMessageId: null,
})); }));
}, [currentThread, setCurrentThreadState]); }, [currentThread, setCurrentThreadState]);
@ -1669,7 +1668,7 @@ export default function NewChatPage() {
<div className="flex-1 flex flex-col min-w-0 overflow-hidden"> <div className="flex-1 flex flex-col min-w-0 overflow-hidden">
<Thread messageThinkingSteps={messageThinkingSteps} /> <Thread messageThinkingSteps={messageThinkingSteps} />
</div> </div>
<ReportPanel /> <MobileReportPanel />
</div> </div>
</AssistantRuntimeProvider> </AssistantRuntimeProvider>
); );

View file

@ -1,7 +1,13 @@
import type { BaseLayoutProps } from "fumadocs-ui/layouts/shared"; import type { BaseLayoutProps } from "fumadocs-ui/layouts/shared";
import Image from "next/image";
export const baseOptions: BaseLayoutProps = { export const baseOptions: BaseLayoutProps = {
nav: { nav: {
title: "SurfSense Docs", title: (
<>
<Image src="/icon-128.svg" alt="SurfSense" width={24} height={24} className="dark:invert" />
SurfSense Docs
</>
),
}, },
githubUrl: "https://github.com/MODSetter/SurfSense", githubUrl: "https://github.com/MODSetter/SurfSense",
}; };

View file

@ -55,7 +55,37 @@ export default function sitemap(): MetadataRoute.Sitemap {
priority: 0.9, priority: 0.9,
}, },
{ {
url: "https://www.surfsense.com/docs/docker-installation", url: "https://www.surfsense.com/docs/prerequisites",
lastModified,
changeFrequency: "daily",
priority: 0.9,
},
{
url: "https://www.surfsense.com/docs/docker-installation/install-script",
lastModified,
changeFrequency: "daily",
priority: 0.9,
},
{
url: "https://www.surfsense.com/docs/docker-installation/docker-compose",
lastModified,
changeFrequency: "daily",
priority: 0.9,
},
{
url: "https://www.surfsense.com/docs/docker-installation/updating",
lastModified,
changeFrequency: "daily",
priority: 0.9,
},
{
url: "https://www.surfsense.com/docs/docker-installation/dev-compose",
lastModified,
changeFrequency: "daily",
priority: 0.9,
},
{
url: "https://www.surfsense.com/docs/docker-installation/migrate-from-allinone",
lastModified, lastModified,
changeFrequency: "daily", changeFrequency: "daily",
priority: 0.9, priority: 0.9,
@ -163,6 +193,12 @@ export default function sitemap(): MetadataRoute.Sitemap {
changeFrequency: "daily", changeFrequency: "daily",
priority: 0.8, priority: 0.8,
}, },
{
url: "https://www.surfsense.com/docs/connectors/obsidian",
lastModified,
changeFrequency: "daily",
priority: 0.8,
},
{ {
url: "https://www.surfsense.com/docs/connectors/slack", url: "https://www.surfsense.com/docs/connectors/slack",
lastModified, lastModified,
@ -182,5 +218,24 @@ export default function sitemap(): MetadataRoute.Sitemap {
changeFrequency: "daily", changeFrequency: "daily",
priority: 0.8, priority: 0.8,
}, },
{
url: "https://www.surfsense.com/docs/how-to/realtime-collaboration",
lastModified,
changeFrequency: "daily",
priority: 0.8,
},
// Developer documentation
{
url: "https://www.surfsense.com/docs/testing",
lastModified,
changeFrequency: "daily",
priority: 0.7,
},
{
url: "https://www.surfsense.com/docs/code-of-conduct",
lastModified,
changeFrequency: "daily",
priority: 0.7,
},
]; ];
} }

View file

@ -1,33 +1,17 @@
import { atom } from "jotai"; import { atom } from "jotai";
import type { ChatVisibility } from "@/lib/chat/thread-persistence"; import type { ChatVisibility } from "@/lib/chat/thread-persistence";
import { reportPanelAtom, reportPanelOpenAtom } from "./report-panel.atom"; import { reportPanelAtom } from "./report-panel.atom";
// TODO: Update `hasComments` to true when the first comment is created on a thread.
// Currently it only updates on thread load. The gutter still works because
// `addingCommentToMessageId` keeps it open, but the state is technically stale.
// TODO: Reset `addingCommentToMessageId` to null after a comment is successfully created.
// Currently it stays set until navigation or clicking another message's bubble.
// Not causing issues since panel visibility is driven by per-message comment count.
// TODO: Consider calling `resetCurrentThreadAtom` when unmounting the chat page
// for explicit cleanup, though React navigation handles this implicitly.
interface CurrentThreadState { interface CurrentThreadState {
id: number | null; id: number | null;
visibility: ChatVisibility | null; visibility: ChatVisibility | null;
hasComments: boolean; hasComments: boolean;
addingCommentToMessageId: number | null;
/** Whether the right-side comments panel is collapsed (desktop only) */
commentsCollapsed: boolean;
} }
const initialState: CurrentThreadState = { const initialState: CurrentThreadState = {
id: null, id: null,
visibility: null, visibility: null,
hasComments: false, hasComments: false,
addingCommentToMessageId: null,
commentsCollapsed: false,
}; };
export const currentThreadAtom = atom<CurrentThreadState>(initialState); export const currentThreadAtom = atom<CurrentThreadState>(initialState);
@ -36,63 +20,22 @@ export const commentsEnabledAtom = atom(
(get) => get(currentThreadAtom).visibility === "SEARCH_SPACE" (get) => get(currentThreadAtom).visibility === "SEARCH_SPACE"
); );
export const showCommentsGutterAtom = atom((get) => {
const thread = get(currentThreadAtom);
// Hide gutter if comments are collapsed
if (thread.commentsCollapsed) return false;
// Hide gutter if report panel is open (report panel takes the right side)
if (get(reportPanelOpenAtom)) return false;
return (
thread.visibility === "SEARCH_SPACE" &&
(thread.hasComments || thread.addingCommentToMessageId !== null)
);
});
export const addingCommentToMessageIdAtom = atom(
(get) => get(currentThreadAtom).addingCommentToMessageId,
(get, set, messageId: number | null) => {
set(currentThreadAtom, { ...get(currentThreadAtom), addingCommentToMessageId: messageId });
}
);
// Setter atom for updating thread visibility
export const setThreadVisibilityAtom = atom(null, (get, set, newVisibility: ChatVisibility) => { export const setThreadVisibilityAtom = atom(null, (get, set, newVisibility: ChatVisibility) => {
set(currentThreadAtom, { ...get(currentThreadAtom), visibility: newVisibility }); set(currentThreadAtom, { ...get(currentThreadAtom), visibility: newVisibility });
}); });
export const resetCurrentThreadAtom = atom(null, (_, set) => { export const resetCurrentThreadAtom = atom(null, (_, set) => {
set(currentThreadAtom, initialState); set(currentThreadAtom, initialState);
// Also close the report panel when resetting the thread
set(reportPanelAtom, { isOpen: false, reportId: null, title: null, wordCount: null }); set(reportPanelAtom, { isOpen: false, reportId: null, title: null, wordCount: null });
}); });
/** Atom to read whether comments panel is collapsed */
export const commentsCollapsedAtom = atom((get) => get(currentThreadAtom).commentsCollapsed);
/** Atom to toggle the comments collapsed state */
export const toggleCommentsCollapsedAtom = atom(null, (get, set) => {
const current = get(currentThreadAtom);
set(currentThreadAtom, { ...current, commentsCollapsed: !current.commentsCollapsed });
});
/** Atom to explicitly set the comments collapsed state */
export const setCommentsCollapsedAtom = atom(null, (get, set, collapsed: boolean) => {
set(currentThreadAtom, { ...get(currentThreadAtom), commentsCollapsed: collapsed });
});
/** Target comment ID to scroll to (from URL navigation or inbox click) */ /** Target comment ID to scroll to (from URL navigation or inbox click) */
export const targetCommentIdAtom = atom<number | null>(null); export const targetCommentIdAtom = atom<number | null>(null);
/** Setter for target comment ID - also ensures comments are not collapsed */ export const setTargetCommentIdAtom = atom(null, (_, set, commentId: number | null) => {
export const setTargetCommentIdAtom = atom(null, (get, set, commentId: number | null) => {
// Ensure comments are not collapsed when navigating to a comment
if (commentId !== null) {
set(currentThreadAtom, { ...get(currentThreadAtom), commentsCollapsed: false });
}
set(targetCommentIdAtom, commentId); set(targetCommentIdAtom, commentId);
}); });
/** Clear target after navigation completes */
export const clearTargetCommentIdAtom = atom(null, (_, set) => { export const clearTargetCommentIdAtom = atom(null, (_, set) => {
set(targetCommentIdAtom, null); set(targetCommentIdAtom, null);
}); });

View file

@ -1,4 +1,6 @@
import { atom } from "jotai"; import { atom } from "jotai";
import { documentsSidebarOpenAtom } from "@/atoms/documents/ui.atoms";
import { rightPanelCollapsedAtom, rightPanelTabAtom } from "@/atoms/layout/right-panel.atom";
interface ReportPanelState { interface ReportPanelState {
isOpen: boolean; isOpen: boolean;
@ -43,10 +45,14 @@ export const openReportPanelAtom = atom(
wordCount: wordCount ?? null, wordCount: wordCount ?? null,
shareToken: shareToken ?? null, shareToken: shareToken ?? null,
}); });
set(rightPanelTabAtom, "report");
set(rightPanelCollapsedAtom, false);
set(documentsSidebarOpenAtom, true);
} }
); );
/** Action atom to close the report panel */ /** Action atom to close the report panel */
export const closeReportPanelAtom = atom(null, (_, set) => { export const closeReportPanelAtom = atom(null, (_get, set) => {
set(reportPanelAtom, initialState); set(reportPanelAtom, initialState);
set(rightPanelTabAtom, "sources");
}); });

View file

@ -0,0 +1,8 @@
import { atom } from "jotai";
export type RightPanelTab = "sources" | "report";
export const rightPanelTabAtom = atom<RightPanelTab>("sources");
/** Whether the right panel is collapsed (hidden but state preserved) */
export const rightPanelCollapsedAtom = atom(false);

View file

@ -6,16 +6,11 @@ import {
useAssistantState, useAssistantState,
useMessage, useMessage,
} from "@assistant-ui/react"; } from "@assistant-ui/react";
import { useAtom, useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
import { CheckIcon, CopyIcon, DownloadIcon, MessageSquare, RefreshCwIcon } from "lucide-react"; import { CheckIcon, CopyIcon, DownloadIcon, MessageSquare, RefreshCwIcon } from "lucide-react";
import type { FC } from "react"; import type { FC } from "react";
import { useContext, useEffect, useMemo, useRef, useState } from "react"; import { useContext, useEffect, useMemo, useRef, useState } from "react";
import { import { commentsEnabledAtom, targetCommentIdAtom } from "@/atoms/chat/current-thread.atom";
addingCommentToMessageIdAtom,
commentsCollapsedAtom,
commentsEnabledAtom,
targetCommentIdAtom,
} from "@/atoms/chat/current-thread.atom";
import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms"; import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms";
import { MarkdownText } from "@/components/assistant-ui/markdown-text"; import { MarkdownText } from "@/components/assistant-ui/markdown-text";
import { import {
@ -26,7 +21,6 @@ import { ToolFallback } from "@/components/assistant-ui/tool-fallback";
import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button"; import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
import { CommentPanelContainer } from "@/components/chat-comments/comment-panel-container/comment-panel-container"; import { CommentPanelContainer } from "@/components/chat-comments/comment-panel-container/comment-panel-container";
import { CommentSheet } from "@/components/chat-comments/comment-sheet/comment-sheet"; import { CommentSheet } from "@/components/chat-comments/comment-sheet/comment-sheet";
import { CommentTrigger } from "@/components/chat-comments/comment-trigger/comment-trigger";
import { useComments } from "@/hooks/use-comments"; import { useComments } from "@/hooks/use-comments";
import { useMediaQuery } from "@/hooks/use-media-query"; import { useMediaQuery } from "@/hooks/use-media-query";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@ -96,20 +90,17 @@ function parseMessageId(assistantUiMessageId: string | undefined): number | null
} }
export const AssistantMessage: FC = () => { export const AssistantMessage: FC = () => {
const [messageHeight, setMessageHeight] = useState<number | undefined>(undefined);
const [isSheetOpen, setIsSheetOpen] = useState(false); const [isSheetOpen, setIsSheetOpen] = useState(false);
const [isInlineOpen, setIsInlineOpen] = useState(false);
const messageRef = useRef<HTMLDivElement>(null); const messageRef = useRef<HTMLDivElement>(null);
const commentPanelRef = useRef<HTMLDivElement>(null);
const commentTriggerRef = useRef<HTMLButtonElement>(null);
const messageId = useAssistantState(({ message }) => message?.id); const messageId = useAssistantState(({ message }) => message?.id);
const searchSpaceId = useAtomValue(activeSearchSpaceIdAtom); const searchSpaceId = useAtomValue(activeSearchSpaceIdAtom);
const dbMessageId = parseMessageId(messageId); const dbMessageId = parseMessageId(messageId);
const commentsEnabled = useAtomValue(commentsEnabledAtom); const commentsEnabled = useAtomValue(commentsEnabledAtom);
const commentsCollapsed = useAtomValue(commentsCollapsedAtom);
const [addingCommentToMessageId, setAddingCommentToMessageId] = useAtom(
addingCommentToMessageIdAtom
);
// Screen size detection for responsive comment UI // Desktop: >= 1024px (inline expandable), Medium: 768px-1023px (right sheet), Mobile: <768px (bottom sheet)
// Mobile: < 768px (bottom sheet), Medium: 768px - 1024px (right sheet), Desktop: >= 1024px (inline panel)
const isMediumScreen = useMediaQuery("(min-width: 768px) and (max-width: 1023px)"); const isMediumScreen = useMediaQuery("(min-width: 768px) and (max-width: 1023px)");
const isDesktop = useMediaQuery("(min-width: 1024px)"); const isDesktop = useMediaQuery("(min-width: 1024px)");
@ -122,10 +113,8 @@ export const AssistantMessage: FC = () => {
enabled: !!dbMessageId, enabled: !!dbMessageId,
}); });
// Target comment navigation - read target from global atom
const targetCommentId = useAtomValue(targetCommentIdAtom); const targetCommentId = useAtomValue(targetCommentIdAtom);
// Check if target comment belongs to this message (including replies)
const hasTargetComment = useMemo(() => { const hasTargetComment = useMemo(() => {
if (!targetCommentId || !commentsData?.comments) return false; if (!targetCommentId || !commentsData?.comments) return false;
return commentsData.comments.some( return commentsData.comments.some(
@ -135,27 +124,36 @@ export const AssistantMessage: FC = () => {
const commentCount = commentsData?.total_count ?? 0; const commentCount = commentsData?.total_count ?? 0;
const hasComments = commentCount > 0; const hasComments = commentCount > 0;
const isAddingComment = dbMessageId !== null && addingCommentToMessageId === dbMessageId;
const showCommentPanel = hasComments || isAddingComment;
const handleToggleAddComment = () => { const showCommentTrigger = searchSpaceId && commentsEnabled && !isMessageStreaming && dbMessageId;
if (!dbMessageId) return;
setAddingCommentToMessageId(isAddingComment ? null : dbMessageId);
};
const handleCommentTriggerClick = () => {
setIsSheetOpen(true);
};
// Close floating panel when clicking outside (but not on portaled popover/dropdown content)
useEffect(() => { useEffect(() => {
if (!messageRef.current) return; if (!isInlineOpen) return;
const el = messageRef.current; const handleClickOutside = (e: MouseEvent) => {
const update = () => setMessageHeight(el.offsetHeight); const target = e.target as Element;
update(); if (
const observer = new ResizeObserver(update); commentPanelRef.current?.contains(target) ||
observer.observe(el); commentTriggerRef.current?.contains(target) ||
return () => observer.disconnect(); target.closest?.("[data-radix-popper-content-wrapper]")
}, []); )
return;
setIsInlineOpen(false);
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [isInlineOpen]);
// Auto-open floating panel on desktop when this message has the target comment
useEffect(() => {
if (hasTargetComment && isDesktop && commentsLoaded) {
setIsInlineOpen(true);
const timeoutId = setTimeout(() => {
messageRef.current?.scrollIntoView({ behavior: "smooth", block: "center" });
}, 100);
return () => clearTimeout(timeoutId);
}
}, [hasTargetComment, isDesktop, commentsLoaded]);
// Auto-open sheet on mobile/tablet when this message has the target comment // Auto-open sheet on mobile/tablet when this message has the target comment
useEffect(() => { useEffect(() => {
@ -164,20 +162,6 @@ export const AssistantMessage: FC = () => {
} }
}, [hasTargetComment, isDesktop, commentsLoaded]); }, [hasTargetComment, isDesktop, commentsLoaded]);
// Scroll message into view when it contains target comment (desktop)
useEffect(() => {
if (hasTargetComment && isDesktop && commentsLoaded && messageRef.current) {
// Small delay to ensure DOM is ready after comments render
const timeoutId = setTimeout(() => {
messageRef.current?.scrollIntoView({ behavior: "smooth", block: "center" });
}, 100);
return () => clearTimeout(timeoutId);
}
}, [hasTargetComment, isDesktop, commentsLoaded]);
const showCommentTrigger = searchSpaceId && commentsEnabled && !isMessageStreaming && dbMessageId;
// Determine sheet side based on screen size
const sheetSide = isMediumScreen ? "right" : "bottom"; const sheetSide = isMediumScreen ? "right" : "bottom";
return ( return (
@ -186,54 +170,25 @@ export const AssistantMessage: FC = () => {
className="aui-assistant-message-root group fade-in slide-in-from-bottom-1 relative mx-auto w-full max-w-(--thread-max-width) animate-in py-3 duration-150" className="aui-assistant-message-root group fade-in slide-in-from-bottom-1 relative mx-auto w-full max-w-(--thread-max-width) animate-in py-3 duration-150"
data-role="assistant" data-role="assistant"
> >
<AssistantMessageInner /> {/* Comment trigger — right-aligned, just below user query on all screen sizes */}
{showCommentTrigger && (
{/* Desktop comment panel - only on lg screens and above, hidden when collapsed */} <div className="mr-2 mb-1 flex justify-end">
{searchSpaceId && commentsEnabled && !isMessageStreaming && !commentsCollapsed && (
<div className="absolute left-full top-0 ml-4 hidden lg:block w-72">
<div
className={`sticky top-3 ${showCommentPanel ? "opacity-100" : "opacity-0 group-hover:opacity-100"} transition-opacity`}
>
{!hasComments && (
<CommentTrigger
commentCount={0}
isOpen={isAddingComment}
onClick={handleToggleAddComment}
disabled={!dbMessageId}
/>
)}
{showCommentPanel && dbMessageId && (
<div
className={
hasComments ? "" : "mt-2 animate-in fade-in slide-in-from-top-2 duration-200"
}
>
<CommentPanelContainer
messageId={dbMessageId}
isOpen={true}
maxHeight={messageHeight}
/>
</div>
)}
</div>
</div>
)}
{/* Mobile & Medium screen comment trigger - shown below lg breakpoint */}
{showCommentTrigger && !isDesktop && (
<div className="ml-2 mt-1 flex justify-start">
<button <button
ref={isDesktop ? commentTriggerRef : undefined}
type="button" type="button"
onClick={handleCommentTriggerClick} onClick={
isDesktop ? () => setIsInlineOpen((prev) => !prev) : () => setIsSheetOpen(true)
}
className={cn( className={cn(
"flex items-center gap-2 rounded-full px-3 py-1.5 text-sm transition-colors", "flex items-center gap-1.5 rounded-full px-3 py-1 text-sm transition-colors",
hasComments isDesktop && isInlineOpen
? "border border-primary/50 bg-primary/5 text-primary hover:bg-primary/10" ? "bg-primary/10 text-primary"
: "text-muted-foreground hover:bg-muted hover:text-foreground" : hasComments
? "text-primary hover:bg-primary/10"
: "text-muted-foreground hover:text-foreground hover:bg-muted"
)} )}
> >
<MessageSquare className={cn("size-4", hasComments && "fill-current")} /> <MessageSquare className={cn("size-3.5", hasComments && "fill-current")} />
{hasComments ? ( {hasComments ? (
<span> <span>
{commentCount} {commentCount === 1 ? "comment" : "comments"} {commentCount} {commentCount === 1 ? "comment" : "comments"}
@ -245,7 +200,19 @@ export const AssistantMessage: FC = () => {
</div> </div>
)} )}
{/* Comment sheet - bottom for mobile, right for medium screens */} {/* Desktop floating comment panel — overlays on top of chat content */}
{showCommentTrigger && isDesktop && isInlineOpen && dbMessageId && (
<div
ref={commentPanelRef}
className="absolute right-0 top-10 z-30 w-full max-w-md animate-in fade-in slide-in-from-top-2 duration-200"
>
<CommentPanelContainer messageId={dbMessageId} isOpen={true} variant="inline" />
</div>
)}
<AssistantMessageInner />
{/* Comment sheet — bottom for mobile, right for medium screens */}
{showCommentTrigger && !isDesktop && ( {showCommentTrigger && !isDesktop && (
<CommentSheet <CommentSheet
messageId={dbMessageId} messageId={dbMessageId}

View file

@ -6,6 +6,7 @@ import Link from "next/link";
import { useSearchParams } from "next/navigation"; import { useSearchParams } from "next/navigation";
import { type FC, forwardRef, useImperativeHandle, useMemo } from "react"; import { type FC, forwardRef, useImperativeHandle, useMemo } from "react";
import { documentTypeCountsAtom } from "@/atoms/documents/document-query.atoms"; import { documentTypeCountsAtom } from "@/atoms/documents/document-query.atoms";
import { statusInboxItemsAtom } from "@/atoms/inbox/status-inbox.atom";
import { import {
globalNewLLMConfigsAtom, globalNewLLMConfigsAtom,
llmPreferencesAtom, llmPreferencesAtom,
@ -19,7 +20,6 @@ import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
import { Spinner } from "@/components/ui/spinner"; import { Spinner } from "@/components/ui/spinner";
import { Tabs, TabsContent } from "@/components/ui/tabs"; import { Tabs, TabsContent } from "@/components/ui/tabs";
import type { SearchSourceConnector } from "@/contracts/types/connector.types"; import type { SearchSourceConnector } from "@/contracts/types/connector.types";
import { statusInboxItemsAtom } from "@/atoms/inbox/status-inbox.atom";
import { useConnectorsElectric } from "@/hooks/use-connectors-electric"; import { useConnectorsElectric } from "@/hooks/use-connectors-electric";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { ConnectorDialogHeader } from "./connector-popup/components/connector-dialog-header"; import { ConnectorDialogHeader } from "./connector-popup/components/connector-dialog-header";
@ -47,400 +47,407 @@ interface ConnectorIndicatorProps {
export const ConnectorIndicator = forwardRef<ConnectorIndicatorHandle, ConnectorIndicatorProps>( export const ConnectorIndicator = forwardRef<ConnectorIndicatorHandle, ConnectorIndicatorProps>(
({ showTrigger = true }, ref) => { ({ showTrigger = true }, ref) => {
const searchSpaceId = useAtomValue(activeSearchSpaceIdAtom); const searchSpaceId = useAtomValue(activeSearchSpaceIdAtom);
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const { data: currentUser } = useAtomValue(currentUserAtom); const { data: currentUser } = useAtomValue(currentUserAtom);
const { data: preferences = {}, isFetching: preferencesLoading } = const { data: preferences = {}, isFetching: preferencesLoading } =
useAtomValue(llmPreferencesAtom); useAtomValue(llmPreferencesAtom);
const { data: globalConfigs = [], isFetching: globalConfigsLoading } = const { data: globalConfigs = [], isFetching: globalConfigsLoading } =
useAtomValue(globalNewLLMConfigsAtom); useAtomValue(globalNewLLMConfigsAtom);
// Check if document summary LLM is properly configured // Check if document summary LLM is properly configured
// - If ID is 0 (Auto mode), we need global configs to be available // - If ID is 0 (Auto mode), we need global configs to be available
// - If ID is positive (user config) or negative (specific global config), it's configured // - If ID is positive (user config) or negative (specific global config), it's configured
// - If ID is null/undefined, it's not configured // - If ID is null/undefined, it's not configured
const docSummaryLlmId = preferences.document_summary_llm_id; const docSummaryLlmId = preferences.document_summary_llm_id;
const isAutoMode = docSummaryLlmId === 0; const isAutoMode = docSummaryLlmId === 0;
const hasGlobalConfigs = globalConfigs.length > 0; const hasGlobalConfigs = globalConfigs.length > 0;
const hasDocumentSummaryLLM = const hasDocumentSummaryLLM =
docSummaryLlmId !== null && docSummaryLlmId !== null &&
docSummaryLlmId !== undefined && docSummaryLlmId !== undefined &&
// If it's Auto mode, we need global configs to actually be available // If it's Auto mode, we need global configs to actually be available
(!isAutoMode || hasGlobalConfigs); (!isAutoMode || hasGlobalConfigs);
const llmConfigLoading = preferencesLoading || globalConfigsLoading; const llmConfigLoading = preferencesLoading || globalConfigsLoading;
// Fetch document type counts via the lightweight /type-counts endpoint (cached 10 min) // Fetch document type counts via the lightweight /type-counts endpoint (cached 10 min)
const { data: documentTypeCounts, isFetching: documentTypesLoading } = const { data: documentTypeCounts, isFetching: documentTypesLoading } =
useAtomValue(documentTypeCountsAtom); useAtomValue(documentTypeCountsAtom);
// Read status inbox items from shared atom (populated by LayoutDataProvider) // Read status inbox items from shared atom (populated by LayoutDataProvider)
// instead of creating a duplicate useInbox("status") hook. // instead of creating a duplicate useInbox("status") hook.
const statusInboxItems = useAtomValue(statusInboxItemsAtom); const statusInboxItems = useAtomValue(statusInboxItemsAtom);
const inboxItems = useMemo( const inboxItems = useMemo(
() => statusInboxItems.filter((item) => item.type === "connector_indexing"), () => statusInboxItems.filter((item) => item.type === "connector_indexing"),
[statusInboxItems] [statusInboxItems]
); );
// Check if YouTube view is active // Check if YouTube view is active
const isYouTubeView = searchParams.get("view") === "youtube"; const isYouTubeView = searchParams.get("view") === "youtube";
// Use the custom hook for dialog state management // Use the custom hook for dialog state management
const { const {
isOpen, isOpen,
activeTab, activeTab,
connectingId, connectingId,
isScrolled, isScrolled,
searchQuery, searchQuery,
indexingConfig, indexingConfig,
indexingConnector, indexingConnector,
indexingConnectorConfig, indexingConnectorConfig,
editingConnector, editingConnector,
connectingConnectorType, connectingConnectorType,
isCreatingConnector, isCreatingConnector,
startDate, startDate,
endDate, endDate,
isStartingIndexing, isStartingIndexing,
isSaving, isSaving,
isDisconnecting, isDisconnecting,
periodicEnabled, periodicEnabled,
frequencyMinutes, frequencyMinutes,
enableSummary, enableSummary,
allConnectors, allConnectors,
viewingAccountsType, viewingAccountsType,
viewingMCPList, viewingMCPList,
setSearchQuery, setSearchQuery,
setStartDate, setStartDate,
setEndDate, setEndDate,
setPeriodicEnabled, setPeriodicEnabled,
setFrequencyMinutes, setFrequencyMinutes,
setEnableSummary, setEnableSummary,
handleOpenChange, handleOpenChange,
handleTabChange, handleTabChange,
handleScroll, handleScroll,
handleConnectOAuth, handleConnectOAuth,
handleConnectNonOAuth, handleConnectNonOAuth,
handleCreateWebcrawler, handleCreateWebcrawler,
handleCreateYouTubeCrawler, handleCreateYouTubeCrawler,
handleSubmitConnectForm, handleSubmitConnectForm,
handleStartIndexing, handleStartIndexing,
handleSkipIndexing, handleSkipIndexing,
handleStartEdit, handleStartEdit,
handleSaveConnector, handleSaveConnector,
handleDisconnectConnector, handleDisconnectConnector,
handleBackFromEdit, handleBackFromEdit,
handleBackFromConnect, handleBackFromConnect,
handleBackFromYouTube, handleBackFromYouTube,
handleViewAccountsList, handleViewAccountsList,
handleBackFromAccountsList, handleBackFromAccountsList,
handleBackFromMCPList, handleBackFromMCPList,
handleAddNewMCPFromList, handleAddNewMCPFromList,
handleQuickIndexConnector, handleQuickIndexConnector,
connectorConfig, connectorConfig,
setConnectorConfig, setConnectorConfig,
setIndexingConnectorConfig, setIndexingConnectorConfig,
setConnectorName, setConnectorName,
} = useConnectorDialog(); } = useConnectorDialog();
// Fetch connectors using Electric SQL + PGlite for real-time updates // Fetch connectors using Electric SQL + PGlite for real-time updates
// This provides instant updates when connectors change, without polling // This provides instant updates when connectors change, without polling
const { const {
connectors: connectorsFromElectric = [], connectors: connectorsFromElectric = [],
loading: connectorsLoading, loading: connectorsLoading,
error: connectorsError, error: connectorsError,
refreshConnectors: refreshConnectorsElectric, refreshConnectors: refreshConnectorsElectric,
} = useConnectorsElectric(searchSpaceId); } = useConnectorsElectric(searchSpaceId);
// Fallback to API if Electric is not available or fails // Fallback to API if Electric is not available or fails
// Use Electric data if: 1) we have data, or 2) still loading without error // Use Electric data if: 1) we have data, or 2) still loading without error
// Use API data if: Electric failed (has error) or finished loading with no data // Use API data if: Electric failed (has error) or finished loading with no data
const useElectricData = const useElectricData =
connectorsFromElectric.length > 0 || (connectorsLoading && !connectorsError); connectorsFromElectric.length > 0 || (connectorsLoading && !connectorsError);
const connectors = useElectricData ? connectorsFromElectric : allConnectors || []; const connectors = useElectricData ? connectorsFromElectric : allConnectors || [];
// Manual refresh function that works with both Electric and API // Manual refresh function that works with both Electric and API
const refreshConnectors = async () => { const refreshConnectors = async () => {
if (useElectricData) { if (useElectricData) {
await refreshConnectorsElectric(); await refreshConnectorsElectric();
} else { } else {
// Fallback: use allConnectors from useConnectorDialog (which uses connectorsAtom) // Fallback: use allConnectors from useConnectorDialog (which uses connectorsAtom)
// The connectorsAtom will handle refetching if needed // The connectorsAtom will handle refetching if needed
} }
}; };
// Track indexing state locally - clears automatically when Electric SQL detects last_indexed_at changed // Track indexing state locally - clears automatically when Electric SQL detects last_indexed_at changed
// Also clears when failed notifications are detected // Also clears when failed notifications are detected
const { indexingConnectorIds, startIndexing, stopIndexing } = useIndexingConnectors( const { indexingConnectorIds, startIndexing, stopIndexing } = useIndexingConnectors(
connectors as SearchSourceConnector[], connectors as SearchSourceConnector[],
inboxItems inboxItems
); );
const isLoading = connectorsLoading || documentTypesLoading; const isLoading = connectorsLoading || documentTypesLoading;
// Get document types that have documents in the search space // Get document types that have documents in the search space
const activeDocumentTypes = documentTypeCounts const activeDocumentTypes = documentTypeCounts
? Object.entries(documentTypeCounts).filter(([, count]) => count > 0) ? Object.entries(documentTypeCounts).filter(([, count]) => count > 0)
: []; : [];
const hasConnectors = connectors.length > 0; const hasConnectors = connectors.length > 0;
const hasSources = hasConnectors || activeDocumentTypes.length > 0; const hasSources = hasConnectors || activeDocumentTypes.length > 0;
const totalSourceCount = connectors.length + activeDocumentTypes.length; const totalSourceCount = connectors.length + activeDocumentTypes.length;
const activeConnectorsCount = connectors.length; const activeConnectorsCount = connectors.length;
// Check which connectors are already connected // Check which connectors are already connected
// Using Electric SQL + PGlite for real-time connector updates // Using Electric SQL + PGlite for real-time connector updates
const connectedTypes = new Set<string>( const connectedTypes = new Set<string>(
(connectors || []).map((c: SearchSourceConnector) => c.connector_type) (connectors || []).map((c: SearchSourceConnector) => c.connector_type)
); );
useImperativeHandle(ref, () => ({ useImperativeHandle(ref, () => ({
open: () => handleOpenChange(true), open: () => handleOpenChange(true),
})); }));
if (!searchSpaceId) return null; if (!searchSpaceId) return null;
return ( return (
<Dialog open={isOpen} onOpenChange={handleOpenChange}> <Dialog open={isOpen} onOpenChange={handleOpenChange}>
{showTrigger && ( {showTrigger && (
<TooltipIconButton <TooltipIconButton
data-joyride="connector-icon" data-joyride="connector-icon"
tooltip={hasConnectors ? `Manage ${activeConnectorsCount} connectors` : "Connect your data"} tooltip={
side="bottom" hasConnectors ? `Manage ${activeConnectorsCount} connectors` : "Connect your data"
className={cn(
"size-[34px] rounded-full p-1 flex items-center justify-center transition-colors relative",
"hover:bg-muted-foreground/15 dark:hover:bg-muted-foreground/30",
"outline-none focus:outline-none focus-visible:outline-none font-semibold text-xs",
"border-0 ring-0 focus:ring-0 shadow-none focus:shadow-none"
)}
aria-label={
hasConnectors ? `View ${activeConnectorsCount} connectors` : "Add your first connector"
}
onClick={() => handleOpenChange(true)}
>
{isLoading ? (
<Spinner size="sm" />
) : (
<>
<Cable className="size-4 stroke-[1.5px]" />
{activeConnectorsCount > 0 && (
<span className="absolute -top-0.5 right-0 flex items-center justify-center min-w-[16px] h-4 px-1 text-[10px] font-medium rounded-full bg-primary text-primary-foreground shadow-sm select-none">
{activeConnectorsCount > 99 ? "99+" : activeConnectorsCount}
</span>
)}
</>
)}
</TooltipIconButton>
)}
<DialogContent className="max-w-3xl w-[95vw] sm:w-full h-[75vh] sm:h-[85vh] flex flex-col p-0 gap-0 overflow-hidden border border-border ring-0 dark:ring-0 bg-muted dark:bg-muted text-foreground focus:outline-none focus:ring-0 focus-visible:outline-none focus-visible:ring-0 [&>button]:right-4 sm:[&>button]:right-12 [&>button]:top-6 sm:[&>button]:top-10 [&>button]:opacity-80 hover:[&>button]:opacity-100 [&>button_svg]:size-5 select-none">
<DialogTitle className="sr-only">Manage Connectors</DialogTitle>
{/* YouTube Crawler View - shown when adding YouTube videos */}
{isYouTubeView && searchSpaceId ? (
<YouTubeCrawlerView searchSpaceId={searchSpaceId} onBack={handleBackFromYouTube} />
) : viewingMCPList ? (
<ConnectorAccountsListView
connectorType="MCP_CONNECTOR"
connectorTitle="MCP Connectors"
connectors={(allConnectors || []) as SearchSourceConnector[]}
indexingConnectorIds={indexingConnectorIds}
onBack={handleBackFromMCPList}
onManage={handleStartEdit}
onAddAccount={handleAddNewMCPFromList}
addButtonText="Add New MCP Server"
/>
) : viewingAccountsType ? (
<ConnectorAccountsListView
connectorType={viewingAccountsType.connectorType}
connectorTitle={viewingAccountsType.connectorTitle}
connectors={(connectors || []) as SearchSourceConnector[]} // Using Electric SQL + PGlite for real-time connector updates (all connector types)
indexingConnectorIds={indexingConnectorIds}
onBack={handleBackFromAccountsList}
onManage={handleStartEdit}
onAddAccount={() => {
// Check both OAUTH_CONNECTORS and COMPOSIO_CONNECTORS
const oauthConnector =
OAUTH_CONNECTORS.find(
(c) => c.connectorType === viewingAccountsType.connectorType
) ||
COMPOSIO_CONNECTORS.find(
(c) => c.connectorType === viewingAccountsType.connectorType
);
if (oauthConnector) {
handleConnectOAuth(oauthConnector);
}
}}
isConnecting={connectingId !== null}
/>
) : connectingConnectorType ? (
<ConnectorConnectView
connectorType={connectingConnectorType}
onSubmit={(formData) => handleSubmitConnectForm(formData, startIndexing)}
onBack={handleBackFromConnect}
isSubmitting={isCreatingConnector}
/>
) : editingConnector ? (
<ConnectorEditView
connector={{
...editingConnector,
config: connectorConfig || editingConnector.config,
name: editingConnector.name,
// Sync last_indexed_at with live data from Electric SQL for real-time updates
last_indexed_at:
(connectors as SearchSourceConnector[]).find((c) => c.id === editingConnector.id)
?.last_indexed_at ?? editingConnector.last_indexed_at,
}}
startDate={startDate}
endDate={endDate}
periodicEnabled={periodicEnabled}
frequencyMinutes={frequencyMinutes}
enableSummary={enableSummary}
isSaving={isSaving}
isDisconnecting={isDisconnecting}
isIndexing={indexingConnectorIds.has(editingConnector.id)}
searchSpaceId={searchSpaceId?.toString()}
onStartDateChange={setStartDate}
onEndDateChange={setEndDate}
onPeriodicEnabledChange={setPeriodicEnabled}
onFrequencyChange={setFrequencyMinutes}
onEnableSummaryChange={setEnableSummary}
onSave={() => {
startIndexing(editingConnector.id);
handleSaveConnector(() => refreshConnectors());
}}
onDisconnect={() => handleDisconnectConnector(() => refreshConnectors())}
onBack={handleBackFromEdit}
onQuickIndex={
editingConnector.connector_type !== "GOOGLE_DRIVE_CONNECTOR"
? () => {
startIndexing(editingConnector.id);
handleQuickIndexConnector(
editingConnector.id,
editingConnector.connector_type,
stopIndexing,
startDate,
endDate
);
}
: undefined
} }
onConfigChange={setConnectorConfig} side="bottom"
onNameChange={setConnectorName} className={cn(
/> "size-[34px] rounded-full p-1 flex items-center justify-center transition-colors relative",
) : indexingConfig ? ( "hover:bg-muted-foreground/15 dark:hover:bg-muted-foreground/30",
<IndexingConfigurationView "outline-none focus:outline-none focus-visible:outline-none font-semibold text-xs",
config={indexingConfig} "border-0 ring-0 focus:ring-0 shadow-none focus:shadow-none"
connector={ )}
indexingConnector aria-label={
? { hasConnectors
...indexingConnector, ? `View ${activeConnectorsCount} connectors`
config: indexingConnectorConfig || indexingConnector.config, : "Add your first connector"
}
: undefined
} }
startDate={startDate} onClick={() => handleOpenChange(true)}
endDate={endDate}
periodicEnabled={periodicEnabled}
frequencyMinutes={frequencyMinutes}
enableSummary={enableSummary}
isStartingIndexing={isStartingIndexing}
onStartDateChange={setStartDate}
onEndDateChange={setEndDate}
onPeriodicEnabledChange={setPeriodicEnabled}
onFrequencyChange={setFrequencyMinutes}
onEnableSummaryChange={setEnableSummary}
onConfigChange={setIndexingConnectorConfig}
onStartIndexing={() => {
if (indexingConfig.connectorId) {
startIndexing(indexingConfig.connectorId);
}
handleStartIndexing(() => refreshConnectors());
}}
onSkip={handleSkipIndexing}
/>
) : (
<Tabs
value={activeTab}
onValueChange={handleTabChange}
className="flex-1 flex flex-col min-h-0"
> >
{/* Header */} {isLoading ? (
<ConnectorDialogHeader <Spinner size="sm" />
activeTab={activeTab} ) : (
totalSourceCount={activeConnectorsCount} <>
searchQuery={searchQuery} <Cable className="size-4 stroke-[1.5px]" />
onTabChange={handleTabChange} {activeConnectorsCount > 0 && (
onSearchChange={setSearchQuery} <span className="absolute -top-0.5 right-0 flex items-center justify-center min-w-[16px] h-4 px-1 text-[10px] font-medium rounded-full bg-primary text-primary-foreground shadow-sm select-none">
isScrolled={isScrolled} {activeConnectorsCount > 99 ? "99+" : activeConnectorsCount}
</span>
)}
</>
)}
</TooltipIconButton>
)}
<DialogContent className="max-w-3xl w-[95vw] sm:w-full h-[75vh] sm:h-[85vh] flex flex-col p-0 gap-0 overflow-hidden border border-border ring-0 dark:ring-0 bg-muted dark:bg-muted text-foreground focus:outline-none focus:ring-0 focus-visible:outline-none focus-visible:ring-0 [&>button]:right-4 sm:[&>button]:right-12 [&>button]:top-6 sm:[&>button]:top-10 [&>button]:opacity-80 hover:[&>button]:opacity-100 [&>button_svg]:size-5 select-none">
<DialogTitle className="sr-only">Manage Connectors</DialogTitle>
{/* YouTube Crawler View - shown when adding YouTube videos */}
{isYouTubeView && searchSpaceId ? (
<YouTubeCrawlerView searchSpaceId={searchSpaceId} onBack={handleBackFromYouTube} />
) : viewingMCPList ? (
<ConnectorAccountsListView
connectorType="MCP_CONNECTOR"
connectorTitle="MCP Connectors"
connectors={(allConnectors || []) as SearchSourceConnector[]}
indexingConnectorIds={indexingConnectorIds}
onBack={handleBackFromMCPList}
onManage={handleStartEdit}
onAddAccount={handleAddNewMCPFromList}
addButtonText="Add New MCP Server"
/> />
) : viewingAccountsType ? (
<ConnectorAccountsListView
connectorType={viewingAccountsType.connectorType}
connectorTitle={viewingAccountsType.connectorTitle}
connectors={(connectors || []) as SearchSourceConnector[]} // Using Electric SQL + PGlite for real-time connector updates (all connector types)
indexingConnectorIds={indexingConnectorIds}
onBack={handleBackFromAccountsList}
onManage={handleStartEdit}
onAddAccount={() => {
// Check both OAUTH_CONNECTORS and COMPOSIO_CONNECTORS
const oauthConnector =
OAUTH_CONNECTORS.find(
(c) => c.connectorType === viewingAccountsType.connectorType
) ||
COMPOSIO_CONNECTORS.find(
(c) => c.connectorType === viewingAccountsType.connectorType
);
if (oauthConnector) {
handleConnectOAuth(oauthConnector);
}
}}
isConnecting={connectingId !== null}
/>
) : connectingConnectorType ? (
<ConnectorConnectView
connectorType={connectingConnectorType}
onSubmit={(formData) => handleSubmitConnectForm(formData, startIndexing)}
onBack={handleBackFromConnect}
isSubmitting={isCreatingConnector}
/>
) : editingConnector ? (
<ConnectorEditView
connector={{
...editingConnector,
config: connectorConfig || editingConnector.config,
name: editingConnector.name,
// Sync last_indexed_at with live data from Electric SQL for real-time updates
last_indexed_at:
(connectors as SearchSourceConnector[]).find((c) => c.id === editingConnector.id)
?.last_indexed_at ?? editingConnector.last_indexed_at,
}}
startDate={startDate}
endDate={endDate}
periodicEnabled={periodicEnabled}
frequencyMinutes={frequencyMinutes}
enableSummary={enableSummary}
isSaving={isSaving}
isDisconnecting={isDisconnecting}
isIndexing={indexingConnectorIds.has(editingConnector.id)}
searchSpaceId={searchSpaceId?.toString()}
onStartDateChange={setStartDate}
onEndDateChange={setEndDate}
onPeriodicEnabledChange={setPeriodicEnabled}
onFrequencyChange={setFrequencyMinutes}
onEnableSummaryChange={setEnableSummary}
onSave={() => {
startIndexing(editingConnector.id);
handleSaveConnector(() => refreshConnectors());
}}
onDisconnect={() => handleDisconnectConnector(() => refreshConnectors())}
onBack={handleBackFromEdit}
onQuickIndex={
editingConnector.connector_type !== "GOOGLE_DRIVE_CONNECTOR"
? () => {
startIndexing(editingConnector.id);
handleQuickIndexConnector(
editingConnector.id,
editingConnector.connector_type,
stopIndexing,
startDate,
endDate
);
}
: undefined
}
onConfigChange={setConnectorConfig}
onNameChange={setConnectorName}
/>
) : indexingConfig ? (
<IndexingConfigurationView
config={indexingConfig}
connector={
indexingConnector
? {
...indexingConnector,
config: indexingConnectorConfig || indexingConnector.config,
}
: undefined
}
startDate={startDate}
endDate={endDate}
periodicEnabled={periodicEnabled}
frequencyMinutes={frequencyMinutes}
enableSummary={enableSummary}
isStartingIndexing={isStartingIndexing}
onStartDateChange={setStartDate}
onEndDateChange={setEndDate}
onPeriodicEnabledChange={setPeriodicEnabled}
onFrequencyChange={setFrequencyMinutes}
onEnableSummaryChange={setEnableSummary}
onConfigChange={setIndexingConnectorConfig}
onStartIndexing={() => {
if (indexingConfig.connectorId) {
startIndexing(indexingConfig.connectorId);
}
handleStartIndexing(() => refreshConnectors());
}}
onSkip={handleSkipIndexing}
/>
) : (
<Tabs
value={activeTab}
onValueChange={handleTabChange}
className="flex-1 flex flex-col min-h-0"
>
{/* Header */}
<ConnectorDialogHeader
activeTab={activeTab}
totalSourceCount={activeConnectorsCount}
searchQuery={searchQuery}
onTabChange={handleTabChange}
onSearchChange={setSearchQuery}
isScrolled={isScrolled}
/>
{/* Content */} {/* Content */}
<div className="flex-1 min-h-0 relative overflow-hidden"> <div className="flex-1 min-h-0 relative overflow-hidden">
<div className="h-full overflow-y-auto" onScroll={handleScroll}> <div className="h-full overflow-y-auto" onScroll={handleScroll}>
<div className="px-4 sm:px-12 py-4 sm:py-8 pb-12 sm:pb-16"> <div className="px-4 sm:px-12 py-4 sm:py-8 pb-12 sm:pb-16">
{/* LLM Configuration Warning */} {/* LLM Configuration Warning */}
{!llmConfigLoading && !hasDocumentSummaryLLM && ( {!llmConfigLoading && !hasDocumentSummaryLLM && (
<Alert variant="destructive" className="mb-6"> <Alert variant="destructive" className="mb-6">
<AlertTriangle className="h-4 w-4" /> <AlertTriangle className="h-4 w-4" />
<AlertTitle>LLM Configuration Required</AlertTitle> <AlertTitle>LLM Configuration Required</AlertTitle>
<AlertDescription className="mt-2"> <AlertDescription className="mt-2">
<p className="mb-3"> <p className="mb-3">
{isAutoMode && !hasGlobalConfigs {isAutoMode && !hasGlobalConfigs
? "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 asChild size="sm" variant="outline">
<Link href={`/dashboard/${searchSpaceId}/settings?tab=models`}> <Link href={`/dashboard/${searchSpaceId}/settings?tab=models`}>
<Settings className="mr-2 h-4 w-4" /> <Settings className="mr-2 h-4 w-4" />
Go to Settings Go to Settings
</Link> </Link>
</Button> </Button>
</AlertDescription> </AlertDescription>
</Alert> </Alert>
)} )}
<TabsContent value="all" className="m-0"> <TabsContent value="all" className="m-0">
<AllConnectorsTab <AllConnectorsTab
searchQuery={searchQuery}
searchSpaceId={searchSpaceId}
connectedTypes={connectedTypes}
connectingId={connectingId}
allConnectors={connectors}
documentTypeCounts={documentTypeCounts}
indexingConnectorIds={indexingConnectorIds}
onConnectOAuth={hasDocumentSummaryLLM ? handleConnectOAuth : () => {}}
onConnectNonOAuth={hasDocumentSummaryLLM ? handleConnectNonOAuth : () => {}}
onCreateWebcrawler={
hasDocumentSummaryLLM ? handleCreateWebcrawler : () => {}
}
onCreateYouTubeCrawler={
hasDocumentSummaryLLM ? handleCreateYouTubeCrawler : () => {}
}
onManage={handleStartEdit}
onViewAccountsList={handleViewAccountsList}
/>
</TabsContent>
<ActiveConnectorsTab
searchQuery={searchQuery} searchQuery={searchQuery}
searchSpaceId={searchSpaceId} hasSources={hasSources}
connectedTypes={connectedTypes} totalSourceCount={totalSourceCount}
connectingId={connectingId} activeDocumentTypes={activeDocumentTypes}
allConnectors={connectors} connectors={connectors as SearchSourceConnector[]}
documentTypeCounts={documentTypeCounts}
indexingConnectorIds={indexingConnectorIds} indexingConnectorIds={indexingConnectorIds}
onConnectOAuth={hasDocumentSummaryLLM ? handleConnectOAuth : () => {}} onTabChange={handleTabChange}
onConnectNonOAuth={hasDocumentSummaryLLM ? handleConnectNonOAuth : () => {}}
onCreateWebcrawler={hasDocumentSummaryLLM ? handleCreateWebcrawler : () => {}}
onCreateYouTubeCrawler={
hasDocumentSummaryLLM ? handleCreateYouTubeCrawler : () => {}
}
onManage={handleStartEdit} onManage={handleStartEdit}
onViewAccountsList={handleViewAccountsList} onViewAccountsList={handleViewAccountsList}
/> />
</TabsContent> </div>
<ActiveConnectorsTab
searchQuery={searchQuery}
hasSources={hasSources}
totalSourceCount={totalSourceCount}
activeDocumentTypes={activeDocumentTypes}
connectors={connectors as SearchSourceConnector[]}
indexingConnectorIds={indexingConnectorIds}
onTabChange={handleTabChange}
onManage={handleStartEdit}
onViewAccountsList={handleViewAccountsList}
/>
</div> </div>
{/* Bottom fade shadow */}
<div className="absolute bottom-0 left-0 right-0 h-7 bg-linear-to-t from-muted via-muted/80 to-transparent pointer-events-none z-10" />
</div> </div>
{/* Bottom fade shadow */} </Tabs>
<div className="absolute bottom-0 left-0 right-0 h-7 bg-linear-to-t from-muted via-muted/80 to-transparent pointer-events-none z-10" /> )}
</div> </DialogContent>
</Tabs> </Dialog>
)} );
</DialogContent> }
</Dialog> );
);
});
ConnectorIndicator.displayName = "ConnectorIndicator"; ConnectorIndicator.displayName = "ConnectorIndicator";

View file

@ -70,23 +70,21 @@ export const BaiduSearchApiConnectForm: FC<ConnectFormProps> = ({ onSubmit, isSu
return ( return (
<div className="space-y-6 pb-6"> <div className="space-y-6 pb-6">
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20 p-2 sm:p-3 flex items-center [&>svg]:relative [&>svg]:left-0 [&>svg]:top-0 [&>svg+div]:translate-y-0"> <Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20 p-2 sm:p-3">
<Info className="h-3 w-3 sm:h-4 sm:w-4 shrink-0 ml-1" /> <Info className="size-4 shrink-0" />
<div className="-ml-1"> <AlertTitle className="text-xs sm:text-sm">API Key Required</AlertTitle>
<AlertTitle className="text-xs sm:text-sm">API Key Required</AlertTitle> <AlertDescription className="text-[10px] sm:text-xs">
<AlertDescription className="text-[10px] sm:text-xs !pl-0"> You'll need a Baidu AppBuilder API key to use this connector. You can get one by signing
You'll need a Baidu AppBuilder API key to use this connector. You can get one by signing up at{" "}
up at{" "} <a
<a href="https://qianfan.cloud.baidu.com/"
href="https://qianfan.cloud.baidu.com/" target="_blank"
target="_blank" rel="noopener noreferrer"
rel="noopener noreferrer" className="font-medium underline underline-offset-4"
className="font-medium underline underline-offset-4" >
> qianfan.cloud.baidu.com
qianfan.cloud.baidu.com </a>
</a> </AlertDescription>
</AlertDescription>
</div>
</Alert> </Alert>
<div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6 space-y-3 sm:space-y-4"> <div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6 space-y-3 sm:space-y-4">

View file

@ -96,15 +96,13 @@ export const BookStackConnectForm: FC<ConnectFormProps> = ({ onSubmit, isSubmitt
return ( return (
<div className="space-y-6 pb-6"> <div className="space-y-6 pb-6">
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20 p-2 sm:p-3 flex items-center [&>svg]:relative [&>svg]:left-0 [&>svg]:top-0 [&>svg+div]:translate-y-0"> <Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20 p-2 sm:p-3">
<Info className="h-3 w-3 sm:h-4 sm:w-4 shrink-0 ml-1" /> <Info className="size-4 shrink-0" />
<div className="-ml-1"> <AlertTitle className="text-xs sm:text-sm">API Token Required</AlertTitle>
<AlertTitle className="text-xs sm:text-sm">API Token Required</AlertTitle> <AlertDescription className="text-[10px] sm:text-xs">
<AlertDescription className="text-[10px] sm:text-xs !pl-0"> You'll need a BookStack API Token to use this connector. You can create one from your
You'll need a BookStack API Token to use this connector. You can create one from your BookStack instance settings.
BookStack instance settings. </AlertDescription>
</AlertDescription>
</div>
</Alert> </Alert>
<div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6 space-y-3 sm:space-y-4"> <div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6 space-y-3 sm:space-y-4">

View file

@ -64,15 +64,13 @@ export const CirclebackConnectForm: FC<ConnectFormProps> = ({ onSubmit, isSubmit
return ( return (
<div className="space-y-6 pb-6"> <div className="space-y-6 pb-6">
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20 p-2 sm:p-3 flex items-center [&>svg]:relative [&>svg]:left-0 [&>svg]:top-0 [&>svg+div]:translate-y-0"> <Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20 p-2 sm:p-3">
<Webhook className="h-3 w-3 sm:h-4 sm:w-4 shrink-0 ml-1" /> <Webhook className="size-4 shrink-0" />
<div className="-ml-1"> <AlertTitle className="text-xs sm:text-sm">Webhook-Based Integration</AlertTitle>
<AlertTitle className="text-xs sm:text-sm">Webhook-Based Integration</AlertTitle> <AlertDescription className="text-[10px] sm:text-xs">
<AlertDescription className="text-[10px] sm:text-xs !pl-0"> Circleback uses webhooks to automatically send meeting data. After connecting, you'll
Circleback uses webhooks to automatically send meeting data. After connecting, you'll receive a webhook URL to configure in your Circleback settings.
receive a webhook URL to configure in your Circleback settings. </AlertDescription>
</AlertDescription>
</div>
</Alert> </Alert>
<div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6 space-y-3 sm:space-y-4"> <div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6 space-y-3 sm:space-y-4">

View file

@ -172,14 +172,12 @@ export const ElasticsearchConnectForm: FC<ConnectFormProps> = ({ onSubmit, isSub
return ( return (
<div className="space-y-6 pb-6"> <div className="space-y-6 pb-6">
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20 p-2 sm:p-3 flex items-center [&>svg]:relative [&>svg]:left-0 [&>svg]:top-0 [&>svg+div]:translate-y-0"> <Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20 p-2 sm:p-3">
<Info className="h-3 w-3 sm:h-4 sm:w-4 shrink-0 ml-1" /> <Info className="size-4 shrink-0" />
<div className="-ml-1"> <AlertTitle className="text-xs sm:text-sm">API Key Required</AlertTitle>
<AlertTitle className="text-xs sm:text-sm">API Key Required</AlertTitle> <AlertDescription className="text-[10px] sm:text-xs">
<AlertDescription className="text-[10px] sm:text-xs !pl-0"> Enter your Elasticsearch cluster endpoint URL and authentication credentials to connect.
Enter your Elasticsearch cluster endpoint URL and authentication credentials to connect. </AlertDescription>
</AlertDescription>
</div>
</Alert> </Alert>
<div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6 space-y-3 sm:space-y-4"> <div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6 space-y-3 sm:space-y-4">

View file

@ -105,24 +105,21 @@ export const GithubConnectForm: FC<ConnectFormProps> = ({ onSubmit, isSubmitting
return ( return (
<div className="space-y-6 pb-6"> <div className="space-y-6 pb-6">
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20 p-2 sm:p-3 flex items-center [&>svg]:relative [&>svg]:left-0 [&>svg]:top-0 [&>svg+div]:translate-y-0"> <Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20 p-2 sm:p-3">
<Info className="h-3 w-3 sm:h-4 sm:w-4 shrink-0 ml-1" /> <Info className="size-4 shrink-0" />
<div className="-ml-1"> <AlertTitle className="text-xs sm:text-sm">Personal Access Token (Optional)</AlertTitle>
<AlertTitle className="text-xs sm:text-sm">Personal Access Token (Optional)</AlertTitle> <AlertDescription className="text-[10px] sm:text-xs">
<AlertDescription className="text-[10px] sm:text-xs !pl-0"> A GitHub PAT is only required for private repositories. Public repos work without a token.{" "}
A GitHub PAT is only required for private repositories. Public repos work without a <a
token.{" "} href="https://github.com/settings/tokens/new?description=surfsense&scopes=repo"
<a target="_blank"
href="https://github.com/settings/tokens/new?description=surfsense&scopes=repo" rel="noopener noreferrer"
target="_blank" className="font-medium underline underline-offset-4 inline-flex items-center gap-1.5"
rel="noopener noreferrer" >
className="font-medium underline underline-offset-4 inline-flex items-center gap-1.5" Get your token
> <ExternalLink className="h-3 w-3 sm:h-4 sm:w-4" />
Get your token </a>{" "}
<ExternalLink className="h-3 w-3 sm:h-4 sm:w-4" /> </AlertDescription>
</a>{" "}
</AlertDescription>
</div>
</Alert> </Alert>
<div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6 space-y-3 sm:space-y-4"> <div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6 space-y-3 sm:space-y-4">

View file

@ -70,22 +70,20 @@ export const LinkupApiConnectForm: FC<ConnectFormProps> = ({ onSubmit, isSubmitt
return ( return (
<div className="space-y-6 pb-6"> <div className="space-y-6 pb-6">
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20 p-2 sm:p-3 flex items-center [&>svg]:relative [&>svg]:left-0 [&>svg]:top-0 [&>svg+div]:translate-y-0"> <Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20 p-2 sm:p-3">
<Info className="h-3 w-3 sm:h-4 sm:w-4 shrink-0 ml-1" /> <Info className="size-4 shrink-0" />
<div className="-ml-1"> <AlertTitle className="text-xs sm:text-sm">API Key Required</AlertTitle>
<AlertTitle className="text-xs sm:text-sm">API Key Required</AlertTitle> <AlertDescription className="text-[10px] sm:text-xs">
<AlertDescription className="text-[10px] sm:text-xs !pl-0"> You'll need a Linkup API key to use this connector. You can get one by signing up at{" "}
You'll need a Linkup API key to use this connector. You can get one by signing up at{" "} <a
<a href="https://linkup.so"
href="https://linkup.so" target="_blank"
target="_blank" rel="noopener noreferrer"
rel="noopener noreferrer" className="font-medium underline underline-offset-4"
className="font-medium underline underline-offset-4" >
> linkup.so
linkup.so </a>
</a> </AlertDescription>
</AlertDescription>
</div>
</Alert> </Alert>
<div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6 space-y-3 sm:space-y-4"> <div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6 space-y-3 sm:space-y-4">

View file

@ -88,22 +88,20 @@ export const LumaConnectForm: FC<ConnectFormProps> = ({ onSubmit, isSubmitting }
return ( return (
<div className="space-y-6 pb-6"> <div className="space-y-6 pb-6">
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20 p-2 sm:p-3 flex items-center [&>svg]:relative [&>svg]:left-0 [&>svg]:top-0 [&>svg+div]:translate-y-0"> <Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20 p-2 sm:p-3">
<Info className="h-3 w-3 sm:h-4 sm:w-4 shrink-0 ml-1" /> <Info className="size-4 shrink-0" />
<div className="-ml-1"> <AlertTitle className="text-xs sm:text-sm">API Key Required</AlertTitle>
<AlertTitle className="text-xs sm:text-sm">API Key Required</AlertTitle> <AlertDescription className="text-[10px] sm:text-xs">
<AlertDescription className="text-[10px] sm:text-xs !pl-0"> You'll need a Luma API Key to use this connector. You can create one from{" "}
You'll need a Luma API Key to use this connector. You can create one from{" "} <a
<a href="https://lu.ma/api"
href="https://lu.ma/api" target="_blank"
target="_blank" rel="noopener noreferrer"
rel="noopener noreferrer" className="font-medium underline underline-offset-4"
className="font-medium underline underline-offset-4" >
> Luma API Settings
Luma API Settings </a>
</a> </AlertDescription>
</AlertDescription>
</div>
</Alert> </Alert>
<div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6 space-y-3 sm:space-y-4"> <div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6 space-y-3 sm:space-y-4">

View file

@ -136,7 +136,7 @@ export const MCPConnectForm: FC<ConnectFormProps> = ({ onSubmit, isSubmitting })
return ( return (
<div className="space-y-6 pb-6"> <div className="space-y-6 pb-6">
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20 p-2 sm:p-3 [&>svg]:top-2 sm:[&>svg]:top-3"> <Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20 p-2 sm:p-3">
<Server className="h-4 w-4 shrink-0" /> <Server className="h-4 w-4 shrink-0" />
<AlertDescription className="text-[10px] sm:text-xs"> <AlertDescription className="text-[10px] sm:text-xs">
Connect to an MCP (Model Context Protocol) server. Each MCP server is added as a separate Connect to an MCP (Model Context Protocol) server. Each MCP server is added as a separate
@ -230,55 +230,51 @@ export const MCPConnectForm: FC<ConnectFormProps> = ({ onSubmit, isSubmitting })
) : ( ) : (
<XCircle className="h-4 w-4 text-red-600" /> <XCircle className="h-4 w-4 text-red-600" />
)} )}
<div className="flex-1"> <div className="col-start-2 flex items-center justify-between">
<div className="flex items-center justify-between"> <AlertTitle className="text-sm">
<AlertTitle className="text-sm"> {testResult.status === "success" ? "Connection Successful" : "Connection Failed"}
{testResult.status === "success" </AlertTitle>
? "Connection Successful" {testResult.tools.length > 0 && (
: "Connection Failed"} <Button
</AlertTitle> type="button"
{testResult.tools.length > 0 && ( variant="ghost"
<Button size="sm"
type="button" className="h-6 px-2 self-start sm:self-auto text-xs"
variant="ghost" onClick={(e) => {
size="sm" e.preventDefault();
className="h-6 px-2 self-start sm:self-auto text-xs" e.stopPropagation();
onClick={(e) => { setShowDetails(!showDetails);
e.preventDefault(); }}
e.stopPropagation(); >
setShowDetails(!showDetails); {showDetails ? (
}} <>
> <ChevronUp className="h-3 w-3 mr-1" />
{showDetails ? ( <span className="hidden sm:inline">Hide Details</span>
<> <span className="sm:hidden">Hide</span>
<ChevronUp className="h-3 w-3 mr-1" /> </>
<span className="hidden sm:inline">Hide Details</span> ) : (
<span className="sm:hidden">Hide</span> <>
</> <ChevronDown className="h-3 w-3 mr-1" />
) : ( <span className="hidden sm:inline">Show Details</span>
<> <span className="sm:hidden">Show</span>
<ChevronDown className="h-3 w-3 mr-1" /> </>
<span className="hidden sm:inline">Show Details</span> )}
<span className="sm:hidden">Show</span> </Button>
</> )}
)}
</Button>
)}
</div>
<AlertDescription className="text-[10px] sm:text-xs mt-1">
{testResult.message}
{showDetails && testResult.tools.length > 0 && (
<div className="mt-3 pt-3 border-t border-green-500/20">
<p className="font-semibold mb-2">Available tools:</p>
<ul className="list-disc list-inside text-xs space-y-0.5">
{testResult.tools.map((tool, i) => (
<li key={i}>{tool.name}</li>
))}
</ul>
</div>
)}
</AlertDescription>
</div> </div>
<AlertDescription className="text-[10px] sm:text-xs mt-1">
{testResult.message}
{showDetails && testResult.tools.length > 0 && (
<div className="mt-3 pt-3 border-t border-green-500/20">
<p className="font-semibold mb-2">Available tools:</p>
<ul className="list-disc list-inside text-xs space-y-0.5">
{testResult.tools.map((tool, i) => (
<li key={i}>{tool.name}</li>
))}
</ul>
</div>
)}
</AlertDescription>
</Alert> </Alert>
)} )}
</div> </div>

View file

@ -102,15 +102,13 @@ export const ObsidianConnectForm: FC<ConnectFormProps> = ({ onSubmit, isSubmitti
return ( return (
<div className="space-y-6 pb-6"> <div className="space-y-6 pb-6">
<Alert className="bg-purple-500/10 dark:bg-purple-500/10 border-purple-500/30 p-2 sm:p-3 flex items-center [&>svg]:relative [&>svg]:left-0 [&>svg]:top-0 [&>svg+div]:translate-y-0"> <Alert className="bg-purple-500/10 dark:bg-purple-500/10 border-purple-500/30 p-2 sm:p-3">
<Info className="h-3 w-3 sm:h-4 sm:w-4 shrink-0 ml-1 text-purple-500" /> <Info className="size-4 shrink-0 text-purple-500" />
<div className="-ml-1"> <AlertTitle className="text-xs sm:text-sm">Self-Hosted Only</AlertTitle>
<AlertTitle className="text-xs sm:text-sm">Self-Hosted Only</AlertTitle> <AlertDescription className="text-[10px] sm:text-xs">
<AlertDescription className="text-[10px] sm:text-xs pl-0!"> This connector requires direct file system access and only works with self-hosted
This connector requires direct file system access and only works with self-hosted SurfSense installations.
SurfSense installations. </AlertDescription>
</AlertDescription>
</div>
</Alert> </Alert>
<div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6 space-y-3 sm:space-y-4"> <div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6 space-y-3 sm:space-y-4">

View file

@ -123,23 +123,21 @@ export const SearxngConnectForm: FC<ConnectFormProps> = ({ onSubmit, isSubmittin
return ( return (
<div className="space-y-6 pb-6"> <div className="space-y-6 pb-6">
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20 p-2 sm:p-3 flex items-center [&>svg]:relative [&>svg]:left-0 [&>svg]:top-0 [&>svg+div]:translate-y-0"> <Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20 p-2 sm:p-3">
<Info className="h-3 w-3 sm:h-4 sm:w-4 shrink-0 ml-1" /> <Info className="size-4 shrink-0" />
<div className="-ml-1"> <AlertTitle className="text-xs sm:text-sm">SearxNG Instance Required</AlertTitle>
<AlertTitle className="text-xs sm:text-sm">SearxNG Instance Required</AlertTitle> <AlertDescription className="text-[10px] sm:text-xs">
<AlertDescription className="text-[10px] sm:text-xs !pl-0"> You need access to a running SearxNG instance. Refer to the{" "}
You need access to a running SearxNG instance. Refer to the{" "} <a
<a href="https://docs.searxng.org/admin/installation-docker.html"
href="https://docs.searxng.org/admin/installation-docker.html" target="_blank"
target="_blank" rel="noopener noreferrer"
rel="noopener noreferrer" className="font-medium underline underline-offset-4"
className="font-medium underline underline-offset-4" >
> SearxNG installation guide
SearxNG installation guide </a>{" "}
</a>{" "} for setup instructions. If your instance requires an API key, include it below.
for setup instructions. If your instance requires an API key, include it below. </AlertDescription>
</AlertDescription>
</div>
</Alert> </Alert>
<div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6 space-y-3 sm:space-y-4"> <div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6 space-y-3 sm:space-y-4">

View file

@ -70,22 +70,20 @@ export const TavilyApiConnectForm: FC<ConnectFormProps> = ({ onSubmit, isSubmitt
return ( return (
<div className="space-y-6 pb-6"> <div className="space-y-6 pb-6">
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20 p-2 sm:p-3 flex items-center [&>svg]:relative [&>svg]:left-0 [&>svg]:top-0 [&>svg+div]:translate-y-0"> <Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20 p-2 sm:p-3">
<Info className="h-3 w-3 sm:h-4 sm:w-4 shrink-0 ml-1" /> <Info className="size-4 shrink-0" />
<div className="-ml-1"> <AlertTitle className="text-xs sm:text-sm">API Key Required</AlertTitle>
<AlertTitle className="text-xs sm:text-sm">API Key Required</AlertTitle> <AlertDescription className="text-[10px] sm:text-xs">
<AlertDescription className="text-[10px] sm:text-xs !pl-0"> You'll need a Tavily API key to use this connector. You can get one by signing up at{" "}
You'll need a Tavily API key to use this connector. You can get one by signing up at{" "} <a
<a href="https://tavily.com"
href="https://tavily.com" target="_blank"
target="_blank" rel="noopener noreferrer"
rel="noopener noreferrer" className="font-medium underline underline-offset-4"
className="font-medium underline underline-offset-4" >
> tavily.com
tavily.com </a>
</a> </AlertDescription>
</AlertDescription>
</div>
</Alert> </Alert>
<div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6 space-y-3 sm:space-y-4"> <div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6 space-y-3 sm:space-y-4">

View file

@ -166,7 +166,7 @@ export const CirclebackConfig: FC<CirclebackConfigProps> = ({ connector, onNameC
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20"> <Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20">
<Info className="h-3 w-3 sm:h-4 sm:w-4" /> <Info className="h-3 w-3 sm:h-4 sm:w-4" />
<AlertTitle className="text-xs sm:text-sm">Configuration Instructions</AlertTitle> <AlertTitle className="text-xs sm:text-sm">Configuration Instructions</AlertTitle>
<AlertDescription className="text-[10px] sm:text-xs !pl-0 mt-1"> <AlertDescription className="text-[10px] sm:text-xs mt-1">
Configure this URL in Circleback Settings Automations Create automation Send Configure this URL in Circleback Settings Automations Create automation Send
webhook request. The webhook will automatically send meeting notes, transcripts, and webhook request. The webhook will automatically send meeting notes, transcripts, and
action items to this search space. action items to this search space.

View file

@ -235,55 +235,51 @@ export const MCPConfig: FC<MCPConfigProps> = ({ connector, onConfigChange, onNam
) : ( ) : (
<XCircle className="h-4 w-4 text-red-600" /> <XCircle className="h-4 w-4 text-red-600" />
)} )}
<div className="flex-1"> <div className="col-start-2 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 sm:gap-0">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 sm:gap-0"> <AlertTitle className="text-sm">
<AlertTitle className="text-sm"> {testResult.status === "success" ? "Connection Successful" : "Connection Failed"}
{testResult.status === "success" </AlertTitle>
? "Connection Successful" {testResult.tools.length > 0 && (
: "Connection Failed"} <Button
</AlertTitle> type="button"
{testResult.tools.length > 0 && ( variant="ghost"
<Button size="sm"
type="button" className="h-6 px-2 self-start sm:self-auto text-xs"
variant="ghost" onClick={(e) => {
size="sm" e.preventDefault();
className="h-6 px-2 self-start sm:self-auto text-xs" e.stopPropagation();
onClick={(e) => { setShowDetails(!showDetails);
e.preventDefault(); }}
e.stopPropagation(); >
setShowDetails(!showDetails); {showDetails ? (
}} <>
> <ChevronUp className="h-3 w-3 mr-1" />
{showDetails ? ( <span className="hidden sm:inline">Hide Details</span>
<> <span className="sm:hidden">Hide</span>
<ChevronUp className="h-3 w-3 mr-1" /> </>
<span className="hidden sm:inline">Hide Details</span> ) : (
<span className="sm:hidden">Hide</span> <>
</> <ChevronDown className="h-3 w-3 mr-1" />
) : ( <span className="hidden sm:inline">Show Details</span>
<> <span className="sm:hidden">Show</span>
<ChevronDown className="h-3 w-3 mr-1" /> </>
<span className="hidden sm:inline">Show Details</span> )}
<span className="sm:hidden">Show</span> </Button>
</> )}
)}
</Button>
)}
</div>
<AlertDescription className="text-xs mt-1">
{testResult.message}
{showDetails && testResult.tools.length > 0 && (
<div className="mt-3 pt-3 border-t border-green-500/20">
<p className="font-semibold mb-2">Available tools:</p>
<ul className="list-disc list-inside text-xs space-y-0.5">
{testResult.tools.map((tool) => (
<li key={tool.name}>{tool.name}</li>
))}
</ul>
</div>
)}
</AlertDescription>
</div> </div>
<AlertDescription className="text-xs mt-1">
{testResult.message}
{showDetails && testResult.tools.length > 0 && (
<div className="mt-3 pt-3 border-t border-green-500/20">
<p className="font-semibold mb-2">Available tools:</p>
<ul className="list-disc list-inside text-xs space-y-0.5">
{testResult.tools.map((tool) => (
<li key={tool.name}>{tool.name}</li>
))}
</ul>
</div>
)}
</AlertDescription>
</Alert> </Alert>
)} )}
</div> </div>

View file

@ -63,7 +63,8 @@ export const WebcrawlerConfig: FC<ConnectorConfigProps> = ({ connector, onConfig
<div className="flex items-start gap-3 rounded-lg border border-blue-200/50 bg-blue-50/50 dark:border-blue-500/20 dark:bg-blue-950/20 p-3 text-xs sm:text-sm"> <div className="flex items-start gap-3 rounded-lg border border-blue-200/50 bg-blue-50/50 dark:border-blue-500/20 dark:bg-blue-950/20 p-3 text-xs sm:text-sm">
<Info className="size-4 mt-0.5 shrink-0 text-blue-600 dark:text-blue-400" /> <Info className="size-4 mt-0.5 shrink-0 text-blue-600 dark:text-blue-400" />
<p className="text-muted-foreground"> <p className="text-muted-foreground">
Want a quick answer from a webpage without indexing it? Just paste the URL directly into the chat instead. Want a quick answer from a webpage without indexing it? Just paste the URL directly into
the chat instead.
</p> </p>
</div> </div>
@ -123,9 +124,9 @@ export const WebcrawlerConfig: FC<ConnectorConfigProps> = ({ connector, onConfig
</div> </div>
{/* Info Alert */} {/* Info Alert */}
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20 p-2 sm:p-3 flex items-center gap-2 [&>svg]:relative [&>svg]:left-0 [&>svg]:top-0 [&>svg+div]:translate-y-0"> <Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20 p-2 sm:p-3">
<Info className="h-3 w-3 sm:h-4 sm:w-4 shrink-0" /> <Info className="size-4 shrink-0" />
<AlertDescription className="text-[10px] sm:text-xs !pl-0"> <AlertDescription className="text-[10px] sm:text-xs">
Configuration is saved when you start indexing. You can update these settings anytime from Configuration is saved when you start indexing. You can update these settings anytime from
the connector management page. the connector management page.
</AlertDescription> </AlertDescription>

View file

@ -280,9 +280,7 @@ export const YouTubeCrawlerView: FC<YouTubeCrawlerViewProps> = ({ searchSpaceId,
<div className="flex items-start gap-3 rounded-lg border border-blue-200/50 bg-blue-50/50 dark:border-blue-500/20 dark:bg-blue-950/20 p-4 text-sm"> <div className="flex items-start gap-3 rounded-lg border border-blue-200/50 bg-blue-50/50 dark:border-blue-500/20 dark:bg-blue-950/20 p-4 text-sm">
<Info className="size-4 mt-0.5 shrink-0 text-blue-600 dark:text-blue-400" /> <Info className="size-4 mt-0.5 shrink-0 text-blue-600 dark:text-blue-400" />
<p className="text-muted-foreground"> <p className="text-muted-foreground">{t("chat_tip")}</p>
{t("chat_tip")}
</p>
</div> </div>
<div className="bg-muted/50 rounded-lg p-4 text-sm"> <div className="bg-muted/50 rounded-lg p-4 text-sm">

View file

@ -14,7 +14,6 @@ import {
AlertCircle, AlertCircle,
ArrowDownIcon, ArrowDownIcon,
ArrowUpIcon, ArrowUpIcon,
Cable,
CheckIcon, CheckIcon,
ChevronLeftIcon, ChevronLeftIcon,
ChevronRightIcon, ChevronRightIcon,
@ -23,17 +22,20 @@ import {
PlusIcon, PlusIcon,
RefreshCwIcon, RefreshCwIcon,
SquareIcon, SquareIcon,
SquareLibrary, Unplug,
Upload,
X,
} from "lucide-react"; } from "lucide-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 { chatSessionStateAtom } from "@/atoms/chat/chat-session-state.atom"; import { chatSessionStateAtom } from "@/atoms/chat/chat-session-state.atom";
import { showCommentsGutterAtom } from "@/atoms/chat/current-thread.atom";
import { import {
mentionedDocumentsAtom, mentionedDocumentsAtom,
sidebarSelectedDocumentsAtom, sidebarSelectedDocumentsAtom,
} from "@/atoms/chat/mentioned-documents.atom"; } from "@/atoms/chat/mentioned-documents.atom";
import { connectorDialogOpenAtom } from "@/atoms/connector-dialog/connector-dialog.atoms";
import { connectorsAtom } from "@/atoms/connectors/connector-query.atoms";
import { documentsSidebarOpenAtom } from "@/atoms/documents/ui.atoms"; import { documentsSidebarOpenAtom } from "@/atoms/documents/ui.atoms";
import { membersAtom } from "@/atoms/members/members-query.atoms"; import { membersAtom } from "@/atoms/members/members-query.atoms";
import { import {
@ -48,6 +50,7 @@ import {
ConnectorIndicator, ConnectorIndicator,
type ConnectorIndicatorHandle, type ConnectorIndicatorHandle,
} from "@/components/assistant-ui/connector-popup"; } from "@/components/assistant-ui/connector-popup";
import { useDocumentUploadDialog } from "@/components/assistant-ui/document-upload-popup";
import { import {
InlineMentionEditor, InlineMentionEditor,
type InlineMentionEditorRef, type InlineMentionEditorRef,
@ -60,13 +63,21 @@ import {
import { ToolFallback } from "@/components/assistant-ui/tool-fallback"; import { ToolFallback } from "@/components/assistant-ui/tool-fallback";
import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button"; import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
import { UserMessage } from "@/components/assistant-ui/user-message"; import { UserMessage } from "@/components/assistant-ui/user-message";
import { SLIDEOUT_PANEL_OPENED_EVENT } from "@/components/layout/ui/sidebar/SidebarSlideOutPanel";
import { import {
DocumentMentionPicker, DocumentMentionPicker,
type DocumentMentionPickerRef, type DocumentMentionPickerRef,
} from "@/components/new-chat/document-mention-picker"; } from "@/components/new-chat/document-mention-picker";
import type { ThinkingStep } from "@/components/tool-ui/deepagent-thinking"; import type { ThinkingStep } from "@/components/tool-ui/deepagent-thinking";
import { Avatar, AvatarFallback, AvatarGroup } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
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";
@ -95,8 +106,6 @@ export const Thread: FC<ThreadProps> = ({ messageThinkingSteps = new Map() }) =>
}; };
const ThreadContent: FC = () => { const ThreadContent: FC = () => {
const showGutter = useAtomValue(showCommentsGutterAtom);
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-background"
@ -106,10 +115,7 @@ const ThreadContent: FC = () => {
> >
<ThreadPrimitive.Viewport <ThreadPrimitive.Viewport
turnAnchor="top" turnAnchor="top"
className={cn( className="aui-thread-viewport relative flex flex-1 min-h-0 flex-col overflow-y-auto px-4 pt-4"
"aui-thread-viewport relative flex flex-1 min-h-0 flex-col overflow-y-auto px-4 pt-4 transition-[padding] duration-300 ease-out",
showGutter && "lg:pr-30"
)}
> >
<AssistantIf condition={({ thread }) => thread.isEmpty}> <AssistantIf condition={({ thread }) => thread.isEmpty}>
<ThreadWelcome /> <ThreadWelcome />
@ -228,6 +234,72 @@ const ThreadWelcome: FC = () => {
); );
}; };
const BANNER_CONNECTORS = [
{ type: "GOOGLE_DRIVE_CONNECTOR", label: "Google Drive" },
{ type: "GOOGLE_GMAIL_CONNECTOR", label: "Gmail" },
{ type: "NOTION_CONNECTOR", label: "Notion" },
{ type: "YOUTUBE_CONNECTOR", label: "YouTube" },
{ type: "SLACK_CONNECTOR", label: "Slack" },
] as const;
const BANNER_DISMISSED_KEY = "surfsense-connect-tools-banner-dismissed";
const ConnectToolsBanner: FC = () => {
const { data: connectors } = useAtomValue(connectorsAtom);
const setConnectorDialogOpen = useSetAtom(connectorDialogOpenAtom);
const [dismissed, setDismissed] = useState(() => {
if (typeof window === "undefined") return false;
return localStorage.getItem(BANNER_DISMISSED_KEY) === "true";
});
const hasConnectors = (connectors?.length ?? 0) > 0;
if (dismissed || hasConnectors) return null;
const handleDismiss = (e: React.MouseEvent) => {
e.stopPropagation();
setDismissed(true);
localStorage.setItem(BANNER_DISMISSED_KEY, "true");
};
return (
<div className="md:hidden border-t border-border/50 bg-muted-foreground/[0.04]">
<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]"
onClick={() => setConnectorDialogOpen(true)}
>
<Unplug className="size-4 text-muted-foreground/70 shrink-0" />
<span className="text-[13px] text-muted-foreground/80 flex-1">Connect your tools</span>
<AvatarGroup className="shrink-0">
{BANNER_CONNECTORS.map(({ type, label }, i) => (
<Avatar key={type} className="size-6" style={{ zIndex: BANNER_CONNECTORS.length - i }}>
<AvatarFallback className="bg-muted text-[10px]">
{getConnectorIcon(type, "size-3.5")}
</AvatarFallback>
</Avatar>
))}
</AvatarGroup>
<span
role="button"
tabIndex={0}
onClick={handleDismiss}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
handleDismiss(e as unknown as React.MouseEvent);
}
}}
className="shrink-0 ml-0.5 p-0.5 text-muted-foreground/40 hover:text-foreground transition-colors"
aria-label="Dismiss"
>
<X className="size-3.5" />
</span>
</button>
</div>
);
};
const Composer: FC = () => { const Composer: FC = () => {
// Document mention state (atoms persist across component remounts) // Document mention state (atoms persist across component remounts)
const [mentionedDocuments, setMentionedDocuments] = useAtom(mentionedDocumentsAtom); const [mentionedDocuments, setMentionedDocuments] = useAtom(mentionedDocumentsAtom);
@ -312,6 +384,16 @@ const Composer: FC = () => {
} }
}, [isThreadEmpty]); }, [isThreadEmpty]);
// Close document picker when a slide-out panel (inbox, shared/private chats) opens
useEffect(() => {
const handler = () => {
setShowDocumentPopover(false);
setMentionQuery("");
};
window.addEventListener(SLIDEOUT_PANEL_OPENED_EVENT, handler);
return () => window.removeEventListener(SLIDEOUT_PANEL_OPENED_EVENT, handler);
}, []);
// Sync editor text with assistant-ui composer runtime // Sync editor text with assistant-ui composer runtime
const handleEditorChange = useCallback( const handleEditorChange = useCallback(
(text: string) => { (text: string) => {
@ -425,9 +507,9 @@ const Composer: FC = () => {
currentUserId={currentUser?.id ?? null} currentUserId={currentUser?.id ?? null}
members={members ?? []} members={members ?? []}
/> />
<div className="aui-composer-attachment-dropzone flex w-full flex-col rounded-2xl border-input bg-muted px-1 pt-2 outline-none transition-shadow"> <div className="aui-composer-attachment-dropzone flex w-full flex-col overflow-hidden rounded-2xl border-input bg-muted pt-2 outline-none transition-shadow">
{/* Inline editor with @mention support */} {/* Inline editor with @mention support */}
<div ref={editorContainerRef} className="aui-composer-input-wrapper px-3 pt-3 pb-6"> <div ref={editorContainerRef} className="aui-composer-input-wrapper px-4 pt-3 pb-6">
<InlineMentionEditor <InlineMentionEditor
ref={editorRef} ref={editorRef}
placeholder={currentPlaceholder} placeholder={currentPlaceholder}
@ -466,6 +548,7 @@ const Composer: FC = () => {
document.body document.body
)} )}
<ComposerAction isBlockedByOtherUser={isBlockedByOtherUser} /> <ComposerAction isBlockedByOtherUser={isBlockedByOtherUser} />
<ConnectToolsBanner />
</div> </div>
</ComposerPrimitive.Root> </ComposerPrimitive.Root>
); );
@ -481,7 +564,9 @@ const ComposerAction: FC<ComposerActionProps> = ({ isBlockedByOtherUser = false
const setDocumentsSidebarOpen = useSetAtom(documentsSidebarOpenAtom); const setDocumentsSidebarOpen = useSetAtom(documentsSidebarOpenAtom);
const connectorRef = useRef<ConnectorIndicatorHandle>(null); const connectorRef = useRef<ConnectorIndicatorHandle>(null);
const [addMenuOpen, setAddMenuOpen] = useState(false); const [addMenuOpen, setAddMenuOpen] = useState(false);
const { openDialog: openUploadDialog } = useDocumentUploadDialog();
const { data: connectors } = useAtomValue(connectorsAtom);
const connectorCount = connectors?.length ?? 0;
const isComposerTextEmpty = useAssistantState(({ composer }) => { const isComposerTextEmpty = useAssistantState(({ composer }) => {
const text = composer.text?.trim() || ""; const text = composer.text?.trim() || "";
return text.length === 0; return text.length === 0;
@ -506,55 +591,61 @@ const ComposerAction: FC<ComposerActionProps> = ({ isBlockedByOtherUser = false
const isSendDisabled = isComposerEmpty || !hasModelConfigured || isBlockedByOtherUser; const isSendDisabled = isComposerEmpty || !hasModelConfigured || isBlockedByOtherUser;
return ( return (
<div className="aui-composer-action-wrapper relative mx-2 mb-2 flex items-center justify-between"> <div className="aui-composer-action-wrapper relative mx-3 mb-2 flex items-center justify-between">
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<Popover open={addMenuOpen} onOpenChange={setAddMenuOpen}> <DropdownMenu open={addMenuOpen} onOpenChange={setAddMenuOpen}>
<PopoverTrigger asChild> <DropdownMenuTrigger asChild>
<TooltipIconButton <TooltipIconButton
tooltip="Configuration" tooltip="Add files and more"
side="bottom" side="bottom"
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"
aria-label="Configuration" aria-label="Add files and more"
data-joyride="connector-icon" data-joyride="connector-icon"
> >
<PlusIcon className="size-4" /> <PlusIcon className="size-4" />
</TooltipIconButton> </TooltipIconButton>
</PopoverTrigger> </DropdownMenuTrigger>
<PopoverContent <DropdownMenuContent
side="bottom" side="bottom"
align="start" align="start"
sideOffset={12} sideOffset={12}
className="w-[calc(100vw-2rem)] max-w-60 sm:w-60 p-2" className="w-[calc(100vw-2rem)] max-w-60 sm:w-60"
> >
<div className="flex flex-col gap-0.5"> <DropdownMenuItem
<button onClick={() => {
type="button" setAddMenuOpen(false);
className="flex w-full items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground" openUploadDialog();
onClick={() => { }}
setAddMenuOpen(false); >
setDocumentsSidebarOpen(true); <Upload className="size-4 shrink-0" />
}} Upload files
> </DropdownMenuItem>
<SquareLibrary className="size-4 shrink-0" /> <DropdownMenuItem
Documents onClick={() => {
</button> setAddMenuOpen(false);
<button connectorRef.current?.open();
type="button" }}
className="flex w-full items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground" >
onClick={() => { <Unplug className="size-4 shrink-0" />
setAddMenuOpen(false); {connectorCount > 0 ? "Manage tools" : "Connect your tools"}
connectorRef.current?.open(); {connectorCount > 0 && (
}} <span className="ml-auto text-xs text-muted-foreground">{connectorCount}</span>
> )}
<Cable className="size-4 shrink-0" /> </DropdownMenuItem>
Manage connectors </DropdownMenuContent>
</button> </DropdownMenu>
</div>
</PopoverContent>
</Popover>
<ConnectorIndicator ref={connectorRef} showTrigger={false} /> <ConnectorIndicator ref={connectorRef} showTrigger={false} />
{sidebarDocs.length > 0 && (
<button
type="button"
onClick={() => setDocumentsSidebarOpen(true)}
className="rounded-full border border-border/60 bg-accent/50 px-2.5 py-1 text-xs font-medium text-foreground/80 transition-colors hover:bg-accent"
>
{sidebarDocs.length} {sidebarDocs.length === 1 ? "source" : "sources"} selected
</button>
)}
</div> </div>
{!hasModelConfigured && ( {!hasModelConfigured && (
@ -565,16 +656,6 @@ const ComposerAction: FC<ComposerActionProps> = ({ isBlockedByOtherUser = false
)} )}
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{sidebarDocs.length > 0 && (
<button
type="button"
onClick={() => setDocumentsSidebarOpen(true)}
className="rounded-full border border-border/60 bg-accent/50 px-2.5 py-1 text-xs font-medium text-foreground/80 transition-colors hover:bg-accent"
>
{sidebarDocs.length} {sidebarDocs.length === 1 ? "source" : "sources"} selected
</button>
)}
<AssistantIf condition={({ thread }) => !thread.isRunning}> <AssistantIf condition={({ thread }) => !thread.isRunning}>
<ComposerPrimitive.Send asChild disabled={isSendDisabled}> <ComposerPrimitive.Send asChild disabled={isSendDisabled}>
<TooltipIconButton <TooltipIconButton

View file

@ -1,6 +1,6 @@
"use client"; "use client";
import { Send, X } from "lucide-react"; import { ArrowUp, Send, X } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Popover, PopoverAnchor, PopoverContent } from "@/components/ui/popover"; import { Popover, PopoverAnchor, PopoverContent } from "@/components/ui/popover";
@ -86,6 +86,7 @@ export function CommentComposer({
onCancel, onCancel,
autoFocus = false, autoFocus = false,
initialValue = "", initialValue = "",
compact = false,
}: CommentComposerProps) { }: CommentComposerProps) {
const [displayContent, setDisplayContent] = useState(initialValue); const [displayContent, setDisplayContent] = useState(initialValue);
const [insertedMentions, setInsertedMentions] = useState<InsertedMention[]>([]); const [insertedMentions, setInsertedMentions] = useState<InsertedMention[]>([]);
@ -257,44 +258,46 @@ export function CommentComposer({
}, [adjustTextareaHeight]); }, [adjustTextareaHeight]);
return ( return (
<div className="flex flex-col gap-2"> <div className={cn("flex", compact ? "flex-row items-center gap-2" : "flex-col gap-2")}>
<Popover <div className={cn(compact && "flex-1 min-w-0")}>
open={mentionState.isActive} <Popover
onOpenChange={(open) => !open && closeMentionPicker()} open={mentionState.isActive}
modal={false} onOpenChange={(open) => !open && closeMentionPicker()}
> modal={false}
<PopoverAnchor asChild>
<Textarea
ref={textareaRef}
value={displayContent}
onChange={handleInputChange}
onKeyDown={handleKeyDown}
placeholder={placeholder}
className="min-h-[40px] max-h-[200px] resize-none overflow-y-auto scrollbar-thin"
rows={1}
disabled={isSubmitting}
/>
</PopoverAnchor>
<PopoverContent
side="top"
align="start"
sideOffset={4}
collisionPadding={8}
className="w-72 p-0"
onOpenAutoFocus={(e) => e.preventDefault()}
> >
<MemberMentionPicker <PopoverAnchor asChild>
members={members} <Textarea
query={mentionState.query} ref={textareaRef}
highlightedIndex={highlightedIndex} value={displayContent}
isLoading={membersLoading} onChange={handleInputChange}
onSelect={insertMention} onKeyDown={handleKeyDown}
onHighlightChange={setHighlightedIndex} placeholder={placeholder}
/> className="min-h-[40px] max-h-[200px] w-full resize-none overflow-y-auto scrollbar-thin border-none shadow-none focus-visible:ring-0 bg-transparent dark:bg-transparent"
</PopoverContent> rows={1}
</Popover> disabled={isSubmitting}
/>
</PopoverAnchor>
<PopoverContent
side="top"
align="start"
sideOffset={4}
collisionPadding={8}
className="w-72 p-0"
onOpenAutoFocus={(e) => e.preventDefault()}
>
<MemberMentionPicker
members={members}
query={mentionState.query}
highlightedIndex={highlightedIndex}
isLoading={membersLoading}
onSelect={insertMention}
onHighlightChange={setHighlightedIndex}
/>
</PopoverContent>
</Popover>
</div>
<div className="flex items-center justify-end gap-2"> <div className={cn("flex items-center gap-2", !compact && "justify-end")}>
{onCancel && ( {onCancel && (
<Button <Button
type="button" type="button"
@ -309,13 +312,19 @@ export function CommentComposer({
)} )}
<Button <Button
type="button" type="button"
size="sm" size={compact ? "icon" : "sm"}
onClick={handleSubmit} onClick={handleSubmit}
disabled={!canSubmit} disabled={!canSubmit}
className={cn(!canSubmit && "opacity-50")} className={cn(!canSubmit && "opacity-50", compact && "size-8 shrink-0 rounded-full")}
> >
<Send className="mr-1 size-4" /> {compact ? (
{submitLabel} <ArrowUp className="size-4" />
) : (
<>
<Send className="mr-1 size-4" />
{submitLabel}
</>
)}
</Button> </Button>
</div> </div>
</div> </div>

View file

@ -10,6 +10,8 @@ export interface CommentComposerProps {
onCancel?: () => void; onCancel?: () => void;
autoFocus?: boolean; autoFocus?: boolean;
initialValue?: string; initialValue?: string;
/** Compact mode: inline send button with ArrowUp icon, no label */
compact?: boolean;
} }
export interface MentionState { export interface MentionState {

View file

@ -6,7 +6,6 @@ import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
DropdownMenuItem, DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import type { CommentActionsProps } from "./types"; import type { CommentActionsProps } from "./types";
@ -34,7 +33,6 @@ export function CommentActions({ canEdit, canDelete, onEdit, onDelete }: Comment
Edit Edit
</DropdownMenuItem> </DropdownMenuItem>
)} )}
{canEdit && canDelete && <DropdownMenuSeparator />}
{canDelete && ( {canDelete && (
<DropdownMenuItem onClick={onDelete}> <DropdownMenuItem onClick={onDelete}>
<Trash2 className="mr-2 size-4" /> <Trash2 className="mr-2 size-4" />

View file

@ -2,6 +2,6 @@ export interface CommentPanelContainerProps {
messageId: number; messageId: number;
isOpen: boolean; isOpen: boolean;
maxHeight?: number; maxHeight?: number;
/** Variant for responsive styling - desktop shows border/bg, mobile is plain */ /** Variant for responsive styling - desktop shows border/bg, mobile is plain, inline fits within message width */
variant?: "desktop" | "mobile"; variant?: "desktop" | "mobile" | "inline";
} }

View file

@ -1,25 +1,10 @@
"use client"; "use client";
import { useAtom } from "jotai";
import { currentUserAtom } from "@/atoms/user/user-query.atoms";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { CommentComposer } from "../comment-composer/comment-composer"; import { CommentComposer } from "../comment-composer/comment-composer";
import { CommentThread } from "../comment-thread/comment-thread"; import { CommentThread } from "../comment-thread/comment-thread";
import type { CommentPanelProps } from "./types"; import type { CommentPanelProps } from "./types";
function getInitials(name: string | null | undefined, email: string): string {
if (name) {
return name
.split(" ")
.map((part) => part[0])
.join("")
.toUpperCase()
.slice(0, 2);
}
return email[0].toUpperCase();
}
export function CommentPanel({ export function CommentPanel({
threads, threads,
members, members,
@ -33,20 +18,23 @@ export function CommentPanel({
maxHeight, maxHeight,
variant = "desktop", variant = "desktop",
}: CommentPanelProps) { }: CommentPanelProps) {
const [{ data: currentUser }] = useAtom(currentUserAtom);
const handleCommentSubmit = (content: string) => { const handleCommentSubmit = (content: string) => {
onCreateComment(content); onCreateComment(content);
}; };
const isMobile = variant === "mobile"; const isMobile = variant === "mobile";
const isInline = variant === "inline";
if (isLoading) { if (isLoading) {
return ( return (
<div <div
className={cn( className={cn(
"flex min-h-[120px] items-center justify-center p-4", "flex min-h-[120px] items-center justify-center p-4",
!isMobile && "w-96 rounded-lg border bg-card" isInline &&
"w-full rounded-xl border-sidebar-border border bg-sidebar text-sidebar-foreground shadow-lg",
!isMobile &&
!isInline &&
"w-96 rounded-lg border-sidebar-border border bg-sidebar text-sidebar-foreground"
)} )}
> >
<div className="flex items-center gap-2 text-sm text-muted-foreground"> <div className="flex items-center gap-2 text-sm text-muted-foreground">
@ -65,8 +53,18 @@ export function CommentPanel({
return ( return (
<div <div
className={cn("flex flex-col", isMobile ? "w-full" : "w-85 rounded-lg border bg-card")} className={cn(
style={!isMobile && effectiveMaxHeight ? { maxHeight: effectiveMaxHeight } : undefined} "flex flex-col",
isMobile && "w-full",
isInline &&
"w-full rounded-xl border-sidebar-border border bg-sidebar text-sidebar-foreground shadow-lg max-h-80",
!isMobile &&
!isInline &&
"w-85 rounded-lg border-sidebar-border border bg-sidebar text-sidebar-foreground"
)}
style={
!isMobile && !isInline && effectiveMaxHeight ? { maxHeight: effectiveMaxHeight } : undefined
}
> >
{hasThreads && ( {hasThreads && (
<div className={cn("min-h-0 flex-1 overflow-y-auto scrollbar-thin", isMobile && "pb-24")}> <div className={cn("min-h-0 flex-1 overflow-y-auto scrollbar-thin", isMobile && "pb-24")}>
@ -87,25 +85,6 @@ export function CommentPanel({
</div> </div>
)} )}
{!hasThreads && currentUser && (
<div className="flex items-center gap-3 px-4 pt-4 pb-1">
<Avatar className="size-10">
<AvatarImage
src={currentUser.avatar_url ?? undefined}
alt={currentUser.display_name ?? currentUser.email}
/>
<AvatarFallback className="bg-primary/10 text-primary text-sm font-medium">
{getInitials(currentUser.display_name, currentUser.email)}
</AvatarFallback>
</Avatar>
<div className="flex flex-col">
<span className="text-sm font-medium">
{currentUser.display_name ?? currentUser.email}
</span>
</div>
</div>
)}
<div className={cn("p-3", isMobile && "fixed bottom-0 left-0 right-0 z-50 bg-card border-t")}> <div className={cn("p-3", isMobile && "fixed bottom-0 left-0 right-0 z-50 bg-card border-t")}>
<CommentComposer <CommentComposer
members={members} members={members}
@ -115,6 +94,7 @@ export function CommentPanel({
isSubmitting={isSubmitting} isSubmitting={isSubmitting}
onSubmit={handleCommentSubmit} onSubmit={handleCommentSubmit}
autoFocus={!hasThreads} autoFocus={!hasThreads}
compact
/> />
</div> </div>
</div> </div>

View file

@ -12,6 +12,6 @@ export interface CommentPanelProps {
onDeleteComment: (commentId: number) => void; onDeleteComment: (commentId: number) => void;
isSubmitting?: boolean; isSubmitting?: boolean;
maxHeight?: number; maxHeight?: number;
/** Variant for responsive styling - desktop shows border/bg, mobile is plain */ /** Variant for responsive styling - desktop shows border/bg, mobile is plain, inline fits within message width */
variant?: "desktop" | "mobile"; variant?: "desktop" | "mobile" | "inline";
} }

View file

@ -138,6 +138,7 @@ export function CommentThread({
onSubmit={handleReplySubmit} onSubmit={handleReplySubmit}
onCancel={handleReplyCancel} onCancel={handleReplyCancel}
autoFocus autoFocus
compact
/> />
</div> </div>
) : ( ) : (

View file

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

View file

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

View file

@ -29,7 +29,7 @@ export function MemberMentionItem({
type="button" type="button"
className={cn( className={cn(
"flex w-full items-center gap-3 px-3 py-2 text-left transition-colors", "flex w-full items-center gap-3 px-3 py-2 text-left transition-colors",
isHighlighted ? "bg-accent" : "hover:bg-accent/50" isHighlighted ? "bg-primary/15 text-accent-foreground" : "hover:bg-accent/50"
)} )}
onClick={() => onSelect(member)} onClick={() => onSelect(member)}
onMouseEnter={onMouseEnter} onMouseEnter={onMouseEnter}

View file

@ -1,8 +1,16 @@
"use client"; "use client";
import { IconBrandGithub } from "@tabler/icons-react"; import { IconBrandGithub } from "@tabler/icons-react";
import { StarIcon } from "lucide-react";
import type { HTMLMotionProps, UseInViewOptions } from "motion/react"; import type { HTMLMotionProps, UseInViewOptions } from "motion/react";
import { motion, useInView, useMotionValue, useSpring } from "motion/react"; import {
AnimatePresence,
motion,
useInView,
useMotionValue,
useSpring,
useTransform,
} from "motion/react";
import * as React from "react"; import * as React from "react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@ -45,6 +53,122 @@ function useIsInView<T extends HTMLElement = HTMLElement>(
return { ref: localRef, isInView }; return { ref: localRef, isInView };
} }
// ---------------------------------------------------------------------------
// Particles (for star burst effect on completion)
// ---------------------------------------------------------------------------
type ParticlesContextType = { animate: boolean; isInView: boolean };
const [ParticlesProvider, useParticles] =
getStrictContext<ParticlesContextType>("ParticlesContext");
function Particles({
ref,
animate = true,
inView = false,
inViewMargin = "0px",
inViewOnce = true,
children,
style,
...props
}: Omit<HTMLMotionProps<"div">, "children"> & {
animate?: boolean;
children: React.ReactNode;
} & UseIsInViewOptions) {
const { ref: localRef, isInView } = useIsInView(ref as React.Ref<HTMLDivElement>, {
inView,
inViewOnce,
inViewMargin,
});
return (
<ParticlesProvider value={{ animate, isInView }}>
<motion.div ref={localRef} style={{ position: "relative", ...style }} {...props}>
{children}
</motion.div>
</ParticlesProvider>
);
}
function ParticlesEffect({
side = "top",
align = "center",
count = 6,
radius = 30,
spread = 360,
duration = 0.8,
holdDelay = 0.05,
sideOffset = 0,
alignOffset = 0,
delay = 0,
transition,
style,
...props
}: Omit<HTMLMotionProps<"div">, "children"> & {
side?: "top" | "bottom" | "left" | "right";
align?: "start" | "center" | "end";
count?: number;
radius?: number;
spread?: number;
duration?: number;
holdDelay?: number;
sideOffset?: number;
alignOffset?: number;
delay?: number;
}) {
const { animate, isInView } = useParticles();
const isVertical = side === "top" || side === "bottom";
const alignPct = align === "start" ? "0%" : align === "end" ? "100%" : "50%";
const top = isVertical
? side === "top"
? `calc(0% - ${sideOffset}px)`
: `calc(100% + ${sideOffset}px)`
: `calc(${alignPct} + ${alignOffset}px)`;
const left = isVertical
? `calc(${alignPct} + ${alignOffset}px)`
: side === "left"
? `calc(0% - ${sideOffset}px)`
: `calc(100% + ${sideOffset}px)`;
const containerStyle: React.CSSProperties = {
position: "absolute",
top,
left,
transform: "translate(-50%, -50%)",
};
const angleStep = (spread * (Math.PI / 180)) / Math.max(1, count - 1);
return (
<AnimatePresence>
{animate &&
isInView &&
[...Array(count)].map((_, i) => {
const angle = i * angleStep;
const x = Math.cos(angle) * radius;
const y = Math.sin(angle) * radius;
return (
<motion.div
key={`particle-${angle}`}
style={{ ...containerStyle, ...style }}
initial={{ scale: 0, opacity: 0 }}
animate={{
x: `${x}px`,
y: `${y}px`,
scale: [0, 1, 0],
opacity: [0, 1, 0],
}}
transition={{
duration,
delay: delay + i * holdDelay,
ease: "easeOut",
...transition,
}}
{...props}
/>
);
})}
</AnimatePresence>
);
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Per-digit scrolling wheel // Per-digit scrolling wheel
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -193,42 +317,18 @@ function AnimatedStarCount({
value, value,
itemSize = 22, itemSize = 22,
isRolling = false, isRolling = false,
animated = true,
className, className,
onComplete, onComplete,
}: { }: {
value: number; value: number;
itemSize?: number; itemSize?: number;
isRolling?: boolean; isRolling?: boolean;
animated?: boolean;
className?: string; className?: string;
onComplete?: () => void; onComplete?: () => void;
}) { }) {
const formatted = numberFormatter.format(value); const formatted = numberFormatter.format(value);
const chars = formatted.split(""); const chars = formatted.split("");
if (!animated) {
return (
<div className="flex items-center">
{chars.map((char, idx) => (
<div
key={`static-${idx}-${char}`}
className={className}
style={{
height: itemSize,
display: "flex",
alignItems: "center",
justifyContent: "center",
width: char >= "0" && char <= "9" ? undefined : "0.3em",
}}
>
{char}
</div>
))}
</div>
);
}
let totalDigits = 0; let totalDigits = 0;
for (const c of chars) { for (const c of chars) {
if (c >= "0" && c <= "9") totalDigits++; if (c >= "0" && c <= "9") totalDigits++;
@ -307,13 +407,13 @@ function NavbarGitHubStars({
href = "https://github.com/MODSetter/SurfSense", href = "https://github.com/MODSetter/SurfSense",
className, className,
}: NavbarGitHubStarsProps) { }: NavbarGitHubStarsProps) {
const [hasMounted, setHasMounted] = React.useState(false);
const [stars, setStars] = React.useState(0); const [stars, setStars] = React.useState(0);
const [isLoading, setIsLoading] = React.useState(true); const [isLoading, setIsLoading] = React.useState(true);
const [isCompleted, setIsCompleted] = React.useState(false);
React.useEffect(() => { const fillRaw = useMotionValue(0);
setHasMounted(true); const fillSpring = useSpring(fillRaw, { stiffness: 12, damping: 14 });
}, []); const clipPath = useTransform(fillSpring, (v) => `inset(${100 - v * 100}% 0 0 0)`);
React.useEffect(() => { React.useEffect(() => {
const abortController = new AbortController(); const abortController = new AbortController();
@ -324,6 +424,7 @@ function NavbarGitHubStars({
.then((data) => { .then((data) => {
if (data && typeof data.stargazers_count === "number") { if (data && typeof data.stargazers_count === "number") {
setStars(data.stargazers_count); setStars(data.stargazers_count);
fillRaw.set(1);
} }
}) })
.catch((err) => { .catch((err) => {
@ -333,7 +434,7 @@ function NavbarGitHubStars({
}) })
.finally(() => setIsLoading(false)); .finally(() => setIsLoading(false));
return () => abortController.abort(); return () => abortController.abort();
}, [username, repo]); }, [username, repo, fillRaw]);
return ( return (
<a <a
@ -341,20 +442,37 @@ function NavbarGitHubStars({
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className={cn( className={cn(
"group inline-flex items-center rounded-full border border-neutral-200 bg-white/80 px-3 py-1.5 text-sm backdrop-blur-sm transition-colors dark:border-neutral-800 dark:bg-neutral-950/80", "group flex items-center gap-2 rounded-full px-3 py-1.5 transition-colors",
"hover:bg-neutral-100 dark:hover:bg-neutral-900",
className className
)} )}
> >
<IconBrandGithub className="h-5 w-5 shrink-0 text-neutral-600 transition-colors dark:text-neutral-300 group-hover:text-neutral-800 dark:group-hover:text-neutral-100" /> <IconBrandGithub className="h-5 w-5 text-neutral-600 dark:text-neutral-300 shrink-0" />
<div className="ml-2 flex items-center text-neutral-500 transition-colors dark:text-neutral-400 group-hover:text-neutral-800 dark:group-hover:text-neutral-200"> <div className="flex items-center gap-1 rounded-md bg-neutral-100 dark:bg-neutral-800 group-hover:bg-neutral-200 dark:group-hover:bg-neutral-700 px-2 py-0.5 transition-colors">
<AnimatedStarCount <AnimatedStarCount
value={isLoading ? 10000 : stars} value={isLoading ? 10000 : stars}
itemSize={ITEM_SIZE} itemSize={ITEM_SIZE}
isRolling={hasMounted && isLoading} isRolling={isLoading}
animated={hasMounted}
className="text-sm font-semibold tabular-nums text-neutral-500 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 dark:text-neutral-400 group-hover:text-neutral-800 dark:group-hover:text-neutral-200 transition-colors"
onComplete={() => setIsCompleted(true)}
/> />
<Particles animate={isCompleted}>
<div className="relative size-4">
<StarIcon
aria-hidden="true"
className="absolute inset-0 size-4 fill-neutral-400 stroke-neutral-400 dark:fill-neutral-700 dark:stroke-neutral-700 group-hover:fill-neutral-600 group-hover:stroke-neutral-600 dark:group-hover:fill-neutral-300 dark:group-hover:stroke-neutral-300 transition-colors"
/>
<motion.div className="absolute inset-0" style={{ clipPath }}>
<StarIcon
aria-hidden="true"
className="size-4 fill-neutral-300 stroke-neutral-300 dark:fill-neutral-400 dark:stroke-neutral-400 group-hover:fill-neutral-500 group-hover:stroke-neutral-500 dark:group-hover:fill-neutral-200 dark:group-hover:stroke-neutral-200 transition-colors"
/>
</motion.div>
</div>
<ParticlesEffect
delay={0.3}
className="size-1 rounded-full bg-neutral-300 dark:bg-neutral-400"
/>
</Particles>
</div> </div>
</a> </a>
); );

View file

@ -176,10 +176,9 @@ function GetStartedButton() {
); );
} }
const BackgroundGrids = () => { const BackgroundGrids = () => {
return ( return (
<div className="pointer-events-none absolute inset-0 z-0 grid h-full w-full -rotate-45 transform select-none grid-cols-2 gap-10 md:grid-cols-4"> <div className="pointer-events-none absolute inset-0 z-0 grid h-screen w-full -rotate-45 transform select-none grid-cols-2 gap-10 md:grid-cols-4">
<div className="relative h-full w-full"> <div className="relative h-full w-full">
<GridLineVertical className="left-0" /> <GridLineVertical className="left-0" />
<GridLineVertical className="left-auto right-0" /> <GridLineVertical className="left-auto right-0" />

View file

@ -11,6 +11,7 @@ import { toast } from "sonner";
import { currentThreadAtom, resetCurrentThreadAtom } from "@/atoms/chat/current-thread.atom"; import { currentThreadAtom, resetCurrentThreadAtom } from "@/atoms/chat/current-thread.atom";
import { documentsSidebarOpenAtom } from "@/atoms/documents/ui.atoms"; import { documentsSidebarOpenAtom } from "@/atoms/documents/ui.atoms";
import { statusInboxItemsAtom } from "@/atoms/inbox/status-inbox.atom"; import { statusInboxItemsAtom } from "@/atoms/inbox/status-inbox.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 { currentUserAtom } from "@/atoms/user/user-query.atoms"; import { currentUserAtom } from "@/atoms/user/user-query.atoms";
@ -38,6 +39,7 @@ 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";
import { useIsMobile } from "@/hooks/use-mobile";
import { notificationsApiService } from "@/lib/apis/notifications-api.service"; import { notificationsApiService } from "@/lib/apis/notifications-api.service";
import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service"; import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service";
import { logout } from "@/lib/auth-utils"; import { logout } from "@/lib/auth-utils";
@ -74,6 +76,7 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
const pathname = usePathname(); const pathname = usePathname();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { theme, setTheme } = useTheme(); const { theme, setTheme } = useTheme();
const isMobile = useIsMobile();
// Announcements // Announcements
const { unreadCount: announcementUnreadCount } = useAnnouncements(); const { unreadCount: announcementUnreadCount } = useAnnouncements();
@ -117,10 +120,23 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
// Inbox sidebar state // Inbox sidebar state
const [isInboxSidebarOpen, setIsInboxSidebarOpen] = useState(false); const [isInboxSidebarOpen, setIsInboxSidebarOpen] = useState(false);
const [isInboxDocked, setIsInboxDocked] = useState(false);
// Documents sidebar state (shared atom so Composer can toggle it) // Documents sidebar state (shared atom so Composer can toggle it)
const [isDocumentsSidebarOpen, setIsDocumentsSidebarOpen] = useAtom(documentsSidebarOpenAtom); const [isDocumentsSidebarOpen, setIsDocumentsSidebarOpen] = useAtom(documentsSidebarOpenAtom);
const [isDocumentsDocked, setIsDocumentsDocked] = useState(true);
const [isRightPanelCollapsed, setIsRightPanelCollapsed] = useAtom(rightPanelCollapsedAtom);
// Open documents sidebar by default on desktop (docked mode)
const documentsInitialized = useRef(false);
useEffect(() => {
if (!documentsInitialized.current) {
documentsInitialized.current = true;
const isDesktop = typeof window !== "undefined" && window.innerWidth >= 768;
if (isDesktop) {
setIsDocumentsSidebarOpen(true);
}
}
}, [setIsDocumentsSidebarOpen]);
// Announcements sidebar state // Announcements sidebar state
const [isAnnouncementsSidebarOpen, setIsAnnouncementsSidebarOpen] = useState(false); const [isAnnouncementsSidebarOpen, setIsAnnouncementsSidebarOpen] = useState(false);
@ -304,7 +320,9 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
title: "Documents", title: "Documents",
url: "#documents", url: "#documents",
icon: SquareLibrary, icon: SquareLibrary,
isActive: isDocumentsSidebarOpen, isActive: isMobile
? isDocumentsSidebarOpen
: isDocumentsSidebarOpen && !isRightPanelCollapsed,
statusIndicator: documentsProcessingStatus, statusIndicator: documentsProcessingStatus,
}, },
{ {
@ -316,8 +334,10 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
}, },
], ],
[ [
isMobile,
isInboxSidebarOpen, isInboxSidebarOpen,
isDocumentsSidebarOpen, isDocumentsSidebarOpen,
isRightPanelCollapsed,
totalUnreadCount, totalUnreadCount,
isAnnouncementsSidebarOpen, isAnnouncementsSidebarOpen,
announcementUnreadCount, announcementUnreadCount,
@ -419,7 +439,6 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
if (!prev) { if (!prev) {
setIsAllSharedChatsSidebarOpen(false); setIsAllSharedChatsSidebarOpen(false);
setIsAllPrivateChatsSidebarOpen(false); setIsAllPrivateChatsSidebarOpen(false);
setIsDocumentsSidebarOpen(false);
setIsAnnouncementsSidebarOpen(false); setIsAnnouncementsSidebarOpen(false);
} }
return !prev; return !prev;
@ -427,15 +446,28 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
return; return;
} }
if (item.url === "#documents") { if (item.url === "#documents") {
setIsDocumentsSidebarOpen((prev) => { if (!isMobile) {
if (!prev) { if (!isDocumentsSidebarOpen) {
setIsDocumentsSidebarOpen(true);
setIsRightPanelCollapsed(false);
setIsInboxSidebarOpen(false); setIsInboxSidebarOpen(false);
setIsAllSharedChatsSidebarOpen(false); setIsAllSharedChatsSidebarOpen(false);
setIsAllPrivateChatsSidebarOpen(false); setIsAllPrivateChatsSidebarOpen(false);
setIsAnnouncementsSidebarOpen(false); setIsAnnouncementsSidebarOpen(false);
} else {
setIsRightPanelCollapsed((prev) => !prev);
} }
return !prev; } else {
}); setIsDocumentsSidebarOpen((prev) => {
if (!prev) {
setIsInboxSidebarOpen(false);
setIsAllSharedChatsSidebarOpen(false);
setIsAllPrivateChatsSidebarOpen(false);
setIsAnnouncementsSidebarOpen(false);
}
return !prev;
});
}
return; return;
} }
if (item.url === "#announcements") { if (item.url === "#announcements") {
@ -444,7 +476,6 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
setIsInboxSidebarOpen(false); setIsInboxSidebarOpen(false);
setIsAllSharedChatsSidebarOpen(false); setIsAllSharedChatsSidebarOpen(false);
setIsAllPrivateChatsSidebarOpen(false); setIsAllPrivateChatsSidebarOpen(false);
setIsDocumentsSidebarOpen(false);
} }
return !prev; return !prev;
}); });
@ -452,7 +483,7 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
} }
router.push(item.url); router.push(item.url);
}, },
[router, setIsDocumentsSidebarOpen] [router, isMobile, isDocumentsSidebarOpen, setIsDocumentsSidebarOpen, setIsRightPanelCollapsed]
); );
const handleNewChat = useCallback(() => { const handleNewChat = useCallback(() => {
@ -549,17 +580,15 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
setIsAllSharedChatsSidebarOpen(true); setIsAllSharedChatsSidebarOpen(true);
setIsAllPrivateChatsSidebarOpen(false); setIsAllPrivateChatsSidebarOpen(false);
setIsInboxSidebarOpen(false); setIsInboxSidebarOpen(false);
setIsDocumentsSidebarOpen(false);
setIsAnnouncementsSidebarOpen(false); setIsAnnouncementsSidebarOpen(false);
}, [setIsDocumentsSidebarOpen]); }, []);
const handleViewAllPrivateChats = useCallback(() => { const handleViewAllPrivateChats = useCallback(() => {
setIsAllPrivateChatsSidebarOpen(true); setIsAllPrivateChatsSidebarOpen(true);
setIsAllSharedChatsSidebarOpen(false); setIsAllSharedChatsSidebarOpen(false);
setIsInboxSidebarOpen(false); setIsInboxSidebarOpen(false);
setIsDocumentsSidebarOpen(false);
setIsAnnouncementsSidebarOpen(false); setIsAnnouncementsSidebarOpen(false);
}, [setIsDocumentsSidebarOpen]); }, []);
// Delete handlers // Delete handlers
const confirmDeleteChat = useCallback(async () => { const confirmDeleteChat = useCallback(async () => {
@ -688,8 +717,6 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
markAsRead: statusInbox.markAsRead, markAsRead: statusInbox.markAsRead,
markAllAsRead: statusInbox.markAllAsRead, markAllAsRead: statusInbox.markAllAsRead,
}, },
isDocked: isInboxDocked,
onDockedChange: setIsInboxDocked,
}} }}
announcementsPanel={{ announcementsPanel={{
open: isAnnouncementsSidebarOpen, open: isAnnouncementsSidebarOpen,
@ -708,6 +735,8 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
documentsPanel={{ documentsPanel={{
open: isDocumentsSidebarOpen, open: isDocumentsSidebarOpen,
onOpenChange: setIsDocumentsSidebarOpen, onOpenChange: setIsDocumentsSidebarOpen,
isDocked: isDocumentsDocked,
onDockedChange: setIsDocumentsDocked,
}} }}
> >
<Fragment key={chatResetKey}>{children}</Fragment> <Fragment key={chatResetKey}>{children}</Fragment>

View file

@ -1,11 +1,18 @@
"use client"; "use client";
import { useAtomValue } from "jotai"; import { useAtom, useAtomValue } from "jotai";
import { PanelRight } from "lucide-react";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import { currentThreadAtom } from "@/atoms/chat/current-thread.atom"; import { currentThreadAtom } from "@/atoms/chat/current-thread.atom";
import { reportPanelAtom } from "@/atoms/chat/report-panel.atom";
import { documentsSidebarOpenAtom } from "@/atoms/documents/ui.atoms";
import { rightPanelCollapsedAtom } from "@/atoms/layout/right-panel.atom";
import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms"; import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms";
import { ChatHeader } from "@/components/new-chat/chat-header"; import { ChatHeader } from "@/components/new-chat/chat-header";
import { ChatShareButton } from "@/components/new-chat/chat-share-button"; import { ChatShareButton } from "@/components/new-chat/chat-share-button";
import { Button } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { useIsMobile } from "@/hooks/use-mobile";
import type { ChatVisibility, ThreadRecord } from "@/lib/chat/thread-persistence"; import type { ChatVisibility, ThreadRecord } from "@/lib/chat/thread-persistence";
interface HeaderProps { interface HeaderProps {
@ -15,6 +22,7 @@ interface HeaderProps {
export function Header({ mobileMenuTrigger }: HeaderProps) { export function Header({ mobileMenuTrigger }: HeaderProps) {
const pathname = usePathname(); const pathname = usePathname();
const searchSpaceId = useAtomValue(activeSearchSpaceIdAtom); const searchSpaceId = useAtomValue(activeSearchSpaceIdAtom);
const isMobile = useIsMobile();
const isChatPage = pathname?.includes("/new-chat") ?? false; const isChatPage = pathname?.includes("/new-chat") ?? false;
@ -38,6 +46,13 @@ export function Header({ mobileMenuTrigger }: HeaderProps) {
const handleVisibilityChange = (_visibility: ChatVisibility) => {}; const handleVisibilityChange = (_visibility: ChatVisibility) => {};
const [collapsed, setCollapsed] = useAtom(rightPanelCollapsedAtom);
const documentsOpen = useAtomValue(documentsSidebarOpenAtom);
const reportState = useAtomValue(reportPanelAtom);
const reportOpen = reportState.isOpen && !!reportState.reportId;
const hasRightPanelContent = documentsOpen || reportOpen;
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-background/95 backdrop-blur supports-backdrop-filter:bg-background/60 px-4">
{/* Left side - Mobile menu trigger + Model selector */} {/* Left side - Mobile menu trigger + Model selector */}
@ -49,10 +64,26 @@ export function Header({ mobileMenuTrigger }: HeaderProps) {
</div> </div>
{/* Right side - Actions */} {/* Right side - Actions */}
<div className="flex items-center gap-4"> <div className="flex items-center gap-2">
{hasThread && ( {hasThread && (
<ChatShareButton thread={threadForButton} onVisibilityChange={handleVisibilityChange} /> <ChatShareButton thread={threadForButton} onVisibilityChange={handleVisibilityChange} />
)} )}
{showExpandButton && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={() => setCollapsed(false)}
className="h-8 w-8 shrink-0"
>
<PanelRight className="h-4 w-4" />
<span className="sr-only">Expand panel</span>
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">Expand panel</TooltipContent>
</Tooltip>
)}
</div> </div>
</header> </header>
); );

View file

@ -0,0 +1,168 @@
"use client";
import { useAtom, useAtomValue, useSetAtom } from "jotai";
import { PanelRight, PanelRightClose } from "lucide-react";
import { AnimatePresence, motion } from "motion/react";
import { startTransition, useEffect } from "react";
import { closeReportPanelAtom, reportPanelAtom } from "@/atoms/chat/report-panel.atom";
import { documentsSidebarOpenAtom } from "@/atoms/documents/ui.atoms";
import { rightPanelCollapsedAtom, rightPanelTabAtom } from "@/atoms/layout/right-panel.atom";
import { ReportPanelContent } from "@/components/report-panel/report-panel";
import { Button } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { DocumentsSidebar } from "../sidebar";
interface RightPanelProps {
documentsPanel?: {
open: boolean;
onOpenChange: (open: boolean) => void;
};
}
function CollapseButton({ onClick }: { onClick: () => void }) {
return (
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon" onClick={onClick} className="h-8 w-8 shrink-0">
<PanelRightClose className="h-4 w-4" />
<span className="sr-only">Collapse panel</span>
</Button>
</TooltipTrigger>
<TooltipContent side="left">Collapse panel</TooltipContent>
</Tooltip>
);
}
/**
* Absolutely positioned expand button renders at top-right of the main
* container so it occupies the same screen position as the collapse button
* inside the Documents header.
*/
export function RightPanelExpandButton() {
const [collapsed, setCollapsed] = useAtom(rightPanelCollapsedAtom);
const documentsOpen = useAtomValue(documentsSidebarOpenAtom);
const reportState = useAtomValue(reportPanelAtom);
const reportOpen = reportState.isOpen && !!reportState.reportId;
const hasContent = documentsOpen || reportOpen;
if (!collapsed || !hasContent) return null;
return (
<div className="absolute top-4 right-4 z-20">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={() => startTransition(() => setCollapsed(false))}
className="h-8 w-8 shrink-0"
>
<PanelRight className="h-4 w-4" />
<span className="sr-only">Expand panel</span>
</Button>
</TooltipTrigger>
<TooltipContent side="left">Expand panel</TooltipContent>
</Tooltip>
</div>
);
}
const PANEL_WIDTHS = { sources: 420, report: 640 } as const;
export function RightPanel({ documentsPanel }: RightPanelProps) {
const [activeTab] = useAtom(rightPanelTabAtom);
const reportState = useAtomValue(reportPanelAtom);
const closeReport = useSetAtom(closeReportPanelAtom);
const [collapsed, setCollapsed] = useAtom(rightPanelCollapsedAtom);
const documentsOpen = documentsPanel?.open ?? false;
const reportOpen = reportState.isOpen && !!reportState.reportId;
useEffect(() => {
if (!reportOpen) return;
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") closeReport();
};
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [reportOpen, closeReport]);
const isVisible = (documentsOpen || reportOpen) && !collapsed;
const effectiveTab =
activeTab === "report" && !reportOpen
? "sources"
: activeTab === "sources" && !documentsOpen
? "report"
: activeTab;
const targetWidth = PANEL_WIDTHS[effectiveTab];
const collapseButton = <CollapseButton onClick={() => setCollapsed(true)} />;
const contentKey =
effectiveTab === "sources" && documentsOpen
? "sources"
: effectiveTab === "report" && reportOpen
? "report"
: null;
return (
<AnimatePresence>
{isVisible && (
<motion.aside
key="right-panel"
initial={{ width: 0, opacity: 0 }}
animate={{ width: targetWidth, opacity: 1 }}
exit={{ width: 0, opacity: 0 }}
transition={{
width: { type: "spring", stiffness: 400, damping: 35, mass: 0.8 },
opacity: { duration: 0.2, ease: "easeOut" },
}}
style={{ willChange: "width, opacity", contain: "layout style" }}
className="flex h-full shrink-0 flex-col border-l bg-background overflow-hidden"
>
<div className="relative flex-1 min-h-0 overflow-hidden">
<AnimatePresence mode="popLayout" initial={false}>
{contentKey === "sources" && documentsPanel && (
<motion.div
key="sources"
initial={{ opacity: 0, x: 8 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -8 }}
transition={{ duration: 0.15, ease: "easeOut" }}
className="h-full"
>
<DocumentsSidebar
open={documentsPanel.open}
onOpenChange={documentsPanel.onOpenChange}
embedded
headerAction={collapseButton}
/>
</motion.div>
)}
{contentKey === "report" && (
<motion.div
key="report"
initial={{ opacity: 0, x: 8 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -8 }}
transition={{ duration: 0.15, ease: "easeOut" }}
className="h-full"
>
<div className="flex h-full flex-col">
<ReportPanelContent
reportId={reportState.reportId!}
title={reportState.title || "Report"}
onClose={closeReport}
shareToken={reportState.shareToken}
/>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
</motion.aside>
)}
</AnimatePresence>
);
}

View file

@ -1,5 +1,6 @@
"use client"; "use client";
import { motion } from "motion/react";
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { TooltipProvider } from "@/components/ui/tooltip"; import { TooltipProvider } from "@/components/ui/tooltip";
import type { InboxItem } from "@/hooks/use-inbox"; import type { InboxItem } from "@/hooks/use-inbox";
@ -10,6 +11,7 @@ import { useSidebarResize } from "../../hooks/useSidebarResize";
import type { ChatItem, NavItem, PageUsage, SearchSpace, User } from "../../types/layout.types"; import type { ChatItem, NavItem, PageUsage, SearchSpace, User } from "../../types/layout.types";
import { Header } from "../header"; import { Header } from "../header";
import { IconRail } from "../icon-rail"; import { IconRail } from "../icon-rail";
import { RightPanel } from "../right-panel/RightPanel";
import { import {
AllPrivateChatsSidebar, AllPrivateChatsSidebar,
AllSharedChatsSidebar, AllSharedChatsSidebar,
@ -40,8 +42,6 @@ interface InboxProps {
totalUnreadCount: number; totalUnreadCount: number;
comments: TabDataSource; comments: TabDataSource;
status: TabDataSource; status: TabDataSource;
isDocked?: boolean;
onDockedChange?: (docked: boolean) => void;
} }
interface LayoutShellProps { interface LayoutShellProps {
@ -97,6 +97,8 @@ interface LayoutShellProps {
documentsPanel?: { documentsPanel?: {
open: boolean; open: boolean;
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
isDocked?: boolean;
onDockedChange?: (docked: boolean) => void;
}; };
} }
@ -306,45 +308,36 @@ export function LayoutShell({
isResizing={isResizing} isResizing={isResizing}
/> />
{/* Docked Inbox Sidebar - renders as flex sibling between sidebar and content */} <motion.main
{inbox?.isDocked && ( layout="position"
<InboxSidebar style={{ contain: "inline-size" }}
open={inbox.isOpen} className="flex-1 flex flex-col min-w-0"
onOpenChange={inbox.onOpenChange} >
comments={inbox.comments}
status={inbox.status}
totalUnreadCount={inbox.totalUnreadCount}
isDocked={inbox.isDocked}
onDockedChange={inbox.onDockedChange}
/>
)}
<main className="flex-1 flex flex-col min-w-0">
<Header /> <Header />
<div className={cn("flex-1", isChatPage ? "overflow-hidden" : "overflow-auto")}> <div className={cn("flex-1", isChatPage ? "overflow-hidden" : "overflow-auto")}>
{children} {children}
</div> </div>
</main> </motion.main>
{/* Floating Inbox Sidebar - positioned absolutely on top of content */} {/* Right panel — tabbed Sources/Report (desktop only) */}
{inbox && !inbox.isDocked && ( {documentsPanel && (
<RightPanel
documentsPanel={{
open: documentsPanel.open,
onOpenChange: documentsPanel.onOpenChange,
}}
/>
)}
{/* Inbox Sidebar - slide-out panel */}
{inbox && (
<InboxSidebar <InboxSidebar
open={inbox.isOpen} open={inbox.isOpen}
onOpenChange={inbox.onOpenChange} onOpenChange={inbox.onOpenChange}
comments={inbox.comments} comments={inbox.comments}
status={inbox.status} status={inbox.status}
totalUnreadCount={inbox.totalUnreadCount} totalUnreadCount={inbox.totalUnreadCount}
isDocked={false}
onDockedChange={inbox.onDockedChange}
/>
)}
{/* Documents Sidebar - slide-out panel */}
{documentsPanel && (
<DocumentsSidebar
open={documentsPanel.open}
onOpenChange={documentsPanel.onOpenChange}
/> />
)} )}

View file

@ -1,13 +1,6 @@
"use client"; "use client";
import { import { ArchiveIcon, MoreHorizontal, PenLine, RotateCcwIcon, Trash2 } from "lucide-react";
ArchiveIcon,
MessageSquare,
MoreHorizontal,
PenLine,
RotateCcwIcon,
Trash2,
} from "lucide-react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useCallback, useState } from "react"; import { useCallback, useState } from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@ -64,21 +57,26 @@ export function ChatListItem({
{...(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 transition-colors",
"[&>span:last-child]:truncate", "group-hover/item:bg-accent group-hover/item:text-accent-foreground",
"hover:bg-accent hover: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"
)} )}
> >
<MessageSquare className="h-4 w-4 shrink-0 text-muted-foreground" /> <span className="truncate">{animatedName}</span>
<span className="w-[calc(100%-3rem)] ">{animatedName}</span>
</button> </button>
{/* Actions dropdown - trigger hidden on mobile, long-press opens it instead */} {/* Actions dropdown - trigger hidden on mobile, long-press opens it instead */}
<div <div
className={cn( className={cn(
"absolute right-1 top-1/2 -translate-y-1/2 transition-opacity", "absolute right-0 top-0 bottom-0 flex items-center pr-1 pl-6 rounded-r-md",
isMobile ? "opacity-0 pointer-events-none" : "opacity-0 group-hover/item:opacity-100" isActive
? "bg-gradient-to-l from-accent from-60% to-transparent"
: "bg-gradient-to-l from-sidebar from-60% to-transparent group-hover/item:from-accent",
isMobile
? "opacity-0 pointer-events-none"
: isActive
? "opacity-100"
: "opacity-0 group-hover/item:opacity-100"
)} )}
> >
<DropdownMenu open={dropdownOpen} onOpenChange={setDropdownOpen}> <DropdownMenu open={dropdownOpen} onOpenChange={setDropdownOpen}>

View file

@ -1,7 +1,7 @@
"use client"; "use client";
import { useAtom, useAtomValue } from "jotai"; import { useAtom, useAtomValue, useSetAtom } from "jotai";
import { ChevronLeft } from "lucide-react"; import { ChevronLeft, ChevronRight, Unplug } from "lucide-react";
import { useParams } from "next/navigation"; import { useParams } from "next/navigation";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
@ -12,8 +12,12 @@ import {
type SortKey, type SortKey,
} from "@/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentsTableShell"; } from "@/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentsTableShell";
import { sidebarSelectedDocumentsAtom } from "@/atoms/chat/mentioned-documents.atom"; import { sidebarSelectedDocumentsAtom } from "@/atoms/chat/mentioned-documents.atom";
import { connectorDialogOpenAtom } from "@/atoms/connector-dialog/connector-dialog.atoms";
import { deleteDocumentMutationAtom } from "@/atoms/documents/document-mutation.atoms"; import { deleteDocumentMutationAtom } from "@/atoms/documents/document-mutation.atoms";
import { Avatar, AvatarFallback, AvatarGroup } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
import type { DocumentTypeEnum } from "@/contracts/types/document.types"; import type { DocumentTypeEnum } from "@/contracts/types/document.types";
import { useDebouncedValue } from "@/hooks/use-debounced-value"; import { useDebouncedValue } from "@/hooks/use-debounced-value";
import { useDocumentSearch } from "@/hooks/use-document-search"; import { useDocumentSearch } from "@/hooks/use-document-search";
@ -21,17 +25,43 @@ import { useDocuments } from "@/hooks/use-documents";
import { useMediaQuery } from "@/hooks/use-media-query"; import { useMediaQuery } from "@/hooks/use-media-query";
import { SidebarSlideOutPanel } from "./SidebarSlideOutPanel"; import { SidebarSlideOutPanel } from "./SidebarSlideOutPanel";
const SHOWCASE_CONNECTORS = [
{ type: "GOOGLE_DRIVE_CONNECTOR", label: "Google Drive" },
{ type: "GOOGLE_GMAIL_CONNECTOR", label: "Gmail" },
{ type: "NOTION_CONNECTOR", label: "Notion" },
{ type: "YOUTUBE_CONNECTOR", label: "YouTube" },
{ type: "GOOGLE_CALENDAR_CONNECTOR", label: "Google Calendar" },
{ type: "SLACK_CONNECTOR", label: "Slack" },
{ type: "LINEAR_CONNECTOR", label: "Linear" },
{ type: "JIRA_CONNECTOR", label: "Jira" },
{ type: "GITHUB_CONNECTOR", label: "GitHub" },
] as const;
interface DocumentsSidebarProps { interface DocumentsSidebarProps {
open: boolean; open: boolean;
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
isDocked?: boolean;
onDockedChange?: (docked: boolean) => void;
/** When true, renders content without any wrapper — parent provides the container */
embedded?: boolean;
/** Optional action element rendered in the header row (e.g. collapse button) */
headerAction?: React.ReactNode;
} }
export function DocumentsSidebar({ open, onOpenChange }: DocumentsSidebarProps) { export function DocumentsSidebar({
open,
onOpenChange,
isDocked = false,
onDockedChange,
embedded = false,
headerAction,
}: DocumentsSidebarProps) {
const t = useTranslations("documents"); const t = useTranslations("documents");
const tSidebar = useTranslations("sidebar"); const tSidebar = useTranslations("sidebar");
const params = useParams(); const params = useParams();
const isMobile = !useMediaQuery("(min-width: 640px)"); const isMobile = !useMediaQuery("(min-width: 640px)");
const searchSpaceId = Number(params.search_space_id); const searchSpaceId = Number(params.search_space_id);
const setConnectorDialogOpen = useSetAtom(connectorDialogOpenAtom);
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const debouncedSearch = useDebouncedValue(search, 250); const debouncedSearch = useDebouncedValue(search, 250);
@ -148,8 +178,8 @@ export function DocumentsSidebar({ open, onOpenChange }: DocumentsSidebarProps)
const documentsContent = ( const documentsContent = (
<> <>
<div className="shrink-0 p-4 pb-10"> <div className="shrink-0 flex h-14 items-center px-4">
<div className="flex items-center justify-between"> <div className="flex w-full items-center justify-between">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{isMobile && ( {isMobile && (
<Button <Button
@ -162,11 +192,71 @@ export function DocumentsSidebar({ open, onOpenChange }: DocumentsSidebarProps)
<span className="sr-only">{tSidebar("close") || "Close"}</span> <span className="sr-only">{tSidebar("close") || "Close"}</span>
</Button> </Button>
)} )}
<h2 className="text-lg font-semibold">{t("title") || "Documents"}</h2> <h2 className="select-none text-lg font-semibold">{t("title") || "Documents"}</h2>
</div>
<div className="flex items-center gap-1">
{!isMobile && onDockedChange && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 rounded-full"
onClick={() => {
if (isDocked) {
onDockedChange(false);
onOpenChange(false);
} else {
onDockedChange(true);
}
}}
>
{isDocked ? (
<ChevronLeft className="h-4 w-4 text-muted-foreground" />
) : (
<ChevronRight className="h-4 w-4 text-muted-foreground" />
)}
<span className="sr-only">{isDocked ? "Collapse panel" : "Expand panel"}</span>
</Button>
</TooltipTrigger>
<TooltipContent className="z-80">
{isDocked ? "Collapse panel" : "Expand panel"}
</TooltipContent>
</Tooltip>
)}
{headerAction}
</div> </div>
</div> </div>
</div> </div>
{/* Connected tools strip */}
<div className="shrink-0 mx-4 mt-2 mb-3 flex select-none items-center gap-2 rounded-lg border bg-muted/50 px-3 py-2">
<button
type="button"
onClick={() => setConnectorDialogOpen(true)}
className="flex items-center gap-2 min-w-0 flex-1 text-left"
>
<Unplug className="size-4 shrink-0 text-muted-foreground" />
<span className="truncate text-xs text-muted-foreground">Connect your tools</span>
<AvatarGroup className="ml-auto shrink-0">
{SHOWCASE_CONNECTORS.map(({ type, label }, i) => (
<Tooltip key={type}>
<TooltipTrigger asChild>
<Avatar className="size-6" style={{ zIndex: SHOWCASE_CONNECTORS.length - i }}>
<AvatarFallback className="bg-muted text-[10px]">
{getConnectorIcon(type, "size-3.5")}
</AvatarFallback>
</Avatar>
</TooltipTrigger>
<TooltipContent side="top" className="text-xs">
{label}
</TooltipContent>
</Tooltip>
))}
</AvatarGroup>
</button>
</div>
<div className="flex-1 min-h-0 overflow-x-hidden pt-0 flex flex-col"> <div className="flex-1 min-h-0 overflow-x-hidden pt-0 flex flex-col">
<div className="px-4 pb-2"> <div className="px-4 pb-2">
<DocumentsFilters <DocumentsFilters
@ -199,12 +289,31 @@ export function DocumentsSidebar({ open, onOpenChange }: DocumentsSidebarProps)
</> </>
); );
if (embedded) {
return (
<div className="flex h-full flex-col bg-sidebar text-sidebar-foreground">
{documentsContent}
</div>
);
}
if (isDocked && open && !isMobile) {
return (
<aside
className="h-full w-[380px] shrink-0 bg-sidebar text-sidebar-foreground flex flex-col border-r"
aria-label={t("title") || "Documents"}
>
{documentsContent}
</aside>
);
}
return ( return (
<SidebarSlideOutPanel <SidebarSlideOutPanel
open={open} open={open}
onOpenChange={onOpenChange} onOpenChange={onOpenChange}
ariaLabel={t("title") || "Documents"} ariaLabel={t("title") || "Documents"}
width={isMobile ? undefined : 480} width={isMobile ? undefined : 380}
> >
{documentsContent} {documentsContent}
</SidebarSlideOutPanel> </SidebarSlideOutPanel>

View file

@ -10,7 +10,6 @@ import {
CheckCheck, CheckCheck,
CheckCircle2, CheckCircle2,
ChevronLeft, ChevronLeft,
ChevronRight,
History, History,
Inbox, Inbox,
LayoutGrid, LayoutGrid,
@ -23,7 +22,7 @@ import { useParams, useRouter } from "next/navigation";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { getDocumentTypeLabel } from "@/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentTypeIcon"; import { getDocumentTypeLabel } from "@/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentTypeIcon";
import { setCommentsCollapsedAtom, setTargetCommentIdAtom } from "@/atoms/chat/current-thread.atom"; import { setTargetCommentIdAtom } from "@/atoms/chat/current-thread.atom";
import { convertRenderedToDisplay } from "@/components/chat-comments/comment-item/comment-item"; import { convertRenderedToDisplay } from "@/components/chat-comments/comment-item/comment-item";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@ -148,8 +147,6 @@ interface InboxSidebarProps {
status: TabDataSource; status: TabDataSource;
totalUnreadCount: number; totalUnreadCount: number;
onCloseMobileSidebar?: () => void; onCloseMobileSidebar?: () => void;
isDocked?: boolean;
onDockedChange?: (docked: boolean) => void;
} }
export function InboxSidebar({ export function InboxSidebar({
@ -159,8 +156,6 @@ export function InboxSidebar({
status, status,
totalUnreadCount, totalUnreadCount,
onCloseMobileSidebar, onCloseMobileSidebar,
isDocked = false,
onDockedChange,
}: InboxSidebarProps) { }: InboxSidebarProps) {
const t = useTranslations("sidebar"); const t = useTranslations("sidebar");
const router = useRouter(); const router = useRouter();
@ -168,7 +163,6 @@ export function InboxSidebar({
const isMobile = !useMediaQuery("(min-width: 640px)"); const isMobile = !useMediaQuery("(min-width: 640px)");
const searchSpaceId = params?.search_space_id ? Number(params.search_space_id) : null; const searchSpaceId = params?.search_space_id ? Number(params.search_space_id) : null;
const [, setCommentsCollapsed] = useAtom(setCommentsCollapsedAtom);
const [, setTargetCommentId] = useAtom(setTargetCommentIdAtom); const [, setTargetCommentId] = useAtom(setTargetCommentIdAtom);
const [searchQuery, setSearchQuery] = useState(""); const [searchQuery, setSearchQuery] = useState("");
@ -822,37 +816,6 @@ export function InboxSidebar({
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
)} )}
{!isMobile && onDockedChange && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 rounded-full"
onClick={() => {
if (isDocked) {
setCommentsCollapsed(false);
onDockedChange(false);
onOpenChange(false);
} else {
setCommentsCollapsed(true);
onDockedChange(true);
}
}}
>
{isDocked ? (
<ChevronLeft className="h-4 w-4 text-muted-foreground" />
) : (
<ChevronRight className="h-4 w-4 text-muted-foreground" />
)}
<span className="sr-only">{isDocked ? "Collapse panel" : "Expand panel"}</span>
</Button>
</TooltipTrigger>
<TooltipContent className="z-80">
{isDocked ? "Collapse panel" : "Expand panel"}
</TooltipContent>
</Tooltip>
)}
</div> </div>
</div> </div>
@ -1095,17 +1058,6 @@ export function InboxSidebar({
</> </>
); );
if (isDocked && open && !isMobile) {
return (
<aside
className="h-full w-[360px] shrink-0 bg-sidebar text-sidebar-foreground flex flex-col border-r"
aria-label={t("inbox") || "Inbox"}
>
{inboxContent}
</aside>
);
}
return ( return (
<SidebarSlideOutPanel open={open} onOpenChange={onOpenChange} ariaLabel={t("inbox") || "Inbox"}> <SidebarSlideOutPanel open={open} onOpenChange={onOpenChange} ariaLabel={t("inbox") || "Inbox"}>
{inboxContent} {inboxContent}

View file

@ -1,10 +1,13 @@
"use client"; "use client";
import { AnimatePresence, motion } from "motion/react"; import { AnimatePresence, motion } from "motion/react";
import { useEffect } from "react";
import { useMediaQuery } from "@/hooks/use-media-query"; import { useMediaQuery } from "@/hooks/use-media-query";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useSidebarContextSafe } from "../../hooks"; import { useSidebarContextSafe } from "../../hooks";
export const SLIDEOUT_PANEL_OPENED_EVENT = "slideout-panel-opened";
const SIDEBAR_COLLAPSED_WIDTH = 60; const SIDEBAR_COLLAPSED_WIDTH = 60;
interface SidebarSlideOutPanelProps { interface SidebarSlideOutPanelProps {
@ -36,20 +39,29 @@ export function SidebarSlideOutPanel({
? SIDEBAR_COLLAPSED_WIDTH ? SIDEBAR_COLLAPSED_WIDTH
: (sidebarContext?.sidebarWidth ?? 240); : (sidebarContext?.sidebarWidth ?? 240);
useEffect(() => {
if (open) {
window.dispatchEvent(new Event(SLIDEOUT_PANEL_OPENED_EVENT));
}
}, [open]);
return ( return (
<AnimatePresence> <AnimatePresence>
{open && ( {open && (
<> <>
{/* Click-away layer - covers the full container including the sidebar */} {/* Backdrop overlay with blur — desktop only, covers main content area (right of sidebar) */}
<motion.div {!isMobile && (
initial={{ opacity: 0 }} <motion.div
animate={{ opacity: 1 }} initial={{ opacity: 0 }}
exit={{ opacity: 0 }} animate={{ opacity: 1 }}
transition={{ duration: 0.15 }} exit={{ opacity: 0 }}
className="absolute inset-0 z-[5]" transition={{ duration: 0.15 }}
onClick={() => onOpenChange(false)} style={{ left: sidebarWidth }}
aria-hidden="true" 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 */} {/* Clip container - positioned at sidebar edge with overflow hidden */}
<div <div
@ -57,7 +69,7 @@ export function SidebarSlideOutPanel({
left: isMobile ? 0 : sidebarWidth, left: isMobile ? 0 : sidebarWidth,
width: isMobile ? "100%" : width, width: isMobile ? "100%" : width,
}} }}
className={cn("absolute z-10 overflow-hidden pointer-events-none", "inset-y-0")} className={cn("absolute z-30 overflow-hidden pointer-events-none", "inset-y-0")}
> >
<motion.div <motion.div
initial={{ x: "-100%" }} initial={{ x: "-100%" }}

View file

@ -95,9 +95,9 @@ function ReportPanelSkeleton() {
} }
/** /**
* Inner content component used by both desktop panel and mobile drawer * Inner content component used by desktop panel, mobile drawer, and the layout right panel
*/ */
function ReportPanelContent({ export function ReportPanelContent({
reportId, reportId,
title, title,
onClose, onClose,
@ -294,32 +294,11 @@ function ReportPanelContent({
} }
}, [activeReportId, currentMarkdown]); }, [activeReportId, currentMarkdown]);
// Show full-page skeleton only on initial load (no data loaded yet).
// Once we have versions/content from a prior fetch, keep the action bar visible.
const hasLoadedBefore = versions.length > 0 || reportContent !== null;
if (isLoading && !hasLoadedBefore) {
return (
<>
{/* Minimal top bar with close button even during initial load */}
<div className="flex items-center justify-end px-4 py-2 shrink-0">
{onClose && (
<Button variant="ghost" size="icon" onClick={onClose} className="size-7 shrink-0">
<XIcon className="size-4" />
<span className="sr-only">Close report panel</span>
</Button>
)}
</div>
<ReportPanelSkeleton />
</>
);
}
const activeVersionIndex = versions.findIndex((v) => v.id === activeReportId); const activeVersionIndex = versions.findIndex((v) => v.id === activeReportId);
return ( return (
<> <>
{/* Action bar — always visible after initial load */} {/* Action bar — always visible; buttons are disabled while loading */}
<div className="flex items-center justify-between px-4 py-2 shrink-0"> <div className="flex items-center justify-between px-4 py-2 shrink-0">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{/* Copy button */} {/* Copy button */}
@ -352,27 +331,51 @@ function ReportPanelContent({
> >
{!shareToken && ( {!shareToken && (
<> <>
<DropdownMenuLabel className="text-xs text-muted-foreground">Documents</DropdownMenuLabel> <DropdownMenuLabel className="text-xs text-muted-foreground">
<DropdownMenuItem onClick={() => handleExport("pdf")} disabled={exporting !== null}> Documents
</DropdownMenuLabel>
<DropdownMenuItem
onClick={() => handleExport("pdf")}
disabled={exporting !== null}
>
PDF (.pdf) PDF (.pdf)
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem onClick={() => handleExport("docx")} disabled={exporting !== null}> <DropdownMenuItem
onClick={() => handleExport("docx")}
disabled={exporting !== null}
>
Word (.docx) Word (.docx)
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem onClick={() => handleExport("odt")} disabled={exporting !== null}> <DropdownMenuItem
onClick={() => handleExport("odt")}
disabled={exporting !== null}
>
OpenDocument (.odt) OpenDocument (.odt)
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuLabel className="text-xs text-muted-foreground">Web &amp; E-Book</DropdownMenuLabel> <DropdownMenuLabel className="text-xs text-muted-foreground">
<DropdownMenuItem onClick={() => handleExport("html")} disabled={exporting !== null}> Web &amp; E-Book
</DropdownMenuLabel>
<DropdownMenuItem
onClick={() => handleExport("html")}
disabled={exporting !== null}
>
HTML (.html) HTML (.html)
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem onClick={() => handleExport("epub")} disabled={exporting !== null}> <DropdownMenuItem
onClick={() => handleExport("epub")}
disabled={exporting !== null}
>
EPUB (.epub) EPUB (.epub)
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuLabel className="text-xs text-muted-foreground">Source &amp; Plain</DropdownMenuLabel> <DropdownMenuLabel className="text-xs text-muted-foreground">
<DropdownMenuItem onClick={() => handleExport("latex")} disabled={exporting !== null}> Source &amp; Plain
</DropdownMenuLabel>
<DropdownMenuItem
onClick={() => handleExport("latex")}
disabled={exporting !== null}
>
LaTeX (.tex) LaTeX (.tex)
</DropdownMenuItem> </DropdownMenuItem>
</> </>
@ -381,7 +384,10 @@ function ReportPanelContent({
Markdown (.md) Markdown (.md)
</DropdownMenuItem> </DropdownMenuItem>
{!shareToken && ( {!shareToken && (
<DropdownMenuItem onClick={() => handleExport("plain")} disabled={exporting !== null}> <DropdownMenuItem
onClick={() => handleExport("plain")}
disabled={exporting !== null}
>
Plain Text (.txt) Plain Text (.txt)
</DropdownMenuItem> </DropdownMenuItem>
)} )}
@ -538,9 +544,6 @@ function MobileReportDrawer() {
* *
* On desktop (lg+): Renders as a right-side split panel (flex sibling to the chat thread) * On desktop (lg+): Renders as a right-side split panel (flex sibling to the chat thread)
* On mobile/tablet: Renders as a Vaul bottom drawer * On mobile/tablet: Renders as a Vaul bottom drawer
*
* When open on desktop, the comments gutter is automatically suppressed
* (handled via showCommentsGutterAtom in current-thread.atom.ts)
*/ */
export function ReportPanel() { export function ReportPanel() {
const panelState = useAtomValue(reportPanelAtom); const panelState = useAtomValue(reportPanelAtom);
@ -555,3 +558,18 @@ export function ReportPanel() {
return <MobileReportDrawer />; return <MobileReportDrawer />;
} }
/**
* MobileReportPanel mobile-only report drawer
*
* Used in the dashboard chat page where the desktop report is handled
* by the layout-level RightPanel instead.
*/
export function MobileReportPanel() {
const panelState = useAtomValue(reportPanelAtom);
const isDesktop = useMediaQuery("(min-width: 1024px)");
if (isDesktop || !panelState.isOpen || !panelState.reportId) return null;
return <MobileReportDrawer />;
}

View file

@ -348,7 +348,7 @@ export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) {
{/* Global info */} {/* Global info */}
{globalConfigs.filter((g) => !("is_auto_mode" in g && g.is_auto_mode)).length > 0 && ( {globalConfigs.filter((g) => !("is_auto_mode" in g && g.is_auto_mode)).length > 0 && (
<Alert className="flex flex-row items-center gap-2 bg-muted/50 py-3 [&>svg]:static [&>svg+div]:translate-y-0 [&>svg~*]:pl-0"> <Alert className="bg-muted/50 py-3">
<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">
<span className="font-medium"> <span className="font-medium">

View file

@ -700,7 +700,12 @@ function PermissionsEditor({
tabIndex={0} 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); } }} 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" />
@ -763,7 +768,12 @@ function PermissionsEditor({
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); } }} 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>

View file

@ -254,7 +254,7 @@ export function DocumentUploadTab({
return ( return (
<div className="space-y-3 sm:space-y-6 max-w-4xl mx-auto pt-0"> <div className="space-y-3 sm:space-y-6 max-w-4xl mx-auto pt-0">
<Alert className="border border-border bg-slate-400/5 dark:bg-white/5 flex items-start gap-3 [&>svg]:relative [&>svg]:left-0 [&>svg]:top-0 [&>svg~*]:pl-0"> <Alert className="border border-border bg-slate-400/5 dark:bg-white/5">
<Info className="h-4 w-4 shrink-0 mt-0.5" /> <Info className="h-4 w-4 shrink-0 mt-0.5" />
<AlertDescription className="text-xs sm:text-sm leading-relaxed pt-0.5"> <AlertDescription className="text-xs sm:text-sm leading-relaxed pt-0.5">
{t("file_size_limit")}{" "} {t("file_size_limit")}{" "}

View file

@ -1,15 +1,16 @@
import { cva, type VariantProps } from "class-variance-authority"; import { cva, type VariantProps } from "class-variance-authority";
import * as React from "react"; import type * as React from "react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
const alertVariants = cva( const alertVariants = cva(
"relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground", "relative grid w-full grid-cols-[0_1fr] items-start gap-y-0.5 rounded-lg border px-4 py-3 text-sm has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] has-[>svg]:gap-x-3 [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
{ {
variants: { variants: {
variant: { variant: {
default: "bg-background text-foreground", default: "bg-card text-card-foreground",
destructive: destructive:
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive", "bg-card text-destructive *:data-[slot=alert-description]:text-destructive/90 [&>svg]:text-current",
}, },
}, },
defaultVariants: { defaultVariants: {
@ -18,31 +19,42 @@ const alertVariants = cva(
} }
); );
const Alert = React.forwardRef< function Alert({
HTMLDivElement, className,
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants> variant,
>(({ className, variant, ...props }, ref) => ( ...props
<div ref={ref} role="alert" className={cn(alertVariants({ variant }), className)} {...props} /> }: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
)); return (
Alert.displayName = "Alert"; <div
data-slot="alert"
const AlertTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>( role="alert"
({ className, ...props }, ref) => ( className={cn(alertVariants({ variant }), className)}
<h5
ref={ref}
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
{...props} {...props}
/> />
) );
); }
AlertTitle.displayName = "AlertTitle";
const AlertDescription = React.forwardRef< function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
HTMLParagraphElement, return (
React.HTMLAttributes<HTMLParagraphElement> <div
>(({ className, ...props }, ref) => ( data-slot="alert-title"
<div ref={ref} className={cn("text-sm [&_p]:leading-relaxed", className)} {...props} /> className={cn("col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight", className)}
)); {...props}
AlertDescription.displayName = "AlertDescription"; />
);
}
function AlertDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-description"
className={cn(
"col-start-2 grid justify-items-start gap-1 text-sm text-muted-foreground [&_p]:leading-relaxed",
className
)}
{...props}
/>
);
}
export { Alert, AlertTitle, AlertDescription }; export { Alert, AlertTitle, AlertDescription };

View file

@ -38,4 +38,21 @@ function AvatarFallback({
); );
} }
export { Avatar, AvatarImage, AvatarFallback }; function AvatarGroup({ className, ...props }: React.ComponentProps<"div">) {
return <div data-slot="avatar-group" className={cn("flex -space-x-2", className)} {...props} />;
}
function AvatarGroupCount({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="avatar-group-count"
className={cn(
"relative flex size-8 shrink-0 items-center justify-center rounded-full border-2 border-background bg-muted text-xs font-medium text-muted-foreground",
className
)}
{...props}
/>
);
}
export { Avatar, AvatarImage, AvatarFallback, AvatarGroup, AvatarGroupCount };

View file

@ -149,7 +149,7 @@ function DropdownMenuSeparator({
return ( return (
<DropdownMenuPrimitive.Separator <DropdownMenuPrimitive.Separator
data-slot="dropdown-menu-separator" data-slot="dropdown-menu-separator"
className={cn("bg-border dark:bg-neutral-700 -mx-1 my-1 h-px", className)} className={cn("bg-border mx-2 my-1 h-px", className)}
{...props} {...props}
/> />
); );

View file

@ -1,7 +1,7 @@
"use client"; "use client";
import { AnimatePresence, motion } from "motion/react";
import { ChevronLeft, ChevronRight } from "lucide-react"; import { ChevronLeft, ChevronRight } from "lucide-react";
import { AnimatePresence, motion } from "motion/react";
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { ExpandedGifOverlay, useExpandedGif } from "@/components/ui/expanded-gif-overlay"; import { ExpandedGifOverlay, useExpandedGif } from "@/components/ui/expanded-gif-overlay";
@ -58,51 +58,29 @@ function HeroCarouselCard({
title, title,
description, description,
src, src,
isActive,
onExpandedChange, onExpandedChange,
}: { }: {
title: string; title: string;
description: string; description: string;
src: string; src: string;
isActive: boolean;
onExpandedChange?: (expanded: boolean) => void; onExpandedChange?: (expanded: boolean) => void;
}) { }) {
const { expanded, open, close } = useExpandedGif(); const { expanded, open, close } = useExpandedGif();
const videoRef = useRef<HTMLVideoElement>(null); const videoRef = useRef<HTMLVideoElement>(null);
const [frozenFrame, setFrozenFrame] = useState<string | null>(null);
const [hasLoaded, setHasLoaded] = useState(false); const [hasLoaded, setHasLoaded] = useState(false);
useEffect(() => { useEffect(() => {
onExpandedChange?.(expanded); onExpandedChange?.(expanded);
}, [expanded, onExpandedChange]); }, [expanded, onExpandedChange]);
const captureFrame = useCallback((video: HTMLVideoElement) => {
try {
const canvas = document.createElement("canvas");
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
canvas.getContext("2d")?.drawImage(video, 0, 0);
setFrozenFrame(canvas.toDataURL("image/jpeg", 0.85));
} catch {
/* tainted canvas */
}
}, []);
useEffect(() => { useEffect(() => {
const video = videoRef.current; const video = videoRef.current;
if (isActive) { if (video) {
setHasLoaded(false); setHasLoaded(false);
if (video) { video.currentTime = 0;
video.currentTime = 0; video.play().catch(() => {});
video.play().catch(() => {});
}
} else {
if (video) {
if (video.readyState >= 2) captureFrame(video);
video.pause();
}
} }
}, [isActive, captureFrame]); }, [src]);
const handleCanPlay = useCallback(() => { const handleCanPlay = useCallback(() => {
setHasLoaded(true); setHasLoaded(true);
@ -119,40 +97,22 @@ function HeroCarouselCard({
<p className="text-sm text-neutral-500 dark:text-neutral-400">{description}</p> <p className="text-sm text-neutral-500 dark:text-neutral-400">{description}</p>
</div> </div>
</div> </div>
<div <div className="cursor-pointer bg-neutral-50 p-2 sm:p-3 dark:bg-neutral-950" onClick={open}>
className={`bg-neutral-50 p-2 sm:p-3 dark:bg-neutral-950 ${ <div className="relative">
isActive ? "cursor-pointer" : "pointer-events-none" <video
}`} ref={videoRef}
onClick={isActive ? open : undefined} src={src}
> autoPlay
{isActive ? ( loop
<div className="relative"> muted
<video playsInline
ref={videoRef} onCanPlay={handleCanPlay}
src={src} className="w-full rounded-lg sm:rounded-xl"
autoPlay />
loop {!hasLoaded && (
muted <div className="absolute inset-0 aspect-video w-full animate-pulse rounded-lg bg-neutral-100 sm:rounded-xl dark:bg-neutral-800" />
playsInline )}
onCanPlay={handleCanPlay} </div>
className="w-full rounded-lg sm:rounded-xl"
/>
{!hasLoaded && frozenFrame && (
<img
src={frozenFrame}
alt={title}
className="absolute inset-0 w-full rounded-lg sm:rounded-xl"
/>
)}
{!hasLoaded && !frozenFrame && (
<div className="aspect-video w-full animate-pulse rounded-lg bg-neutral-100 sm:rounded-xl dark:bg-neutral-800" />
)}
</div>
) : frozenFrame ? (
<img src={frozenFrame} alt={title} className="w-full rounded-lg sm:rounded-xl" />
) : (
<div className="aspect-video w-full rounded-lg bg-neutral-100 sm:rounded-xl dark:bg-neutral-800" />
)}
</div> </div>
</div> </div>
@ -163,15 +123,42 @@ function HeroCarouselCard({
); );
} }
function usePrefetchVideos() {
const videosRef = useRef<HTMLVideoElement[]>([]);
useEffect(() => {
let cancelled = false;
async function prefetch() {
for (const item of carouselItems) {
if (cancelled) break;
await new Promise<void>((resolve) => {
const video = document.createElement("video");
video.preload = "auto";
video.src = item.src;
video.oncanplaythrough = () => resolve();
video.onerror = () => resolve();
setTimeout(resolve, 10000);
videosRef.current.push(video);
});
}
}
prefetch();
return () => {
cancelled = true;
videosRef.current = [];
};
}, []);
}
function HeroCarousel() { function HeroCarousel() {
const [activeIndex, setActiveIndex] = useState(0); const [activeIndex, setActiveIndex] = useState(0);
const [isGifExpanded, setIsGifExpanded] = useState(false); const [isGifExpanded, setIsGifExpanded] = useState(false);
const [containerWidth, setContainerWidth] = useState(0);
const [cardHeight, setCardHeight] = useState(420);
const containerRef = useRef<HTMLDivElement>(null);
const activeCardRef = useRef<HTMLDivElement>(null);
const directionRef = useRef<"forward" | "backward">("forward"); const directionRef = useRef<"forward" | "backward">("forward");
usePrefetchVideos();
const goTo = useCallback( const goTo = useCallback(
(newIndex: number) => { (newIndex: number) => {
directionRef.current = newIndex >= activeIndex ? "forward" : "backward"; directionRef.current = newIndex >= activeIndex ? "forward" : "backward";
@ -188,120 +175,28 @@ function HeroCarousel() {
goTo(activeIndex >= carouselItems.length - 1 ? 0 : activeIndex + 1); goTo(activeIndex >= carouselItems.length - 1 ? 0 : activeIndex + 1);
}, [activeIndex, goTo]); }, [activeIndex, goTo]);
useEffect(() => { const item = carouselItems[activeIndex];
const el = containerRef.current; const isForward = directionRef.current === "forward";
if (!el) return;
const update = () => setContainerWidth(el.offsetWidth);
update();
const observer = new ResizeObserver(update);
observer.observe(el);
return () => observer.disconnect();
}, []);
useEffect(() => {
const el = activeCardRef.current;
if (!el) return;
const update = () => setCardHeight(el.offsetHeight);
update();
const observer = new ResizeObserver(update);
observer.observe(el);
return () => observer.disconnect();
}, [activeIndex, containerWidth]);
const cardWidth =
containerWidth < 640
? containerWidth * 0.85
: containerWidth < 1024
? Math.min(containerWidth * 0.7, 680)
: Math.min(containerWidth * 0.55, 900);
const baseOffset =
containerWidth < 640
? containerWidth * 0.2
: containerWidth < 1024
? containerWidth * 0.15
: 150;
const stackGap = containerWidth < 640 ? 35 : containerWidth < 1024 ? 45 : 55;
const perspective = containerWidth < 640 ? 800 : containerWidth < 1024 ? 1000 : 1200;
const getCardStyle = useCallback(
(index: number) => {
const diff = index - activeIndex;
if (diff === 0) {
const originX = directionRef.current === "forward" ? 1 : 0;
return { x: -cardWidth / 2, rotateY: 0, zIndex: 20, originX, overlayOpacity: 0, blur: 0 };
}
const dist = Math.abs(diff);
const isLeft = diff < 0;
const offset = baseOffset + (dist - 1) * stackGap;
const t = Math.min(1, dist / 3);
return {
x: -cardWidth / 2 + (isLeft ? -offset : offset),
rotateY: isLeft ? 90 : -90,
zIndex: 20 - dist,
originX: isLeft ? 0 : 1,
overlayOpacity: t,
blur: t * 6,
};
},
[activeIndex, cardWidth, baseOffset, stackGap]
);
return ( return (
<div className="w-full py-4 sm:py-8"> <div className="w-full py-4 sm:py-8">
<div ref={containerRef} className="relative mx-auto w-full"> <div className="relative mx-auto w-full max-w-[900px]">
<div <AnimatePresence mode="wait" initial={false}>
className="relative z-6 transition-[height] duration-700" <motion.div
style={{ perspective: `${perspective}px`, height: cardHeight }} key={activeIndex}
> initial={{ opacity: 0, x: isForward ? 60 : -60 }}
{containerWidth > 0 && animate={{ opacity: 1, x: 0 }}
carouselItems.map((item, i) => { exit={{ opacity: 0, x: isForward ? -60 : 60 }}
const style = getCardStyle(i); transition={{ duration: 0.35, ease: [0.32, 0.72, 0, 1] }}
return ( >
<motion.div <HeroCarouselCard
key={`carousel_${i}`} title={item.title}
ref={i === activeIndex ? activeCardRef : undefined} description={item.description}
className="absolute top-0" src={item.src}
style={{ onExpandedChange={setIsGifExpanded}
left: "50%", />
width: cardWidth, </motion.div>
transformStyle: "preserve-3d", </AnimatePresence>
zIndex: style.zIndex,
transformOrigin: `${style.originX * 100}% 50%`,
cursor: i !== activeIndex ? "pointer" : undefined,
}}
onClick={i !== activeIndex && !isGifExpanded ? () => goTo(i) : undefined}
animate={{
x: style.x,
rotateY: style.rotateY,
}}
transition={{ duration: 0.7, ease: [0.32, 0.72, 0, 1] }}
>
<motion.div
animate={{ filter: `blur(${style.blur}px)` }}
transition={{ duration: 0.7, ease: [0.32, 0.72, 0, 1] }}
>
<HeroCarouselCard
title={item.title}
description={item.description}
src={item.src}
isActive={i === activeIndex}
onExpandedChange={setIsGifExpanded}
/>
</motion.div>
<motion.div
className="pointer-events-none absolute inset-0 rounded-2xl bg-black sm:rounded-3xl"
animate={{ opacity: style.overlayOpacity }}
transition={{ duration: 0.7, ease: [0.32, 0.72, 0, 1] }}
/>
</motion.div>
);
})}
</div>
</div> </div>
<div className="relative z-5 mt-6 flex items-center justify-center gap-4"> <div className="relative z-5 mt-6 flex items-center justify-center gap-4">

View file

@ -10,6 +10,8 @@ const Toaster = ({ ...props }: ToasterProps) => {
<Sonner <Sonner
theme={theme as ToasterProps["theme"]} theme={theme as ToasterProps["theme"]}
className="toaster group" className="toaster group"
position="top-right"
richColors
toastOptions={{ toastOptions={{
classNames: { classNames: {
toast: toast:

View file

@ -0,0 +1,132 @@
---
title: Code of Conduct
description: Community guidelines and expectations for behavior
icon: Heart
---
## Our Pledge
We as members, contributors, and leaders pledge to make participation in our
community a harassment-free experience for everyone, regardless of age, body
size, visible or invisible disability, ethnicity, sex characteristics, gender
identity and expression, level of experience, education, socio-economic status,
nationality, personal appearance, race, religion, or sexual identity
and orientation.
We pledge to act and interact in ways that contribute to an open, welcoming,
diverse, inclusive, and healthy community.
## Our Standards
Examples of behavior that contributes to a positive environment for our
community include:
* Demonstrating empathy and kindness toward other people
* Being respectful of differing opinions, viewpoints, and experiences
* Giving and gracefully accepting constructive feedback
* Accepting responsibility and apologizing to those affected by our mistakes,
and learning from the experience
* Focusing on what is best not just for us as individuals, but for the
overall community
Examples of unacceptable behavior include:
* The use of sexualized language or imagery, and sexual attention or
advances of any kind
* Trolling, insulting or derogatory comments, and personal or political attacks
* Public or private harassment
* Publishing others' private information, such as a physical or email
address, without their explicit permission
* Other conduct which could reasonably be considered inappropriate in a
professional setting
## Enforcement Responsibilities
Community leaders are responsible for clarifying and enforcing our standards of
acceptable behavior and will take appropriate and fair corrective action in
response to any behavior that they deem inappropriate, threatening, offensive,
or harmful.
Community leaders have the right and responsibility to remove, edit, or reject
comments, commits, code, wiki edits, issues, and other contributions that are
not aligned to this Code of Conduct, and will communicate reasons for moderation
decisions when appropriate.
## Scope
This Code of Conduct applies within all community spaces, and also applies when
an individual is officially representing the community in public spaces.
Examples of representing our community include using an official e-mail address,
posting via an official social media account, or acting as an appointed
representative at an online or offline event.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported to the community leaders responsible for enforcement at
rohan@surfsense.com.
All complaints will be reviewed and investigated promptly and fairly.
All community leaders are obligated to respect the privacy and security of the
reporter of any incident.
## Enforcement Guidelines
Community leaders will follow these Community Impact Guidelines in determining
the consequences for any action they deem in violation of this Code of Conduct:
### 1. Correction
**Community Impact**: Use of inappropriate language or other behavior deemed
unprofessional or unwelcome in the community.
**Consequence**: A private, written warning from community leaders, providing
clarity around the nature of the violation and an explanation of why the
behavior was inappropriate. A public apology may be requested.
### 2. Warning
**Community Impact**: A violation through a single incident or series
of actions.
**Consequence**: A warning with consequences for continued behavior. No
interaction with the people involved, including unsolicited interaction with
those enforcing the Code of Conduct, for a specified period of time. This
includes avoiding interactions in community spaces as well as external channels
like social media. Violating these terms may lead to a temporary or
permanent ban.
### 3. Temporary Ban
**Community Impact**: A serious violation of community standards, including
sustained inappropriate behavior.
**Consequence**: A temporary ban from any sort of interaction or public
communication with the community for a specified period of time. No public or
private interaction with the people involved, including unsolicited interaction
with those enforcing the Code of Conduct, is allowed during this period.
Violating these terms may lead to a permanent ban.
### 4. Permanent Ban
**Community Impact**: Demonstrating a pattern of violation of community
standards, including sustained inappropriate behavior, harassment of an
individual, or aggression toward or disparagement of classes of individuals.
**Consequence**: A permanent ban from any sort of public interaction within
the community.
## Attribution
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
version 2.0, available at
https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
Community Impact Guidelines were inspired by [Mozilla's code of conduct
enforcement ladder](https://github.com/mozilla/diversity).
[homepage]: https://www.contributor-covenant.org
For answers to common questions about this code of conduct, see the FAQ at
https://www.contributor-covenant.org/faq. Translations are available at
https://www.contributor-covenant.org/translations.

View file

@ -88,7 +88,7 @@ After saving, you'll find your OAuth credentials on the integration page:
## Running SurfSense with Airtable Connector ## Running SurfSense with Airtable Connector
Add the Airtable credentials to your `.env` file (created during [Docker installation](/docs/docker-installation)): Add the Airtable credentials to your `.env` file (created during [Docker installation](/docs/docker-installation/docker-compose)):
```bash ```bash
AIRTABLE_CLIENT_ID=your_airtable_client_id AIRTABLE_CLIENT_ID=your_airtable_client_id

View file

@ -44,7 +44,7 @@ After creating the app, you'll see your credentials:
## Running SurfSense with ClickUp Connector ## Running SurfSense with ClickUp Connector
Add the ClickUp credentials to your `.env` file (created during [Docker installation](/docs/docker-installation)): Add the ClickUp credentials to your `.env` file (created during [Docker installation](/docs/docker-installation/docker-compose)):
```bash ```bash
CLICKUP_CLIENT_ID=your_clickup_client_id CLICKUP_CLIENT_ID=your_clickup_client_id

View file

@ -97,7 +97,7 @@ Select the **"Granular scopes"** tab and enable:
## Running SurfSense with Confluence Connector ## Running SurfSense with Confluence Connector
Add the Atlassian credentials to your `.env` file (created during [Docker installation](/docs/docker-installation)): Add the Atlassian credentials to your `.env` file (created during [Docker installation](/docs/docker-installation/docker-compose)):
```bash ```bash
ATLASSIAN_CLIENT_ID=your_atlassian_client_id ATLASSIAN_CLIENT_ID=your_atlassian_client_id

View file

@ -64,7 +64,7 @@ You'll also see your **Application ID** and **Public Key** on this page.
## Running SurfSense with Discord Connector ## Running SurfSense with Discord Connector
Add the Discord credentials to your `.env` file (created during [Docker installation](/docs/docker-installation)): Add the Discord credentials to your `.env` file (created during [Docker installation](/docs/docker-installation/docker-compose)):
```bash ```bash
DISCORD_CLIENT_ID=your_discord_client_id DISCORD_CLIENT_ID=your_discord_client_id

View file

@ -70,7 +70,7 @@ This guide walks you through setting up a Google OAuth 2.0 integration for SurfS
## Running SurfSense with Gmail Connector ## Running SurfSense with Gmail Connector
Add the Google OAuth credentials to your `.env` file (created during [Docker installation](/docs/docker-installation)): Add the Google OAuth credentials to your `.env` file (created during [Docker installation](/docs/docker-installation/docker-compose)):
```bash ```bash
GOOGLE_OAUTH_CLIENT_ID=your_google_client_id GOOGLE_OAUTH_CLIENT_ID=your_google_client_id

View file

@ -69,7 +69,7 @@ This guide walks you through setting up a Google OAuth 2.0 integration for SurfS
## Running SurfSense with Google Calendar Connector ## Running SurfSense with Google Calendar Connector
Add the Google OAuth credentials to your `.env` file (created during [Docker installation](/docs/docker-installation)): Add the Google OAuth credentials to your `.env` file (created during [Docker installation](/docs/docker-installation/docker-compose)):
```bash ```bash
GOOGLE_OAUTH_CLIENT_ID=your_google_client_id GOOGLE_OAUTH_CLIENT_ID=your_google_client_id

View file

@ -70,7 +70,7 @@ This guide walks you through setting up a Google OAuth 2.0 integration for SurfS
## Running SurfSense with Google Drive Connector ## Running SurfSense with Google Drive Connector
Add the Google OAuth credentials to your `.env` file (created during [Docker installation](/docs/docker-installation)): Add the Google OAuth credentials to your `.env` file (created during [Docker installation](/docs/docker-installation/docker-compose)):
```bash ```bash
GOOGLE_OAUTH_CLIENT_ID=your_google_client_id GOOGLE_OAUTH_CLIENT_ID=your_google_client_id

View file

@ -84,7 +84,7 @@ This guide walks you through setting up an Atlassian OAuth 2.0 (3LO) integration
## Running SurfSense with Jira Connector ## Running SurfSense with Jira Connector
Add the Atlassian credentials to your `.env` file (created during [Docker installation](/docs/docker-installation)): Add the Atlassian credentials to your `.env` file (created during [Docker installation](/docs/docker-installation/docker-compose)):
```bash ```bash
ATLASSIAN_CLIENT_ID=your_atlassian_client_id ATLASSIAN_CLIENT_ID=your_atlassian_client_id

View file

@ -53,7 +53,7 @@ After creating the application, you'll see your OAuth credentials:
## Running SurfSense with Linear Connector ## Running SurfSense with Linear Connector
Add the Linear credentials to your `.env` file (created during [Docker installation](/docs/docker-installation)): Add the Linear credentials to your `.env` file (created during [Docker installation](/docs/docker-installation/docker-compose)):
```bash ```bash
LINEAR_CLIENT_ID=your_linear_client_id LINEAR_CLIENT_ID=your_linear_client_id

View file

@ -90,7 +90,7 @@ After registration, you'll be taken to the app's **Overview** page. Here you'll
## Running SurfSense with Microsoft Teams Connector ## Running SurfSense with Microsoft Teams Connector
Add the Microsoft Teams credentials to your `.env` file (created during [Docker installation](/docs/docker-installation)): Add the Microsoft Teams credentials to your `.env` file (created during [Docker installation](/docs/docker-installation/docker-compose)):
```bash ```bash
TEAMS_CLIENT_ID=your_microsoft_client_id TEAMS_CLIENT_ID=your_microsoft_client_id

View file

@ -91,7 +91,7 @@ For additional information:
## Running SurfSense with Notion Connector ## Running SurfSense with Notion Connector
Add the Notion credentials to your `.env` file (created during [Docker installation](/docs/docker-installation)): Add the Notion credentials to your `.env` file (created during [Docker installation](/docs/docker-installation/docker-compose)):
```bash ```bash
NOTION_OAUTH_CLIENT_ID=your_notion_client_id NOTION_OAUTH_CLIENT_ID=your_notion_client_id

View file

@ -80,7 +80,7 @@ Click **"Add an OAuth Scope"** to add each scope.
## Running SurfSense with Slack Connector ## Running SurfSense with Slack Connector
Add the Slack credentials to your `.env` file (created during [Docker installation](/docs/docker-installation)): Add the Slack credentials to your `.env` file (created during [Docker installation](/docs/docker-installation/docker-compose)):
```bash ```bash
SLACK_CLIENT_ID=your_slack_client_id SLACK_CLIENT_ID=your_slack_client_id

View file

@ -1,301 +0,0 @@
---
title: Docker Installation
description: Setting up SurfSense using Docker
icon: Container
---
This guide explains how to run SurfSense using Docker, with options ranging from a single-command install to a fully manual setup.
## Quick Start
### Option 1 — Install Script (recommended)
Downloads the compose files, generates a `SECRET_KEY`, starts all services, and sets up [Watchtower](https://github.com/nicholas-fedor/watchtower) for automatic daily updates.
**Prerequisites:** [Docker Desktop](https://www.docker.com/products/docker-desktop/) must be installed and running.
#### For Linux/macOS users:
```bash
curl -fsSL https://raw.githubusercontent.com/MODSetter/SurfSense/main/docker/scripts/install.sh | bash
```
#### For Windows users (PowerShell):
```powershell
irm https://raw.githubusercontent.com/MODSetter/SurfSense/main/docker/scripts/install.ps1 | iex
```
This creates a `./surfsense/` directory with `docker-compose.yml` and `.env`, then runs `docker compose up -d`.
To skip Watchtower (e.g. in production where you manage updates yourself):
```bash
curl -fsSL https://raw.githubusercontent.com/MODSetter/SurfSense/main/docker/scripts/install.sh | bash -s -- --no-watchtower
```
To customise the check interval (default 24h), use `--watchtower-interval=SECONDS`.
### Option 2 — Manual Docker Compose
```bash
git clone https://github.com/MODSetter/SurfSense.git
cd SurfSense/docker
cp .env.example .env
# Edit .env — at minimum set SECRET_KEY
docker compose up -d
```
After starting, access SurfSense at:
- **Frontend**: [http://localhost:3000](http://localhost:3000)
- **Backend API**: [http://localhost:8000](http://localhost:8000)
- **API Docs**: [http://localhost:8000/docs](http://localhost:8000/docs)
- **Electric SQL**: [http://localhost:5133](http://localhost:5133)
---
## Updating
**Option 1 — Watchtower daemon (recommended, auto-updates every 24 h):**
If you used the install script (Option 1 above), Watchtower is already running. No extra setup needed.
For manual Docker Compose installs (Option 2), start Watchtower separately:
```bash
docker run -d --name watchtower \
--restart unless-stopped \
-v /var/run/docker.sock:/var/run/docker.sock \
nickfedor/watchtower \
--label-enable \
--interval 86400
```
**Option 2 — Watchtower one-time update:**
```bash
docker run --rm -v /var/run/docker.sock:/var/run/docker.sock \
nickfedor/watchtower --run-once \
--label-filter "com.docker.compose.project=surfsense"
```
<Callout type="warn">
Use `nickfedor/watchtower`. The original `containrrr/watchtower` is no longer maintained and may fail with newer Docker versions.
</Callout>
**Option 3 — Manual:**
```bash
cd surfsense # or SurfSense/docker if you cloned manually
docker compose pull && docker compose up -d
```
Database migrations are applied automatically on every startup.
---
## Configuration
All configuration lives in a single `docker/.env` file (or `surfsense/.env` if you used the install script). Copy `.env.example` to `.env` and edit the values you need.
### Required
| Variable | Description |
|----------|-------------|
| `SECRET_KEY` | JWT secret key. Generate with: `openssl rand -base64 32`. Auto-generated by the install script. |
### Core Settings
| Variable | Description | Default |
|----------|-------------|---------|
| `SURFSENSE_VERSION` | Image tag to deploy. Use `latest`, a clean version (e.g. `0.0.14`), or a specific build (e.g. `0.0.14.1`) | `latest` |
| `AUTH_TYPE` | Authentication method: `LOCAL` (email/password) or `GOOGLE` (OAuth) | `LOCAL` |
| `ETL_SERVICE` | Document parsing: `DOCLING` (local), `UNSTRUCTURED`, or `LLAMACLOUD` | `DOCLING` |
| `EMBEDDING_MODEL` | Embedding model for vector search | `sentence-transformers/all-MiniLM-L6-v2` |
| `TTS_SERVICE` | Text-to-speech provider for podcasts | `local/kokoro` |
| `STT_SERVICE` | Speech-to-text provider for audio files | `local/base` |
| `REGISTRATION_ENABLED` | Allow new user registrations | `TRUE` |
### Ports
| Variable | Description | Default |
|----------|-------------|---------|
| `FRONTEND_PORT` | Frontend service port | `3000` |
| `BACKEND_PORT` | Backend API service port | `8000` |
| `ELECTRIC_PORT` | Electric SQL service port | `5133` |
### Custom Domain / Reverse Proxy
Only set these if serving SurfSense on a real domain via a reverse proxy (Caddy, Nginx, Cloudflare Tunnel, etc.). Leave commented out for standard localhost deployments.
| Variable | Description |
|----------|-------------|
| `NEXT_FRONTEND_URL` | Public frontend URL (e.g. `https://app.yourdomain.com`) |
| `BACKEND_URL` | Public backend URL for OAuth callbacks (e.g. `https://api.yourdomain.com`) |
| `NEXT_PUBLIC_FASTAPI_BACKEND_URL` | Backend URL used by the frontend (e.g. `https://api.yourdomain.com`) |
| `NEXT_PUBLIC_ELECTRIC_URL` | Electric SQL URL used by the frontend (e.g. `https://electric.yourdomain.com`) |
### Database
Defaults work out of the box. Change for security in production.
| Variable | Description | Default |
|----------|-------------|---------|
| `DB_USER` | PostgreSQL username | `surfsense` |
| `DB_PASSWORD` | PostgreSQL password | `surfsense` |
| `DB_NAME` | PostgreSQL database name | `surfsense` |
| `DB_HOST` | PostgreSQL host | `db` |
| `DB_PORT` | PostgreSQL port | `5432` |
| `DB_SSLMODE` | SSL mode: `disable`, `require`, `verify-ca`, `verify-full` | `disable` |
| `DATABASE_URL` | Full connection URL override. Use for managed databases (RDS, Supabase, etc.) | *(built from above)* |
### Electric SQL
| Variable | Description | Default |
|----------|-------------|---------|
| `ELECTRIC_DB_USER` | Replication user for Electric SQL | `electric` |
| `ELECTRIC_DB_PASSWORD` | Replication password for Electric SQL | `electric_password` |
| `ELECTRIC_DATABASE_URL` | Full connection URL override for Electric. Set to `host.docker.internal` when pointing at a local Postgres instance | *(built from above)* |
### Authentication
| Variable | Description |
|----------|-------------|
| `GOOGLE_OAUTH_CLIENT_ID` | Google OAuth client ID (required if `AUTH_TYPE=GOOGLE`) |
| `GOOGLE_OAUTH_CLIENT_SECRET` | Google OAuth client secret (required if `AUTH_TYPE=GOOGLE`) |
Create credentials at the [Google Cloud Console](https://console.cloud.google.com/apis/credentials).
### External API Keys
| Variable | Description |
|----------|-------------|
| `FIRECRAWL_API_KEY` | Firecrawl API key for web crawling |
| `UNSTRUCTURED_API_KEY` | Unstructured.io API key (required if `ETL_SERVICE=UNSTRUCTURED`) |
| `LLAMA_CLOUD_API_KEY` | LlamaCloud API key (required if `ETL_SERVICE=LLAMACLOUD`) |
### Connector OAuth Keys
Uncomment the connectors you want to use. Redirect URIs follow the pattern `http://localhost:8000/api/v1/auth/<connector>/connector/callback`.
| Connector | Variables |
|-----------|-----------|
| Google Drive / Gmail / Calendar | `GOOGLE_DRIVE_REDIRECT_URI`, `GOOGLE_GMAIL_REDIRECT_URI`, `GOOGLE_CALENDAR_REDIRECT_URI` |
| Notion | `NOTION_CLIENT_ID`, `NOTION_CLIENT_SECRET`, `NOTION_REDIRECT_URI` |
| Slack | `SLACK_CLIENT_ID`, `SLACK_CLIENT_SECRET`, `SLACK_REDIRECT_URI` |
| Discord | `DISCORD_CLIENT_ID`, `DISCORD_CLIENT_SECRET`, `DISCORD_BOT_TOKEN`, `DISCORD_REDIRECT_URI` |
| Jira & Confluence | `ATLASSIAN_CLIENT_ID`, `ATLASSIAN_CLIENT_SECRET`, `JIRA_REDIRECT_URI`, `CONFLUENCE_REDIRECT_URI` |
| Linear | `LINEAR_CLIENT_ID`, `LINEAR_CLIENT_SECRET`, `LINEAR_REDIRECT_URI` |
| ClickUp | `CLICKUP_CLIENT_ID`, `CLICKUP_CLIENT_SECRET`, `CLICKUP_REDIRECT_URI` |
| Airtable | `AIRTABLE_CLIENT_ID`, `AIRTABLE_CLIENT_SECRET`, `AIRTABLE_REDIRECT_URI` |
| Microsoft Teams | `TEAMS_CLIENT_ID`, `TEAMS_CLIENT_SECRET`, `TEAMS_REDIRECT_URI` |
For Airtable, create an OAuth integration at the [Airtable Developer Hub](https://airtable.com/create/oauth).
### Observability (optional)
| Variable | Description |
|----------|-------------|
| `LANGSMITH_TRACING` | Enable LangSmith tracing (`true` / `false`) |
| `LANGSMITH_ENDPOINT` | LangSmith API endpoint |
| `LANGSMITH_API_KEY` | LangSmith API key |
| `LANGSMITH_PROJECT` | LangSmith project name |
### Advanced (optional)
| Variable | Description | Default |
|----------|-------------|---------|
| `SCHEDULE_CHECKER_INTERVAL` | How often to check for scheduled connector tasks (e.g. `5m`, `1h`) | `5m` |
| `RERANKERS_ENABLED` | Enable document reranking for improved search | `FALSE` |
| `RERANKERS_MODEL_NAME` | Reranker model name (e.g. `ms-marco-MiniLM-L-12-v2`) | |
| `RERANKERS_MODEL_TYPE` | Reranker model type (e.g. `flashrank`) | |
| `PAGES_LIMIT` | Max pages per user for ETL services | unlimited |
---
## Docker Services
| Service | Description |
|---------|-------------|
| `db` | PostgreSQL with pgvector extension |
| `redis` | Message broker for Celery |
| `backend` | FastAPI application server |
| `celery_worker` | Background task processing (document indexing, etc.) |
| `celery_beat` | Periodic task scheduler (connector sync) |
| `electric` | Electric SQL — real-time sync for the frontend |
| `frontend` | Next.js web application |
All services start automatically with `docker compose up -d`.
The backend includes a health check — dependent services (workers, frontend) wait until the API is fully ready before starting. You can monitor startup progress with `docker compose ps` (look for `(health: starting)` → `(healthy)`).
---
## Development Compose File
If you're contributing to SurfSense and want to build from source, use `docker-compose.dev.yml` instead:
```bash
cd SurfSense/docker
docker compose -f docker-compose.dev.yml up --build
```
This file builds the backend and frontend from your local source code (instead of pulling prebuilt images) and includes pgAdmin for database inspection at [http://localhost:5050](http://localhost:5050). Use the production `docker-compose.yml` for all other cases.
The following `.env` variables are **only used by the dev compose file** (they have no effect on the production `docker-compose.yml`):
| Variable | Description | Default |
|----------|-------------|---------|
| `PGADMIN_PORT` | pgAdmin web UI port | `5050` |
| `PGADMIN_DEFAULT_EMAIL` | pgAdmin login email | `admin@surfsense.com` |
| `PGADMIN_DEFAULT_PASSWORD` | pgAdmin login password | `surfsense` |
| `REDIS_PORT` | Exposed Redis port (internal-only in prod) | `6379` |
| `NEXT_PUBLIC_FASTAPI_BACKEND_AUTH_TYPE` | Frontend build arg for auth type | `LOCAL` |
| `NEXT_PUBLIC_ETL_SERVICE` | Frontend build arg for ETL service | `DOCLING` |
| `NEXT_PUBLIC_DEPLOYMENT_MODE` | Frontend build arg for deployment mode | `self-hosted` |
| `NEXT_PUBLIC_ELECTRIC_AUTH_MODE` | Frontend build arg for Electric auth | `insecure` |
In the production compose file, the `NEXT_PUBLIC_*` frontend variables are automatically derived from `AUTH_TYPE`, `ETL_SERVICE`, and the port settings. In the dev compose file, they are passed as build args since the frontend is built from source.
---
## Migrating from the All-in-One Container
<Callout type="warn">
If you were previously using `docker-compose.quickstart.yml` (the legacy all-in-one `surfsense` container), your data lives in a `surfsense-data` volume and requires a **one-time migration** before switching to the current setup. PostgreSQL has been upgraded from version 14 to 17, so a simple volume swap will not work.
See the full step-by-step guide: [Migrate from the All-in-One Container](/docs/how-to/migrate-from-allinone).
</Callout>
---
## Useful Commands
```bash
# View logs (all services)
docker compose logs -f
# View logs for a specific service
docker compose logs -f backend
docker compose logs -f electric
# Stop all services
docker compose down
# Restart a specific service
docker compose restart backend
# Stop and remove all containers + volumes (destructive!)
docker compose down -v
```
---
## Troubleshooting
- **Ports already in use** — Change the relevant `*_PORT` variable in `.env` and restart.
- **Permission errors on Linux** — You may need to prefix `docker` commands with `sudo`.
- **Electric SQL not connecting** — Check `docker compose logs electric`. If it shows `domain does not exist: db`, ensure `ELECTRIC_DATABASE_URL` is not set to a stale value in `.env`.
- **Real-time updates not working in browser** — Open DevTools → Console and look for `[Electric]` errors. Check that `NEXT_PUBLIC_ELECTRIC_URL` matches the running Electric SQL address.
- **Line ending issues on Windows** — Run `git config --global core.autocrlf true` before cloning.

View file

@ -0,0 +1,30 @@
---
title: Docker Compose Development
description: Building SurfSense from source using docker-compose.dev.yml
---
If you're contributing to SurfSense and want to build from source, use `docker-compose.dev.yml` instead:
```bash
cd SurfSense/docker
docker compose -f docker-compose.dev.yml up --build
```
This file builds the backend and frontend from your local source code (instead of pulling prebuilt images) and includes pgAdmin for database inspection at [http://localhost:5050](http://localhost:5050). Use the production `docker-compose.yml` for all other cases.
## Dev-Only Environment Variables
The following `.env` variables are **only used by the dev compose file** (they have no effect on the production `docker-compose.yml`):
| Variable | Description | Default |
|----------|-------------|---------|
| `PGADMIN_PORT` | pgAdmin web UI port | `5050` |
| `PGADMIN_DEFAULT_EMAIL` | pgAdmin login email | `admin@surfsense.com` |
| `PGADMIN_DEFAULT_PASSWORD` | pgAdmin login password | `surfsense` |
| `REDIS_PORT` | Exposed Redis port (internal-only in prod) | `6379` |
| `NEXT_PUBLIC_FASTAPI_BACKEND_AUTH_TYPE` | Frontend build arg for auth type | `LOCAL` |
| `NEXT_PUBLIC_ETL_SERVICE` | Frontend build arg for ETL service | `DOCLING` |
| `NEXT_PUBLIC_DEPLOYMENT_MODE` | Frontend build arg for deployment mode | `self-hosted` |
| `NEXT_PUBLIC_ELECTRIC_AUTH_MODE` | Frontend build arg for Electric auth | `insecure` |
In the production compose file, the `NEXT_PUBLIC_*` frontend variables are automatically derived from `AUTH_TYPE`, `ETL_SERVICE`, and the port settings. In the dev compose file, they are passed as build args since the frontend is built from source.

View file

@ -0,0 +1,188 @@
---
title: Docker Compose
description: Manual Docker Compose setup for SurfSense
---
## Setup
```bash
git clone https://github.com/MODSetter/SurfSense.git
cd SurfSense/docker
cp .env.example .env
# Edit .env, at minimum set SECRET_KEY
docker compose up -d
```
After starting, access SurfSense at:
- **Frontend**: [http://localhost:3929](http://localhost:3929)
- **Backend API**: [http://localhost:8929](http://localhost:8929)
- **API Docs**: [http://localhost:8929/docs](http://localhost:8929/docs)
- **Electric SQL**: [http://localhost:5929](http://localhost:5929)
---
## Configuration
All configuration lives in a single `docker/.env` file (or `surfsense/.env` if you used the install script). Copy `.env.example` to `.env` and edit the values you need.
### Required
| Variable | Description |
|----------|-------------|
| `SECRET_KEY` | JWT secret key. Generate with: `openssl rand -base64 32`. Auto-generated by the install script. |
### Core Settings
| Variable | Description | Default |
|----------|-------------|---------|
| `SURFSENSE_VERSION` | Image tag to deploy. Use `latest`, a clean version (e.g. `0.0.14`), or a specific build (e.g. `0.0.14.1`) | `latest` |
| `AUTH_TYPE` | Authentication method: `LOCAL` (email/password) or `GOOGLE` (OAuth) | `LOCAL` |
| `ETL_SERVICE` | Document parsing: `DOCLING` (local), `UNSTRUCTURED`, or `LLAMACLOUD` | `DOCLING` |
| `EMBEDDING_MODEL` | Embedding model for vector search | `sentence-transformers/all-MiniLM-L6-v2` |
| `TTS_SERVICE` | Text-to-speech provider for podcasts | `local/kokoro` |
| `STT_SERVICE` | Speech-to-text provider for audio files | `local/base` |
| `REGISTRATION_ENABLED` | Allow new user registrations | `TRUE` |
### Ports
| Variable | Description | Default |
|----------|-------------|---------|
| `FRONTEND_PORT` | Frontend service port | `3929` |
| `BACKEND_PORT` | Backend API service port | `8929` |
| `ELECTRIC_PORT` | Electric SQL service port | `5929` |
### Custom Domain / Reverse Proxy
Only set these if serving SurfSense on a real domain via a reverse proxy (Caddy, Nginx, Cloudflare Tunnel, etc.). Leave commented out for standard localhost deployments.
| Variable | Description |
|----------|-------------|
| `NEXT_FRONTEND_URL` | Public frontend URL (e.g. `https://app.yourdomain.com`) |
| `BACKEND_URL` | Public backend URL for OAuth callbacks (e.g. `https://api.yourdomain.com`) |
| `NEXT_PUBLIC_FASTAPI_BACKEND_URL` | Backend URL used by the frontend (e.g. `https://api.yourdomain.com`) |
| `NEXT_PUBLIC_ELECTRIC_URL` | Electric SQL URL used by the frontend (e.g. `https://electric.yourdomain.com`) |
### Database
Defaults work out of the box. Change for security in production.
| Variable | Description | Default |
|----------|-------------|---------|
| `DB_USER` | PostgreSQL username | `surfsense` |
| `DB_PASSWORD` | PostgreSQL password | `surfsense` |
| `DB_NAME` | PostgreSQL database name | `surfsense` |
| `DB_HOST` | PostgreSQL host | `db` |
| `DB_PORT` | PostgreSQL port | `5432` |
| `DB_SSLMODE` | SSL mode: `disable`, `require`, `verify-ca`, `verify-full` | `disable` |
| `DATABASE_URL` | Full connection URL override. Use for managed databases (RDS, Supabase, etc.) | *(built from above)* |
### Electric SQL
| Variable | Description | Default |
|----------|-------------|---------|
| `ELECTRIC_DB_USER` | Replication user for Electric SQL | `electric` |
| `ELECTRIC_DB_PASSWORD` | Replication password for Electric SQL | `electric_password` |
| `ELECTRIC_DATABASE_URL` | Full connection URL override for Electric. Set to `host.docker.internal` when pointing at a local Postgres instance | *(built from above)* |
### Authentication
| Variable | Description |
|----------|-------------|
| `GOOGLE_OAUTH_CLIENT_ID` | Google OAuth client ID (required if `AUTH_TYPE=GOOGLE`) |
| `GOOGLE_OAUTH_CLIENT_SECRET` | Google OAuth client secret (required if `AUTH_TYPE=GOOGLE`) |
Create credentials at the [Google Cloud Console](https://console.cloud.google.com/apis/credentials).
### External API Keys
| Variable | Description |
|----------|-------------|
| `FIRECRAWL_API_KEY` | [Firecrawl](https://www.firecrawl.dev/) API key for web crawling |
| `UNSTRUCTURED_API_KEY` | [Unstructured.io](https://unstructured.io/) API key (required if `ETL_SERVICE=UNSTRUCTURED`) |
| `LLAMA_CLOUD_API_KEY` | [LlamaCloud](https://cloud.llamaindex.ai/) API key (required if `ETL_SERVICE=LLAMACLOUD`) |
### Connector OAuth Keys
Uncomment the connectors you want to use. Redirect URIs follow the pattern `http://localhost:8000/api/v1/auth/<connector>/connector/callback`.
| Connector | Variables |
|-----------|-----------|
| Google Drive / Gmail / Calendar | `GOOGLE_DRIVE_REDIRECT_URI`, `GOOGLE_GMAIL_REDIRECT_URI`, `GOOGLE_CALENDAR_REDIRECT_URI` |
| Notion | `NOTION_CLIENT_ID`, `NOTION_CLIENT_SECRET`, `NOTION_REDIRECT_URI` |
| Slack | `SLACK_CLIENT_ID`, `SLACK_CLIENT_SECRET`, `SLACK_REDIRECT_URI` |
| Discord | `DISCORD_CLIENT_ID`, `DISCORD_CLIENT_SECRET`, `DISCORD_BOT_TOKEN`, `DISCORD_REDIRECT_URI` |
| Jira & Confluence | `ATLASSIAN_CLIENT_ID`, `ATLASSIAN_CLIENT_SECRET`, `JIRA_REDIRECT_URI`, `CONFLUENCE_REDIRECT_URI` |
| Linear | `LINEAR_CLIENT_ID`, `LINEAR_CLIENT_SECRET`, `LINEAR_REDIRECT_URI` |
| ClickUp | `CLICKUP_CLIENT_ID`, `CLICKUP_CLIENT_SECRET`, `CLICKUP_REDIRECT_URI` |
| Airtable | `AIRTABLE_CLIENT_ID`, `AIRTABLE_CLIENT_SECRET`, `AIRTABLE_REDIRECT_URI` |
| Microsoft Teams | `TEAMS_CLIENT_ID`, `TEAMS_CLIENT_SECRET`, `TEAMS_REDIRECT_URI` |
### Observability (optional)
| Variable | Description |
|----------|-------------|
| `LANGSMITH_TRACING` | Enable LangSmith tracing (`true` / `false`) |
| `LANGSMITH_ENDPOINT` | LangSmith API endpoint |
| `LANGSMITH_API_KEY` | LangSmith API key |
| `LANGSMITH_PROJECT` | LangSmith project name |
### Advanced (optional)
| Variable | Description | Default |
|----------|-------------|---------|
| `SCHEDULE_CHECKER_INTERVAL` | How often to check for scheduled connector tasks (e.g. `5m`, `1h`) | `5m` |
| `RERANKERS_ENABLED` | Enable document reranking for improved search | `FALSE` |
| `RERANKERS_MODEL_NAME` | Reranker model name (e.g. `ms-marco-MiniLM-L-12-v2`) | |
| `RERANKERS_MODEL_TYPE` | Reranker model type (e.g. `flashrank`) | |
| `PAGES_LIMIT` | Max pages per user for ETL services | unlimited |
---
## Docker Services
| Service | Description |
|---------|-------------|
| `db` | PostgreSQL with pgvector extension |
| `redis` | Message broker for Celery |
| `backend` | FastAPI application server |
| `celery_worker` | Background task processing (document indexing, etc.) |
| `celery_beat` | Periodic task scheduler (connector sync) |
| `electric` | Electric SQL (real-time sync for the frontend) |
| `frontend` | Next.js web application |
All services start automatically with `docker compose up -d`.
The backend includes a health check. Dependent services (workers, frontend) wait until the API is fully ready before starting. You can monitor startup progress with `docker compose ps` (look for `(health: starting)` → `(healthy)`).
---
## Useful Commands
```bash
# View logs (all services)
docker compose logs -f
# View logs for a specific service
docker compose logs -f backend
docker compose logs -f electric
# Stop all services
docker compose down
# Restart a specific service
docker compose restart backend
# Stop and remove all containers + volumes (destructive!)
docker compose down -v
```
---
## Troubleshooting
- **Ports already in use**: Change the relevant `*_PORT` variable in `.env` and restart.
- **Permission errors on Linux**: You may need to prefix `docker` commands with `sudo`.
- **Electric SQL not connecting**: Check `docker compose logs electric`. If it shows `domain does not exist: db`, ensure `ELECTRIC_DATABASE_URL` is not set to a stale value in `.env`.
- **Real-time updates not working in browser**: Open DevTools → Console and look for `[Electric]` errors. Check that `NEXT_PUBLIC_ELECTRIC_URL` matches the running Electric SQL address.
- **Line ending issues on Windows**: Run `git config --global core.autocrlf true` before cloning.

View file

@ -0,0 +1,41 @@
---
title: One-Line Install Script
description: One-command installation of SurfSense using Docker
---
Downloads the compose files, generates a `SECRET_KEY`, starts all services, and sets up [Watchtower](https://github.com/nicholas-fedor/watchtower) for automatic daily updates.
**Prerequisites:** [Docker Desktop](https://www.docker.com/products/docker-desktop/) must be installed and running.
### For Linux/macOS users:
```bash
curl -fsSL https://raw.githubusercontent.com/MODSetter/SurfSense/main/docker/scripts/install.sh | bash
```
### For Windows users (PowerShell):
```powershell
irm https://raw.githubusercontent.com/MODSetter/SurfSense/main/docker/scripts/install.ps1 | iex
```
This creates a `./surfsense/` directory with `docker-compose.yml` and `.env`, then runs `docker compose up -d`.
To skip Watchtower (e.g. in production where you manage updates yourself):
```bash
curl -fsSL https://raw.githubusercontent.com/MODSetter/SurfSense/main/docker/scripts/install.sh | bash -s -- --no-watchtower
```
To customise the check interval (default 24h), use `--watchtower-interval=SECONDS`.
---
## Access SurfSense
After starting, access SurfSense at:
- **Frontend**: [http://localhost:3929](http://localhost:3929)
- **Backend API**: [http://localhost:8929](http://localhost:8929)
- **API Docs**: [http://localhost:8929/docs](http://localhost:8929/docs)
- **Electric SQL**: [http://localhost:5929](http://localhost:5929)

View file

@ -0,0 +1,6 @@
{
"title": "Docker Installation",
"pages": ["install-script", "docker-compose", "updating", "dev-compose", "migrate-from-allinone"],
"icon": "Container",
"defaultOpen": false
}

View file

@ -81,87 +81,6 @@ bash migrate-database.sh --db-user myuser --db-password mypass --db-name mydb
--- ---
## Option C — Manual steps
For users who prefer full control or whose platform doesn't support bash scripts (e.g. Windows without WSL2).
### Step 1 — Stop the old all-in-one container
Before mounting the `surfsense-data` volume into a new container, stop the existing one to prevent two PostgreSQL processes from writing to the same data directory:
```bash
docker stop surfsense 2>/dev/null || true
```
### Step 2 — Start a temporary PostgreSQL 14 container
```bash
docker run -d --name surfsense-pg14-temp \
-v surfsense-data:/data \
-e PGDATA=/data/postgres \
-e POSTGRES_USER=surfsense \
-e POSTGRES_PASSWORD=surfsense \
-e POSTGRES_DB=surfsense \
pgvector/pgvector:pg14
```
Wait ~10 seconds, then confirm it is healthy:
```bash
docker exec surfsense-pg14-temp pg_isready -U surfsense
```
### Step 3 — Dump the database
```bash
docker exec -e PGPASSWORD=surfsense surfsense-pg14-temp \
pg_dump -U surfsense surfsense > surfsense_backup.sql
```
### Step 4 — Recover your SECRET\_KEY
```bash
docker run --rm -v surfsense-data:/data alpine cat /data/.secret_key
```
### Step 5 — Set up the new stack
```bash
mkdir -p surfsense/scripts
curl -fsSL https://raw.githubusercontent.com/MODSetter/SurfSense/main/docker/docker-compose.yml -o surfsense/docker-compose.yml
curl -fsSL https://raw.githubusercontent.com/MODSetter/SurfSense/main/docker/.env.example -o surfsense/.env.example
curl -fsSL https://raw.githubusercontent.com/MODSetter/SurfSense/main/docker/postgresql.conf -o surfsense/postgresql.conf
curl -fsSL https://raw.githubusercontent.com/MODSetter/SurfSense/main/docker/scripts/init-electric-user.sh -o surfsense/scripts/init-electric-user.sh
chmod +x surfsense/scripts/init-electric-user.sh
cp surfsense/.env.example surfsense/.env
```
Set `SECRET_KEY` in `surfsense/.env` to the value from Step 4.
### Step 6 — Start PostgreSQL 17 and restore
```bash
cd surfsense
docker compose up -d db
docker compose exec db pg_isready -U surfsense # wait until ready
docker compose exec -T db psql -U surfsense -d surfsense < ../surfsense_backup.sql
```
### Step 7 — Start all services
```bash
docker compose up -d
```
### Step 8 — Clean up
```bash
docker stop surfsense-pg14-temp && docker rm surfsense-pg14-temp
docker volume rm surfsense-data # only after verifying migration succeeded
```
---
## Troubleshooting ## Troubleshooting
### `install.sh` runs normally with a blank database (no migration happened) ### `install.sh` runs normally with a blank database (no migration happened)

View file

@ -0,0 +1,50 @@
---
title: Updating
description: How to update your SurfSense Docker deployment
---
## Watchtower Daemon (recommended)
Auto-updates every 24 hours. If you used the [install script](/docs/docker-installation/install-script), Watchtower is already running. No extra setup needed.
For [manual Docker Compose](/docs/docker-installation/docker-compose) installs, start Watchtower separately:
```bash
docker run -d --name watchtower \
--restart unless-stopped \
-v /var/run/docker.sock:/var/run/docker.sock \
nickfedor/watchtower \
--label-enable \
--interval 86400
```
## Watchtower One-Time Update
```bash
docker run --rm -v /var/run/docker.sock:/var/run/docker.sock \
nickfedor/watchtower --run-once \
--label-filter "com.docker.compose.project=surfsense"
```
<Callout type="warn">
Use `nickfedor/watchtower`. The original `containrrr/watchtower` is no longer maintained and may fail with newer Docker versions.
</Callout>
## Manual Update
```bash
cd surfsense # or SurfSense/docker if you cloned manually
docker compose pull && docker compose up -d
```
Database migrations are applied automatically on every startup.
---
## Migrating from the All-in-One Container
<Callout type="warn">
If you were previously using `docker-compose.quickstart.yml` (the legacy all-in-one `surfsense` container), your data lives in a `surfsense-data` volume and requires a **one-time migration** before switching to the current setup. PostgreSQL has been upgraded from version 14 to 17, so a simple volume swap will not work.
See the full step-by-step guide: [Migrate from the All-in-One Container](/docs/docker-installation/migrate-from-allinone).
</Callout>

View file

@ -5,7 +5,7 @@ description: Setting up Electric SQL for real-time data synchronization in SurfS
[Electric SQL](https://electric-sql.com/) enables real-time data synchronization in SurfSense, providing instant updates for inbox items, document indexing status, and connector sync progress without manual refresh. The frontend uses [PGlite](https://pglite.dev/) (a lightweight PostgreSQL in the browser) to maintain a local database that syncs with the backend via Electric SQL. [Electric SQL](https://electric-sql.com/) enables real-time data synchronization in SurfSense, providing instant updates for inbox items, document indexing status, and connector sync progress without manual refresh. The frontend uses [PGlite](https://pglite.dev/) (a lightweight PostgreSQL in the browser) to maintain a local database that syncs with the backend via Electric SQL.
## What Does Electric SQL Do? ## What does Electric SQL do?
When you index documents or receive inbox updates, Electric SQL pushes updates to your browser in real-time. The data flows like this: When you index documents or receive inbox updates, Electric SQL pushes updates to your browser in real-time. The data flows like this:
@ -23,45 +23,24 @@ This means:
## Docker Setup ## Docker Setup
The `docker-compose.yml` includes the Electric SQL service. It is pre-configured to connect to the Docker-managed `db` container out of the box. - The `docker-compose.yml` includes the Electric SQL service, pre-configured to connect to the Docker-managed `db` container.
- No additional configuration is required. Electric SQL works with the Docker PostgreSQL instance out of the box.
```bash ## Manual Setup (Development Only)
docker compose up -d
```
The Electric SQL service configuration in `docker-compose.yml`: This section is intended for local development environments. Follow the steps below based on your PostgreSQL setup.
```yaml
electric:
image: electricsql/electric:1.4.6
ports:
- "${ELECTRIC_PORT:-5133}:3000"
environment:
DATABASE_URL: ${ELECTRIC_DATABASE_URL:-postgresql://${ELECTRIC_DB_USER:-electric}:${ELECTRIC_DB_PASSWORD:-electric_password}@${DB_HOST:-db}:${DB_PORT:-5432}/${DB_NAME:-surfsense}?sslmode=${DB_SSLMODE:-disable}}
ELECTRIC_INSECURE: "true"
ELECTRIC_WRITE_TO_PG_MODE: direct
depends_on:
db:
condition: service_healthy
```
No additional configuration is required — Electric SQL is pre-configured to work with the Docker PostgreSQL instance.
## Manual Setup
Follow the steps below based on your PostgreSQL setup.
### Step 1: Configure Environment Variables ### Step 1: Configure Environment Variables
Ensure your environment files are configured. If you haven't set up SurfSense yet, follow the [Manual Installation Guide](/docs/manual-installation) first. Ensure your environment files are configured. If you haven't set up SurfSense yet, follow the [Manual Installation Guide](/docs/manual-installation) first.
For Electric SQL, verify these variables are set in `docker/.env`: For Electric SQL, verify these variables are set:
**Backend (`surfsense_backend/.env`):**
```bash ```bash
ELECTRIC_PORT=5133
ELECTRIC_DB_USER=electric ELECTRIC_DB_USER=electric
ELECTRIC_DB_PASSWORD=electric_password ELECTRIC_DB_PASSWORD=electric_password
NEXT_PUBLIC_ELECTRIC_URL=http://localhost:5133
``` ```
**Frontend (`surfsense_web/.env`):** **Frontend (`surfsense_web/.env`):**
@ -71,17 +50,19 @@ NEXT_PUBLIC_ELECTRIC_URL=http://localhost:5133
NEXT_PUBLIC_ELECTRIC_AUTH_MODE=insecure NEXT_PUBLIC_ELECTRIC_AUTH_MODE=insecure
``` ```
Next, choose the option that matches your PostgreSQL setup:
--- ---
### Option A: Using Docker PostgreSQL ### Option A: Using Docker PostgreSQL
If you're using the Docker-managed PostgreSQL instance, no extra configuration is needed. Just start the services: If you're using the Docker-managed PostgreSQL instance, no extra configuration is needed. Just start the services using the development compose file (which exposes the PostgreSQL port to your host machine):
```bash ```bash
docker compose up -d db electric docker compose -f docker-compose.dev.yml up -d db electric
``` ```
Then run the database migration and start the backend: Then run the database migration, start the backend, and launch the frontend:
```bash ```bash
cd surfsense_backend cd surfsense_backend
@ -89,6 +70,13 @@ uv run alembic upgrade head
uv run main.py uv run main.py
``` ```
In a separate terminal, start the frontend:
```bash
cd surfsense_web
pnpm run dev
```
Electric SQL is now configured and connected to your Docker PostgreSQL database. Electric SQL is now configured and connected to your Docker PostgreSQL database.
--- ---
@ -148,7 +136,7 @@ ELECTRIC_DATABASE_URL=postgresql://electric:electric_password@host.docker.intern
**4. Start Electric SQL only (skip the Docker `db` container):** **4. Start Electric SQL only (skip the Docker `db` container):**
```bash ```bash
docker compose up -d --no-deps electric docker compose -f docker-compose.dev.yml up -d --no-deps electric
``` ```
The `--no-deps` flag starts only the `electric` service without starting the Docker-managed `db` container. The `--no-deps` flag starts only the `electric` service without starting the Docker-managed `db` container.
@ -161,18 +149,32 @@ uv run alembic upgrade head
uv run main.py uv run main.py
``` ```
In a separate terminal, start the frontend:
```bash
cd surfsense_web
pnpm run dev
```
Electric SQL is now configured and connected to your local PostgreSQL database. Electric SQL is now configured and connected to your local PostgreSQL database.
## Environment Variables Reference ## Environment Variables Reference
**Required for manual setup:**
| Variable | Location | Description | Default | | Variable | Location | Description | Default |
|----------|----------|-------------|---------| |----------|----------|-------------|---------|
| `ELECTRIC_PORT` | `docker/.env` | Port to expose Electric SQL | `5133` | | `ELECTRIC_DB_USER` | `surfsense_backend/.env` | Database user for Electric replication | `electric` |
| `ELECTRIC_DB_USER` | `docker/.env` | Database user for Electric replication | `electric` | | `ELECTRIC_DB_PASSWORD` | `surfsense_backend/.env` | Database password for Electric replication | `electric_password` |
| `ELECTRIC_DB_PASSWORD` | `docker/.env` | Database password for Electric replication | `electric_password` | | `NEXT_PUBLIC_ELECTRIC_URL` | `surfsense_web/.env` | Electric SQL server URL (PGlite connects to this) | `http://localhost:5133` |
| `ELECTRIC_DATABASE_URL` | `docker/.env` | Full connection URL override for Electric. Set to use `host.docker.internal` when pointing at a local Postgres instance | *(built from above defaults)* | | `NEXT_PUBLIC_ELECTRIC_AUTH_MODE` | `surfsense_web/.env` | Authentication mode (`insecure` for dev, `secure` for production) | `insecure` |
| `NEXT_PUBLIC_ELECTRIC_URL` | Frontend `.env` | Electric SQL server URL (PGlite connects to this) | `http://localhost:5133` |
| `NEXT_PUBLIC_ELECTRIC_AUTH_MODE` | Frontend `.env` | Authentication mode (`insecure` for dev, `secure` for production) | `insecure` | **Optional / Docker-only:**
| Variable | Location | Description | Default |
|----------|----------|-------------|---------|
| `ELECTRIC_PORT` | `docker/.env` | Port to expose Electric SQL on the host | `5133` (dev), `5929` (production) |
| `ELECTRIC_DATABASE_URL` | `docker/.env` | Full connection URL override for Electric. Only needed for Option B (local Postgres via `host.docker.internal`) | *(built from above defaults)* |
## Verify Setup ## Verify Setup

View file

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

View file

@ -1,86 +1,61 @@
--- ---
title: Prerequisites title: Documentation
description: Required setup's before setting up SurfSense description: Welcome to SurfSense's documentation
icon: ClipboardCheck icon: BookOpen
--- ---
import { Card, Cards } from 'fumadocs-ui/components/card';
import { ClipboardCheck, Download, Container, Wrench, Cable, BookOpen, FlaskConical, Heart } from 'lucide-react';
## Auth Setup Welcome to **SurfSense's Documentation!** Here, you'll find everything you need to get the most out of SurfSense. Dive in to explore how SurfSense can be your AI-powered research companion.
SurfSense supports both Google OAuth and local email/password authentication. Google OAuth is optional - if you prefer local authentication, you can skip this section. <Cards>
<Card
**Note**: Google OAuth setup is **required** in your `.env` files if you want to use the Gmail and Google Calendar connectors in SurfSense. icon={<ClipboardCheck />}
title="Prerequisites"
To set up Google OAuth: description="Required setup before installing SurfSense"
href="/docs/prerequisites"
1. Login to your [Google Developer Console](https://console.cloud.google.com/) />
2. Enable the required APIs: <Card
- **People API** (required for basic Google OAuth) icon={<Download />}
![Google Developer Console People API](/docs/connectors/google/google_oauth_people_api.png) title="Installation"
3. Set up OAuth consent screen. description="Choose your installation method"
![Google Developer Console OAuth consent screen](/docs/connectors/google/google_oauth_screen.png) href="/docs/installation"
4. Create OAuth client ID and secret. />
![Google Developer Console OAuth client ID](/docs/connectors/google/google_oauth_client.png) <Card
5. It should look like this. icon={<Container />}
![Google Developer Console Config](/docs/connectors/google/google_oauth_config.png) title="Docker Installation"
description="Deploy SurfSense with Docker Compose"
--- href="/docs/docker-installation"
/>
## File Upload's <Card
icon={<Wrench />}
SurfSense supports three ETL (Extract, Transform, Load) services for converting files to LLM-friendly formats: title="Manual Installation"
description="Set up SurfSense manually from source"
### Option 1: Unstructured href="/docs/manual-installation"
/>
Files are converted using [Unstructured](https://github.com/Unstructured-IO/unstructured) <Card
icon={<Cable />}
1. Get an Unstructured.io API key from [Unstructured Platform](https://platform.unstructured.io/) title="Connectors"
2. You should be able to generate API keys once registered description="Integrate with third-party services"
![Unstructured Dashboard](/docs/unstructured.png) href="/docs/connectors"
/>
### Option 2: LlamaIndex (LlamaCloud) <Card
icon={<BookOpen />}
Files are converted using [LlamaIndex](https://www.llamaindex.ai/) which offers 50+ file format support. title="How-To Guides"
description="Step-by-step guides for common tasks"
1. Get a LlamaIndex API key from [LlamaCloud](https://cloud.llamaindex.ai/) href="/docs/how-to"
2. Sign up for a LlamaCloud account to access their parsing services />
3. LlamaCloud provides enhanced parsing capabilities for complex documents <Card
icon={<FlaskConical />}
### Option 3: Docling (Recommended for Privacy) title="Testing"
description="Running and writing tests for SurfSense"
Files are processed locally using [Docling](https://github.com/DS4SD/docling) - IBM's open-source document parsing library. href="/docs/testing"
/>
1. **No API key required** - all processing happens locally <Card
2. **Privacy-focused** - documents never leave your system icon={<Heart />}
3. **Supported formats**: PDF, Office documents (Word, Excel, PowerPoint), images (PNG, JPEG, TIFF, BMP, WebP), HTML, CSV, AsciiDoc title="Code of Conduct"
4. **Enhanced features**: Advanced table detection, image extraction, and structured document parsing description="Community guidelines and expectations"
5. **GPU acceleration** support for faster processing (when available) href="/docs/code-of-conduct"
/>
**Note**: You only need to set up one of these services. </Cards>
---
## LLM Observability (Optional)
This is not required for SurfSense to work. But it is always a good idea to monitor LLM interactions. So we do not have those WTH moments.
1. Get a LangSmith API key from [smith.langchain.com](https://smith.langchain.com/)
2. This helps in observing SurfSense Researcher Agent.
![LangSmith](/docs/langsmith.png)
---
## Crawler
SurfSense have 2 options for saving webpages:
- [SurfSense Extension](https://github.com/MODSetter/SurfSense/tree/main/surfsense_browser_extension) (Overall better experience & ability to save private webpages, recommended)
- Crawler (If you want to save public webpages)
**NOTE:** SurfSense currently uses [Firecrawl.py](https://www.firecrawl.dev/) for web crawling. If you plan on using the crawler, you will need to create a Firecrawl account and get an API key.
---
## Next Steps
Once you have all prerequisites in place, proceed to the [installation guide](/docs/installation) to set up SurfSense.

View file

@ -12,7 +12,7 @@ There are two ways to install SurfSense, but both require the repository to be c
This method provides a containerized environment with all dependencies pre-configured. Less Customization. This method provides a containerized environment with all dependencies pre-configured. Less Customization.
[Learn more about Docker installation](/docs/docker-installation) [Learn more about Docker installation](/docs/docker-installation/install-script)
## Manual Installation (Preferred) ## Manual Installation (Preferred)

View file

@ -5,12 +5,14 @@
"pages": [ "pages": [
"---Guides---", "---Guides---",
"index", "index",
"prerequisites",
"installation", "installation",
"docker-installation",
"manual-installation", "manual-installation",
"docker-installation",
"connectors", "connectors",
"how-to", "how-to",
"---Development---", "---Developers---",
"testing" "testing",
"code-of-conduct"
] ]
} }

View file

@ -0,0 +1,86 @@
---
title: Prerequisites
description: Required setup's before setting up SurfSense
icon: ClipboardCheck
---
## Auth Setup
SurfSense supports both Google OAuth and local email/password authentication. Google OAuth is optional - if you prefer local authentication, you can skip this section.
**Note**: Google OAuth setup is **required** in your `.env` files if you want to use the Gmail and Google Calendar connectors in SurfSense.
To set up Google OAuth:
1. Login to your [Google Developer Console](https://console.cloud.google.com/)
2. Enable the required APIs:
- **People API** (required for basic Google OAuth)
![Google Developer Console People API](/docs/connectors/google/google_oauth_people_api.png)
3. Set up OAuth consent screen.
![Google Developer Console OAuth consent screen](/docs/connectors/google/google_oauth_screen.png)
4. Create OAuth client ID and secret.
![Google Developer Console OAuth client ID](/docs/connectors/google/google_oauth_client.png)
5. It should look like this.
![Google Developer Console Config](/docs/connectors/google/google_oauth_config.png)
---
## File Upload's
SurfSense supports three ETL (Extract, Transform, Load) services for converting files to LLM-friendly formats:
### Option 1: Unstructured
Files are converted using [Unstructured](https://github.com/Unstructured-IO/unstructured)
1. Get an Unstructured.io API key from [Unstructured Platform](https://platform.unstructured.io/)
2. You should be able to generate API keys once registered
![Unstructured Dashboard](/docs/unstructured.png)
### Option 2: LlamaIndex (LlamaCloud)
Files are converted using [LlamaIndex](https://www.llamaindex.ai/) which offers 50+ file format support.
1. Get a LlamaIndex API key from [LlamaCloud](https://cloud.llamaindex.ai/)
2. Sign up for a LlamaCloud account to access their parsing services
3. LlamaCloud provides enhanced parsing capabilities for complex documents
### Option 3: Docling (Recommended for Privacy)
Files are processed locally using [Docling](https://github.com/DS4SD/docling) - IBM's open-source document parsing library.
1. **No API key required** - all processing happens locally
2. **Privacy-focused** - documents never leave your system
3. **Supported formats**: PDF, Office documents (Word, Excel, PowerPoint), images (PNG, JPEG, TIFF, BMP, WebP), HTML, CSV, AsciiDoc
4. **Enhanced features**: Advanced table detection, image extraction, and structured document parsing
5. **GPU acceleration** support for faster processing (when available)
**Note**: You only need to set up one of these services.
---
## LLM Observability (Optional)
This is not required for SurfSense to work. But it is always a good idea to monitor LLM interactions. So we do not have those WTH moments.
1. Get a LangSmith API key from [smith.langchain.com](https://smith.langchain.com/)
2. This helps in observing SurfSense Researcher Agent.
![LangSmith](/docs/langsmith.png)
---
## Crawler
SurfSense have 2 options for saving webpages:
- [SurfSense Extension](https://github.com/MODSetter/SurfSense/tree/main/surfsense_browser_extension) (Overall better experience & ability to save private webpages, recommended)
- Crawler (If you want to save public webpages)
**NOTE:** SurfSense currently uses [Firecrawl.py](https://www.firecrawl.dev/) for web crawling. If you plan on using the crawler, you will need to create a Firecrawl account and get an API key.
---
## Next Steps
Once you have all prerequisites in place, proceed to the [installation guide](/docs/installation) to set up SurfSense.

View file

@ -61,7 +61,7 @@ export function toDisplayDoc(item: ApiDocumentInput): DocumentDisplay {
} }
const EMPTY_TYPE_FILTER: DocumentTypeEnum[] = []; const EMPTY_TYPE_FILTER: DocumentTypeEnum[] = [];
const INITIAL_PAGE_SIZE = 20; const INITIAL_PAGE_SIZE = 50;
const SCROLL_PAGE_SIZE = 5; const SCROLL_PAGE_SIZE = 5;
function isValidDocument(doc: DocumentElectric): boolean { function isValidDocument(doc: DocumentElectric): boolean {

View file

@ -59,7 +59,7 @@ export function useInbox(
searchSpaceId: number | null, searchSpaceId: number | null,
category: NotificationCategory, category: NotificationCategory,
prefetchedUnread?: { total_unread: number; recent_unread: number } | null, prefetchedUnread?: { total_unread: number; recent_unread: number } | null,
prefetchedUnreadReady = true, prefetchedUnreadReady = true
) { ) {
const electricClient = useElectricClient(); const electricClient = useElectricClient();

View file

@ -156,9 +156,7 @@ class NotificationsApiService {
* Get unread counts for all categories in a single request. * Get unread counts for all categories in a single request.
* Replaces 2 separate getUnreadCount calls (comments + status). * Replaces 2 separate getUnreadCount calls (comments + status).
*/ */
getBatchUnreadCounts = async ( getBatchUnreadCounts = async (searchSpaceId?: number): Promise<GetBatchUnreadCountResponse> => {
searchSpaceId?: number
): Promise<GetBatchUnreadCountResponse> => {
const params = new URLSearchParams(); const params = new URLSearchParams();
if (searchSpaceId !== undefined) { if (searchSpaceId !== undefined) {
params.append("search_space_id", String(searchSpaceId)); params.append("search_space_id", String(searchSpaceId));