Merge pull request #1419 from MODSetter/dev

Release v0.0.24: UI revamp, multi-agent parallelization, citations & HITL improvements
This commit is contained in:
Rohan Verma 2026-05-20 03:27:24 -07:00 committed by GitHub
commit 0f98480096
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
305 changed files with 9160 additions and 7069 deletions

View file

@ -372,7 +372,7 @@ test("mock iframe response", async ({ page }) => {
<html> <html>
<body> <body>
<h1>Mocked Widget</h1> <h1>Mocked Widget</h1>
<button>Mocked Button</button> <p>Mocked widget content</p>
</body> </body>
</html> </html>
`, `,

View file

@ -100,7 +100,7 @@ use: {
Usage: Usage:
```typescript ```typescript
// HTML: <button data-testid="submit-btn">Submit</button> // React: <Button data-testid="submit-btn">Submit</Button>
page.getByTestId("submit-btn"); page.getByTestId("submit-btn");
``` ```

View file

@ -549,6 +549,8 @@ Preload heavy bundles before they're needed to reduce perceived latency.
**Example: preload on hover/focus** **Example: preload on hover/focus**
```tsx ```tsx
import { Button } from '@/components/ui/button'
function EditorButton({ onClick }: { onClick: () => void }) { function EditorButton({ onClick }: { onClick: () => void }) {
const preload = () => { const preload = () => {
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
@ -557,13 +559,13 @@ function EditorButton({ onClick }: { onClick: () => void }) {
} }
return ( return (
<button <Button
onMouseEnter={preload} onMouseEnter={preload}
onFocus={preload} onFocus={preload}
onClick={onClick} onClick={onClick}
> >
Open Editor Open Editor
</button> </Button>
) )
} }
``` ```
@ -1239,11 +1241,12 @@ function StaticContent() {
**For mutations:** **For mutations:**
```tsx ```tsx
import { Button } from '@/components/ui/button'
import { useSWRMutation } from 'swr/mutation' import { useSWRMutation } from 'swr/mutation'
function UpdateButton() { function UpdateButton() {
const { trigger } = useSWRMutation('/api/user', updateUser) const { trigger } = useSWRMutation('/api/user', updateUser)
return <button onClick={() => trigger()}>Update</button> return <Button onClick={() => trigger()}>Update</Button>
} }
``` ```
@ -1369,6 +1372,8 @@ Don't subscribe to dynamic state (searchParams, localStorage) if you only read i
**Incorrect: subscribes to all searchParams changes** **Incorrect: subscribes to all searchParams changes**
```tsx ```tsx
import { Button } from '@/components/ui/button'
function ShareButton({ chatId }: { chatId: string }) { function ShareButton({ chatId }: { chatId: string }) {
const searchParams = useSearchParams() const searchParams = useSearchParams()
@ -1377,13 +1382,15 @@ function ShareButton({ chatId }: { chatId: string }) {
shareChat(chatId, { ref }) shareChat(chatId, { ref })
} }
return <button onClick={handleShare}>Share</button> return <Button onClick={handleShare}>Share</Button>
} }
``` ```
**Correct: reads on demand, no subscription** **Correct: reads on demand, no subscription**
```tsx ```tsx
import { Button } from '@/components/ui/button'
function ShareButton({ chatId }: { chatId: string }) { function ShareButton({ chatId }: { chatId: string }) {
const handleShare = () => { const handleShare = () => {
const params = new URLSearchParams(window.location.search) const params = new URLSearchParams(window.location.search)
@ -1391,7 +1398,7 @@ function ShareButton({ chatId }: { chatId: string }) {
shareChat(chatId, { ref }) shareChat(chatId, { ref })
} }
return <button onClick={handleShare}>Share</button> return <Button onClick={handleShare}>Share</Button>
} }
``` ```
@ -1549,6 +1556,8 @@ If a side effect is triggered by a specific user action (submit, click, drag), r
**Incorrect: event modeled as state + effect** **Incorrect: event modeled as state + effect**
```tsx ```tsx
import { Button } from '@/components/ui/button'
function Form() { function Form() {
const [submitted, setSubmitted] = useState(false) const [submitted, setSubmitted] = useState(false)
const theme = useContext(ThemeContext) const theme = useContext(ThemeContext)
@ -1560,13 +1569,15 @@ function Form() {
} }
}, [submitted, theme]) }, [submitted, theme])
return <button onClick={() => setSubmitted(true)}>Submit</button> return <Button onClick={() => setSubmitted(true)}>Submit</Button>
} }
``` ```
**Correct: do it in the handler** **Correct: do it in the handler**
```tsx ```tsx
import { Button } from '@/components/ui/button'
function Form() { function Form() {
const theme = useContext(ThemeContext) const theme = useContext(ThemeContext)
@ -1575,7 +1586,7 @@ function Form() {
showToast('Registered', theme) showToast('Registered', theme)
} }
return <button onClick={handleSubmit}>Submit</button> return <Button onClick={handleSubmit}>Submit</Button>
} }
``` ```

View file

@ -12,6 +12,8 @@ Preload heavy bundles before they're needed to reduce perceived latency.
**Example (preload on hover/focus):** **Example (preload on hover/focus):**
```tsx ```tsx
import { Button } from "@/components/ui/button"
function EditorButton({ onClick }: { onClick: () => void }) { function EditorButton({ onClick }: { onClick: () => void }) {
const preload = () => { const preload = () => {
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
@ -20,13 +22,13 @@ function EditorButton({ onClick }: { onClick: () => void }) {
} }
return ( return (
<button <Button
onMouseEnter={preload} onMouseEnter={preload}
onFocus={preload} onFocus={preload}
onClick={onClick} onClick={onClick}
> >
Open Editor Open Editor
</button> </Button>
) )
} }
``` ```

View file

@ -45,11 +45,12 @@ function StaticContent() {
**For mutations:** **For mutations:**
```tsx ```tsx
import { Button } from '@/components/ui/button'
import { useSWRMutation } from 'swr/mutation' import { useSWRMutation } from 'swr/mutation'
function UpdateButton() { function UpdateButton() {
const { trigger } = useSWRMutation('/api/user', updateUser) const { trigger } = useSWRMutation('/api/user', updateUser)
return <button onClick={() => trigger()}>Update</button> return <Button onClick={() => trigger()}>Update</Button>
} }
``` ```

View file

@ -12,6 +12,8 @@ Don't subscribe to dynamic state (searchParams, localStorage) if you only read i
**Incorrect (subscribes to all searchParams changes):** **Incorrect (subscribes to all searchParams changes):**
```tsx ```tsx
import { Button } from '@/components/ui/button'
function ShareButton({ chatId }: { chatId: string }) { function ShareButton({ chatId }: { chatId: string }) {
const searchParams = useSearchParams() const searchParams = useSearchParams()
@ -20,13 +22,15 @@ function ShareButton({ chatId }: { chatId: string }) {
shareChat(chatId, { ref }) shareChat(chatId, { ref })
} }
return <button onClick={handleShare}>Share</button> return <Button onClick={handleShare}>Share</Button>
} }
``` ```
**Correct (reads on demand, no subscription):** **Correct (reads on demand, no subscription):**
```tsx ```tsx
import { Button } from '@/components/ui/button'
function ShareButton({ chatId }: { chatId: string }) { function ShareButton({ chatId }: { chatId: string }) {
const handleShare = () => { const handleShare = () => {
const params = new URLSearchParams(window.location.search) const params = new URLSearchParams(window.location.search)
@ -34,6 +38,6 @@ function ShareButton({ chatId }: { chatId: string }) {
shareChat(chatId, { ref }) shareChat(chatId, { ref })
} }
return <button onClick={handleShare}>Share</button> return <Button onClick={handleShare}>Share</Button>
} }
``` ```

View file

@ -12,6 +12,8 @@ If a side effect is triggered by a specific user action (submit, click, drag), r
**Incorrect (event modeled as state + effect):** **Incorrect (event modeled as state + effect):**
```tsx ```tsx
import { Button } from '@/components/ui/button'
function Form() { function Form() {
const [submitted, setSubmitted] = useState(false) const [submitted, setSubmitted] = useState(false)
const theme = useContext(ThemeContext) const theme = useContext(ThemeContext)
@ -23,13 +25,15 @@ function Form() {
} }
}, [submitted, theme]) }, [submitted, theme])
return <button onClick={() => setSubmitted(true)}>Submit</button> return <Button onClick={() => setSubmitted(true)}>Submit</Button>
} }
``` ```
**Correct (do it in the handler):** **Correct (do it in the handler):**
```tsx ```tsx
import { Button } from '@/components/ui/button'
function Form() { function Form() {
const theme = useContext(ThemeContext) const theme = useContext(ThemeContext)
@ -38,7 +42,7 @@ function Form() {
showToast('Registered', theme) showToast('Registered', theme)
} }
return <button onClick={handleSubmit}>Submit</button> return <Button onClick={handleSubmit}>Submit</Button>
} }
``` ```

7
.gitignore vendored
View file

@ -6,16 +6,15 @@ node_modules/
.venv .venv
.pnpm-store .pnpm-store
.DS_Store .DS_Store
deepagents/
debug.log debug.log
opencode/
references/
references
# Playwright (E2E test artifacts) # Playwright (E2E test artifacts)
surfsense_web/playwright/.auth/ surfsense_web/playwright/.auth/
surfsense_web/playwright-report/ surfsense_web/playwright-report/
surfsense_web/test-results/ surfsense_web/test-results/
surfsense_web/blob-report/ surfsense_web/blob-report/
hermes-agent
hermes-agent/
content_research/ content_research/

View file

@ -1 +1 @@
0.0.23 0.0.24

View file

@ -20,6 +20,18 @@
# - Backend .env: SEARXNG_DEFAULT_HOST=http://localhost:${SEARXNG_PORT:-8888} # - Backend .env: SEARXNG_DEFAULT_HOST=http://localhost:${SEARXNG_PORT:-8888}
# - Backend .env: CELERY_BROKER_URL / REDIS_APP_URL → redis://localhost:6379/0 # - Backend .env: CELERY_BROKER_URL / REDIS_APP_URL → redis://localhost:6379/0
# - Web .env: NEXT_PUBLIC_ZERO_CACHE_URL=http://localhost:${ZERO_CACHE_PORT:-4848} # - Web .env: NEXT_PUBLIC_ZERO_CACHE_URL=http://localhost:${ZERO_CACHE_PORT:-4848}
#
# IMPORTANT — schema migrations:
# This compose file does NOT build the backend image and therefore cannot
# run a `migrations` service. You MUST run alembic on the host before
# bringing zero-cache up, or zero-cache will crash-loop with
# `Unknown or invalid publications. Specified: [zero_publication]`.
#
# First-time / after-pull:
# cd surfsense_backend && uv run alembic upgrade head
#
# The other compose files (docker-compose.yml, docker-compose.dev.yml)
# handle this automatically via a dedicated `migrations` service.
# ============================================================================= # =============================================================================
name: surfsense-deps name: surfsense-deps
@ -82,8 +94,12 @@ services:
timeout: 5s timeout: 5s
retries: 5 retries: 5
# NOTE: zero-cache requires the `zero_publication` Postgres publication to
# exist before it starts. In this deps-only stack there is no backend
# container to run migrations, so you must run `uv run alembic upgrade head`
# from `surfsense_backend/` on the host BEFORE `docker compose up -d`.
zero-cache: zero-cache:
image: rocicorp/zero:0.26.2 image: rocicorp/zero:1.4.0
ports: ports:
- "${ZERO_CACHE_PORT:-4848}:4848" - "${ZERO_CACHE_PORT:-4848}:4848"
extra_hosts: extra_hosts:

View file

@ -34,6 +34,25 @@ services:
timeout: 5s timeout: 5s
retries: 5 retries: 5
# Short-lived schema runner; see docker/docker-compose.yml `migrations`
# service for the full rationale. Builds from the same backend context as
# the dev backend/celery services.
migrations:
build: *backend-build
env_file:
- ../surfsense_backend/.env
environment:
- DATABASE_URL=${DATABASE_URL:-postgresql+asyncpg://${DB_USER:-postgres}:${DB_PASSWORD:-postgres}@${DB_HOST:-db}:${DB_PORT:-5432}/${DB_NAME:-surfsense}}
- PYTHONPATH=/app
- SERVICE_ROLE=migrate
- MIGRATION_TIMEOUT=${MIGRATION_TIMEOUT:-900}
volumes:
- zero_init:/zero-init
depends_on:
db:
condition: service_healthy
restart: "no"
pgadmin: pgadmin:
image: dpage/pgadmin4 image: dpage/pgadmin4
ports: ports:
@ -111,8 +130,10 @@ services:
condition: service_healthy condition: service_healthy
searxng: searxng:
condition: service_healthy condition: service_healthy
migrations:
condition: service_completed_successfully
healthcheck: healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/health"] test: ["CMD", "curl", "-f", "http://localhost:8000/ready"]
interval: 15s interval: 15s
timeout: 5s timeout: 5s
retries: 30 retries: 30
@ -141,6 +162,8 @@ services:
condition: service_healthy condition: service_healthy
redis: redis:
condition: service_healthy condition: service_healthy
migrations:
condition: service_completed_successfully
backend: backend:
condition: service_healthy condition: service_healthy
@ -160,6 +183,8 @@ services:
condition: service_healthy condition: service_healthy
redis: redis:
condition: service_healthy condition: service_healthy
migrations:
condition: service_completed_successfully
celery_worker: celery_worker:
condition: service_started condition: service_started
@ -179,14 +204,16 @@ services:
# - celery_worker # - celery_worker
zero-cache: zero-cache:
image: rocicorp/zero:0.26.2 image: rocicorp/zero:1.4.0
ports: ports:
- "${ZERO_CACHE_PORT:-4848}:4848" - "${ZERO_CACHE_PORT:-4848}:4848"
extra_hosts: extra_hosts:
- "host.docker.internal:host-gateway" - "host.docker.internal:host-gateway"
depends_on: depends_on:
backend: db:
condition: service_healthy condition: service_healthy
migrations:
condition: service_completed_successfully
environment: environment:
- ZERO_UPSTREAM_DB=${ZERO_UPSTREAM_DB:-postgresql://${DB_USER:-postgres}:${DB_PASSWORD:-postgres}@${DB_HOST:-db}:${DB_PORT:-5432}/${DB_NAME:-surfsense}?sslmode=${DB_SSLMODE:-disable}} - ZERO_UPSTREAM_DB=${ZERO_UPSTREAM_DB:-postgresql://${DB_USER:-postgres}:${DB_PASSWORD:-postgres}@${DB_HOST:-db}:${DB_PORT:-5432}/${DB_NAME:-surfsense}?sslmode=${DB_SSLMODE:-disable}}
- ZERO_CVR_DB=${ZERO_CVR_DB:-postgresql://${DB_USER:-postgres}:${DB_PASSWORD:-postgres}@${DB_HOST:-db}:${DB_PORT:-5432}/${DB_NAME:-surfsense}?sslmode=${DB_SSLMODE:-disable}} - ZERO_CVR_DB=${ZERO_CVR_DB:-postgresql://${DB_USER:-postgres}:${DB_PASSWORD:-postgres}@${DB_HOST:-db}:${DB_PORT:-5432}/${DB_NAME:-surfsense}?sslmode=${DB_SSLMODE:-disable}}
@ -201,6 +228,12 @@ services:
- ZERO_MUTATE_URL=${ZERO_MUTATE_URL:-http://frontend:3000/api/zero/mutate} - ZERO_MUTATE_URL=${ZERO_MUTATE_URL:-http://frontend:3000/api/zero/mutate}
volumes: volumes:
- zero_cache_data:/data - zero_cache_data:/data
- zero_init:/zero-init
# Wrapper: see docker/docker-compose.yml `zero-cache` for rationale.
entrypoint: ["sh", "-c"]
# Pass the script as a single list element so Compose does not tokenize it.
command:
- 'if [ -f /zero-init/needs_reset ]; then echo "[zero-init] publication change detected; wiping replica file(s) under /data" && rm -f /data/zero.db /data/zero.db-shm /data/zero.db-wal && rm -f /zero-init/needs_reset; fi; exec zero-cache'
restart: unless-stopped restart: unless-stopped
healthcheck: healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:4848/keepalive"] test: ["CMD", "curl", "-f", "http://localhost:4848/keepalive"]
@ -238,3 +271,5 @@ volumes:
name: surfsense-dev-shared-temp name: surfsense-dev-shared-temp
zero_cache_data: zero_cache_data:
name: surfsense-dev-zero-cache name: surfsense-dev-zero-cache
zero_init:
name: surfsense-dev-zero-init

View file

@ -27,6 +27,28 @@ services:
timeout: 5s timeout: 5s
retries: 5 retries: 5
# Short-lived schema runner. Executes `alembic upgrade head` and verifies
# that the `zero_publication` Postgres logical-replication publication
# exists, then exits 0. Downstream services (backend, celery_*, zero-cache)
# gate on this with `condition: service_completed_successfully` so a failed
# migration halts the whole stack instead of silently producing a half-built
# system that crash-loops zero-cache on missing publications.
migrations:
image: ghcr.io/modsetter/surfsense-backend:${SURFSENSE_VERSION:-latest}
env_file:
- .env
environment:
DATABASE_URL: ${DATABASE_URL:-postgresql+asyncpg://${DB_USER:-surfsense}:${DB_PASSWORD:-surfsense}@${DB_HOST:-db}:${DB_PORT:-5432}/${DB_NAME:-surfsense}}
PYTHONPATH: /app
SERVICE_ROLE: migrate
MIGRATION_TIMEOUT: ${MIGRATION_TIMEOUT:-900}
volumes:
- zero_init:/zero-init
depends_on:
db:
condition: service_healthy
restart: "no"
redis: redis:
image: redis:8-alpine image: redis:8-alpine
volumes: volumes:
@ -88,9 +110,11 @@ services:
condition: service_healthy condition: service_healthy
searxng: searxng:
condition: service_healthy condition: service_healthy
migrations:
condition: service_completed_successfully
restart: unless-stopped restart: unless-stopped
healthcheck: healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/health"] test: ["CMD", "curl", "-f", "http://localhost:8000/ready"]
interval: 15s interval: 15s
timeout: 5s timeout: 5s
retries: 30 retries: 30
@ -118,6 +142,8 @@ services:
condition: service_healthy condition: service_healthy
redis: redis:
condition: service_healthy condition: service_healthy
migrations:
condition: service_completed_successfully
backend: backend:
condition: service_healthy condition: service_healthy
labels: labels:
@ -140,6 +166,8 @@ services:
condition: service_healthy condition: service_healthy
redis: redis:
condition: service_healthy condition: service_healthy
migrations:
condition: service_completed_successfully
celery_worker: celery_worker:
condition: service_started condition: service_started
labels: labels:
@ -163,7 +191,7 @@ services:
# restart: unless-stopped # restart: unless-stopped
zero-cache: zero-cache:
image: rocicorp/zero:0.26.2 image: rocicorp/zero:1.4.0
ports: ports:
- "${ZERO_CACHE_PORT:-5929}:4848" - "${ZERO_CACHE_PORT:-5929}:4848"
extra_hosts: extra_hosts:
@ -182,10 +210,21 @@ services:
ZERO_MUTATE_URL: ${ZERO_MUTATE_URL:-http://frontend:3000/api/zero/mutate} ZERO_MUTATE_URL: ${ZERO_MUTATE_URL:-http://frontend:3000/api/zero/mutate}
volumes: volumes:
- zero_cache_data:/data - zero_cache_data:/data
- zero_init:/zero-init
# Wrapper: if the migrations service flagged a publication change via
# /zero-init/needs_reset, wipe the SQLite replica before starting so
# zero-cache does a clean initial sync. Recovers from the half-built
# replica state (`_zero.tableMetadata` missing) caused by earlier crashes.
entrypoint: ["sh", "-c"]
# Pass the script as a single list element so Compose does not tokenize it.
command:
- 'if [ -f /zero-init/needs_reset ]; then echo "[zero-init] publication change detected; wiping replica file(s) under /data" && rm -f /data/zero.db /data/zero.db-shm /data/zero.db-wal && rm -f /zero-init/needs_reset; fi; exec zero-cache'
restart: unless-stopped restart: unless-stopped
depends_on: depends_on:
backend: db:
condition: service_healthy condition: service_healthy
migrations:
condition: service_completed_successfully
healthcheck: healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:4848/keepalive"] test: ["CMD", "curl", "-f", "http://localhost:4848/keepalive"]
interval: 10s interval: 10s
@ -221,3 +260,5 @@ volumes:
name: surfsense-shared-temp name: surfsense-shared-temp
zero_cache_data: zero_cache_data:
name: surfsense-zero-cache name: surfsense-zero-cache
zero_init:
name: surfsense-zero-init

View file

@ -97,6 +97,161 @@ function Wait-ForPostgres {
Write-Ok "PostgreSQL is ready." Write-Ok "PostgreSQL is ready."
} }
# ── Stack health helpers ────────────────────────────────────────────────────
function Get-ComposeServices {
Push-Location $InstallDir
try {
$raw = Invoke-NativeSafe { docker compose ps -a --format json 2>$null }
} finally {
Pop-Location
}
if ([string]::IsNullOrWhiteSpace($raw)) { return @() }
# Compose v2.21+ emits a JSON array; older versions emit one object per line.
try {
$parsed = $raw | ConvertFrom-Json
if ($parsed -is [System.Collections.IEnumerable] -and -not ($parsed -is [string])) {
return @($parsed)
}
return @($parsed)
} catch {
$services = @()
foreach ($line in ($raw -split "`r?`n")) {
$line = $line.Trim()
if (-not $line) { continue }
try { $services += ($line | ConvertFrom-Json) } catch { }
}
return $services
}
}
function Wait-StackHealthy {
param([int]$TimeoutSec = 300)
$deadline = (Get-Date).AddSeconds($TimeoutSec)
$lastReport = ""
while ((Get-Date) -lt $deadline) {
$services = Get-ComposeServices
if (-not $services -or $services.Count -eq 0) {
Start-Sleep -Seconds 3
continue
}
$bad = @()
$waiting = @()
$good = @()
foreach ($svc in $services) {
$name = $svc.Service
$state = $svc.State
$health = if ($svc.PSObject.Properties.Name -contains 'Health') { $svc.Health } else { '' }
$exit = if ($svc.PSObject.Properties.Name -contains 'ExitCode') { $svc.ExitCode } else { $null }
if ($name -eq 'migrations') {
if ($state -eq 'exited' -and $exit -eq 0) { $good += $name }
elseif ($state -eq 'exited') { $bad += "${name} (exit=${exit})" }
else { $waiting += "${name} (${state})" }
continue
}
if ($state -eq 'running') {
if ([string]::IsNullOrEmpty($health) -or $health -eq 'healthy') {
$good += $name
} elseif ($health -eq 'starting') {
$waiting += "${name} (starting)"
} elseif ($health -eq 'unhealthy') {
$bad += "${name} (unhealthy)"
} else {
$waiting += "${name} (${health})"
}
} elseif ($state -eq 'restarting') {
$bad += "${name} (restarting)"
} elseif ($state -eq 'exited') {
$bad += "${name} (exited, code=${exit})"
} else {
$waiting += "${name} (${state})"
}
}
if ($bad.Count -gt 0) {
return @{ Ok = $false; Reason = 'failure'; Bad = $bad; Waiting = $waiting; Good = $good }
}
if ($waiting.Count -eq 0) {
return @{ Ok = $true; Reason = 'all_healthy'; Good = $good }
}
$report = "Waiting on: " + ($waiting -join ', ')
if ($report -ne $lastReport) {
Write-Info $report
$lastReport = $report
}
Start-Sleep -Seconds 5
}
return @{ Ok = $false; Reason = 'timeout'; Bad = $bad; Waiting = $waiting; Good = $good }
}
function Test-StaleZeroCacheVolume {
$raw = Invoke-NativeSafe { docker volume ls --format '{{.Name}}' 2>$null }
if ([string]::IsNullOrWhiteSpace($raw)) { return $false }
$names = $raw -split "`r?`n" | ForEach-Object { $_.Trim() } | Where-Object { $_ }
$hasZeroCache = $names -contains 'surfsense-zero-cache'
$hasZeroInit = $names -contains 'surfsense-zero-init'
# Pre-fix installs created surfsense-zero-cache but never surfsense-zero-init.
# Such a volume may hold a half-initialized SQLite replica from an earlier
# crash-loop. Wiping it forces zero-cache to do a fresh initial sync.
return ($hasZeroCache -and -not $hasZeroInit)
}
function Invoke-StaleZeroCacheCleanup {
if (-not (Test-StaleZeroCacheVolume)) { return }
Write-Warn "Detected pre-existing 'surfsense-zero-cache' volume from an install that"
Write-Warn "predates the migrations-service fix. It may contain a half-initialized"
Write-Warn "SQLite replica that would block zero-cache from starting."
Write-Warn "The volume will be removed in 5 seconds; press Ctrl+C to cancel."
Start-Sleep -Seconds 5
Push-Location $InstallDir
Invoke-NativeSafe { docker compose down --remove-orphans 2>$null } | Out-Null
Pop-Location
Invoke-NativeSafe { docker volume rm surfsense-zero-cache 2>$null } | Out-Null
Write-Ok "Removed surfsense-zero-cache volume; zero-cache will re-sync on next start."
}
function Write-Err-NoExit {
param([string]$Message)
Write-Host "[ERROR] $Message" -ForegroundColor Red
}
function Invoke-StackFailureReport {
param([hashtable]$Result)
Write-Host ""
Write-Err-NoExit "Stack did not reach a healthy state."
if ($Result.Bad.Count -gt 0) { Write-Host (" Failed: " + ($Result.Bad -join ', ')) }
if ($Result.Waiting.Count -gt 0) { Write-Host (" Stuck: " + ($Result.Waiting -join ', ')) }
Write-Host ""
Write-Info "Recent logs from migrations / zero-cache / backend:"
Push-Location $InstallDir
try {
Invoke-NativeSafe { docker compose logs --tail=60 migrations zero-cache backend 2>&1 } | Write-Host
} finally {
Pop-Location
}
Write-Host ""
Write-Host "Recovery hints:" -ForegroundColor Yellow
Write-Host " 1. Inspect migrations: cd $InstallDir; docker compose logs migrations"
Write-Host " 2. Verify publication: cd $InstallDir; docker compose exec db psql -U surfsense -d surfsense -c 'SELECT pubname FROM pg_publication;'"
Write-Host " 3. Hard reset zero db: cd $InstallDir; docker compose down; docker volume rm surfsense-zero-cache; docker compose up -d"
Write-Host ""
exit 1
}
# ── Download files ────────────────────────────────────────────────────────── # ── Download files ──────────────────────────────────────────────────────────
Write-Step "Downloading SurfSense files" Write-Step "Downloading SurfSense files"
@ -191,6 +346,8 @@ if (-not (Test-Path $envPath)) {
# ── Start containers ──────────────────────────────────────────────────────── # ── Start containers ────────────────────────────────────────────────────────
Invoke-StaleZeroCacheCleanup
if ($MigrationMode) { if ($MigrationMode) {
$envContent = Get-Content $envPath $envContent = Get-Content $envPath
$DbUser = ($envContent | Select-String '^DB_USER=' | ForEach-Object { ($_ -split '=',2)[1].Trim('"') }) | Select-Object -First 1 $DbUser = ($envContent | Select-String '^DB_USER=' | ForEach-Object { ($_ -split '=',2)[1].Trim('"') }) | Select-Object -First 1
@ -251,7 +408,13 @@ if ($MigrationMode) {
Push-Location $InstallDir Push-Location $InstallDir
Invoke-NativeSafe { docker compose up -d } Invoke-NativeSafe { docker compose up -d }
Pop-Location Pop-Location
Write-Ok "All services started." Write-Ok "All containers started; waiting for stack to become healthy..."
$waitResult = Wait-StackHealthy -TimeoutSec 300
if (-not $waitResult.Ok) {
Invoke-StackFailureReport -Result $waitResult
}
Write-Ok "All services healthy."
Remove-Item $KeyFile -ErrorAction SilentlyContinue Remove-Item $KeyFile -ErrorAction SilentlyContinue
@ -260,7 +423,13 @@ if ($MigrationMode) {
Push-Location $InstallDir Push-Location $InstallDir
Invoke-NativeSafe { docker compose up -d } Invoke-NativeSafe { docker compose up -d }
Pop-Location Pop-Location
Write-Ok "All services started." Write-Ok "All containers started; waiting for stack to become healthy..."
$waitResult = Wait-StackHealthy -TimeoutSec 300
if (-not $waitResult.Ok) {
Invoke-StackFailureReport -Result $waitResult
}
Write-Ok "All services healthy."
} }
# ── Watchtower (auto-update) ──────────────────────────────────────────────── # ── Watchtower (auto-update) ────────────────────────────────────────────────

View file

@ -97,6 +97,163 @@ wait_for_pg() {
success "PostgreSQL is ready." success "PostgreSQL is ready."
} }
# ── Stack health helpers ─────────────────────────────────────────────────────
# Enumerate compose services for project `surfsense` as `service|state|health|exitcode`
# lines. Uses `docker inspect` so we don't depend on `jq`, `python3`, or the
# exact ordering of fields in `docker compose ps --format json` output.
get_compose_services() {
local containers
containers=$(docker ps -a --filter "label=com.docker.compose.project=surfsense" --format '{{.Names}}' 2>/dev/null) || true
[[ -z "$containers" ]] && return 0
while IFS= read -r container; do
[[ -z "$container" ]] && continue
local svc state health code
svc=$(docker inspect -f '{{index .Config.Labels "com.docker.compose.service"}}' "$container" 2>/dev/null || echo "")
state=$(docker inspect -f '{{.State.Status}}' "$container" 2>/dev/null || echo "unknown")
health=$(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{end}}' "$container" 2>/dev/null || echo "")
code=$(docker inspect -f '{{.State.ExitCode}}' "$container" 2>/dev/null || echo "")
[[ -z "$svc" ]] && continue
printf '%s|%s|%s|%s\n' "$svc" "$state" "$health" "$code"
done <<< "$containers"
}
# Globals populated by wait_stack_healthy / consumed by stack_failure_report.
STACK_BAD=()
STACK_WAITING=()
STACK_GOOD=()
STACK_TIMEOUT=false
wait_stack_healthy() {
local timeout_sec=${1:-300}
local deadline=$(($(date +%s) + timeout_sec))
local last_report=""
local bad=()
local waiting=()
local good=()
while [[ $(date +%s) -lt $deadline ]]; do
local lines
lines=$(get_compose_services)
if [[ -z "$lines" ]]; then
sleep 3
continue
fi
bad=()
waiting=()
good=()
while IFS='|' read -r name state health code; do
[[ -z "$name" ]] && continue
if [[ "$name" == "migrations" ]]; then
if [[ "$state" == "exited" && "$code" == "0" ]]; then
good+=("$name")
elif [[ "$state" == "exited" ]]; then
bad+=("${name} (exit=${code})")
else
waiting+=("${name} (${state})")
fi
continue
fi
if [[ "$state" == "running" ]]; then
if [[ -z "$health" || "$health" == "healthy" ]]; then
good+=("$name")
elif [[ "$health" == "starting" ]]; then
waiting+=("${name} (starting)")
elif [[ "$health" == "unhealthy" ]]; then
bad+=("${name} (unhealthy)")
else
waiting+=("${name} (${health})")
fi
elif [[ "$state" == "restarting" ]]; then
bad+=("${name} (restarting)")
elif [[ "$state" == "exited" ]]; then
bad+=("${name} (exited, code=${code})")
else
waiting+=("${name} (${state})")
fi
done <<< "$lines"
if (( ${#bad[@]} > 0 )); then
STACK_BAD=("${bad[@]}")
STACK_WAITING=("${waiting[@]}")
STACK_GOOD=("${good[@]}")
return 1
fi
if (( ${#waiting[@]} == 0 )); then
STACK_GOOD=("${good[@]}")
return 0
fi
local report="Waiting on: ${waiting[*]}"
if [[ "$report" != "$last_report" ]]; then
info "$report"
last_report="$report"
fi
sleep 5
done
# bad/waiting/good are declared at function scope so referencing them is
# safe even if the polling loop never executed its body.
STACK_BAD=()
[[ ${#bad[@]} -gt 0 ]] && STACK_BAD=("${bad[@]}")
STACK_WAITING=()
[[ ${#waiting[@]} -gt 0 ]] && STACK_WAITING=("${waiting[@]}")
STACK_GOOD=()
[[ ${#good[@]} -gt 0 ]] && STACK_GOOD=("${good[@]}")
STACK_TIMEOUT=true
return 1
}
stack_failure_report() {
echo ""
echo -e "\033[31m[ERROR]\033[0m Stack did not reach a healthy state."
if (( ${#STACK_BAD[@]} > 0 )) && [[ -n "${STACK_BAD[0]}" ]]; then
echo " Failed: ${STACK_BAD[*]}"
fi
if (( ${#STACK_WAITING[@]} > 0 )) && [[ -n "${STACK_WAITING[0]}" ]]; then
echo " Stuck: ${STACK_WAITING[*]}"
fi
echo ""
info "Recent logs from migrations / zero-cache / backend:"
(cd "${INSTALL_DIR}" && ${DC} logs --tail=60 migrations zero-cache backend 2>&1) || true
echo ""
echo "Recovery hints:"
echo " 1. Inspect migrations: cd ${INSTALL_DIR} && ${DC} logs migrations"
echo " 2. Verify publication: cd ${INSTALL_DIR} && ${DC} exec db psql -U surfsense -d surfsense -c 'SELECT pubname FROM pg_publication;'"
echo " 3. Hard reset zero db: cd ${INSTALL_DIR} && ${DC} down && docker volume rm surfsense-zero-cache && ${DC} up -d"
echo ""
exit 1
}
# True if `surfsense-zero-cache` exists but `surfsense-zero-init` does not.
# That signals an install that predates the migrations-service fix; the old
# replica may be half-initialized and would block zero-cache on next start.
test_stale_zero_cache_volume() {
local has_zc has_zi
has_zc=$(docker volume ls --format '{{.Name}}' 2>/dev/null | grep -Fx 'surfsense-zero-cache' || true)
has_zi=$(docker volume ls --format '{{.Name}}' 2>/dev/null | grep -Fx 'surfsense-zero-init' || true)
[[ -n "$has_zc" && -z "$has_zi" ]]
}
invoke_stale_zero_cache_cleanup() {
if ! test_stale_zero_cache_volume; then
return 0
fi
warn "Detected pre-existing 'surfsense-zero-cache' volume from an install that"
warn "predates the migrations-service fix. It may contain a half-initialized"
warn "SQLite replica that would block zero-cache from starting."
warn "The volume will be removed in 5 seconds; press Ctrl+C to cancel."
sleep 5
(cd "${INSTALL_DIR}" && ${DC} down --remove-orphans 2>/dev/null) || true
docker volume rm surfsense-zero-cache 2>/dev/null || true
success "Removed surfsense-zero-cache volume; zero-cache will re-sync on next start."
}
# ── Download files ─────────────────────────────────────────────────────────── # ── Download files ───────────────────────────────────────────────────────────
step "Downloading SurfSense files" step "Downloading SurfSense files"
@ -186,6 +343,8 @@ fi
# ── Start containers ───────────────────────────────────────────────────────── # ── Start containers ─────────────────────────────────────────────────────────
invoke_stale_zero_cache_cleanup
if $MIGRATION_MODE; then if $MIGRATION_MODE; then
# Read DB credentials from .env (fall back to defaults from docker-compose.yml) # Read DB credentials from .env (fall back to defaults from docker-compose.yml)
DB_USER=$(grep '^DB_USER=' "${INSTALL_DIR}/.env" 2>/dev/null | cut -d= -f2 | tr -d '"' | head -1 || true) DB_USER=$(grep '^DB_USER=' "${INSTALL_DIR}/.env" 2>/dev/null | cut -d= -f2 | tr -d '"' | head -1 || true)
@ -243,7 +402,12 @@ if $MIGRATION_MODE; then
step "Starting all SurfSense services" step "Starting all SurfSense services"
(cd "${INSTALL_DIR}" && ${DC} up -d) < /dev/null (cd "${INSTALL_DIR}" && ${DC} up -d) < /dev/null
success "All services started." success "All containers started; waiting for stack to become healthy..."
if ! wait_stack_healthy 300; then
stack_failure_report
fi
success "All services healthy."
# Key file is no longer needed — SECRET_KEY is now in .env # Key file is no longer needed — SECRET_KEY is now in .env
rm -f "${KEY_FILE}" rm -f "${KEY_FILE}"
@ -251,7 +415,12 @@ if $MIGRATION_MODE; then
else else
step "Starting SurfSense" step "Starting SurfSense"
(cd "${INSTALL_DIR}" && ${DC} up -d) < /dev/null (cd "${INSTALL_DIR}" && ${DC} up -d) < /dev/null
success "All services started." success "All containers started; waiting for stack to become healthy..."
if ! wait_stack_healthy 300; then
stack_failure_report
fi
success "All services healthy."
fi fi
# ── Watchtower (auto-update) ───────────────────────────────────────────────── # ── Watchtower (auto-update) ─────────────────────────────────────────────────

View file

@ -167,10 +167,14 @@ COPY scripts/docker/entrypoint.sh /app/scripts/docker/entrypoint.sh
RUN dos2unix /app/scripts/docker/entrypoint.sh && chmod +x /app/scripts/docker/entrypoint.sh RUN dos2unix /app/scripts/docker/entrypoint.sh && chmod +x /app/scripts/docker/entrypoint.sh
# SERVICE_ROLE controls which process this container runs: # SERVICE_ROLE controls which process this container runs:
# api FastAPI backend only (runs migrations on startup) # migrate Run alembic upgrade head, verify zero_publication exists, exit 0.
# Used by the dedicated `migrations` service in docker-compose.yml
# so downstream services gate on `service_completed_successfully`.
# api FastAPI backend only (does NOT run migrations)
# worker Celery worker only # worker Celery worker only
# beat Celery beat scheduler only # beat Celery beat scheduler only
# all All three (legacy / dev default) # all migrations + api + worker + beat (legacy / dev default;
# fails fast on migration error)
ENV SERVICE_ROLE=all ENV SERVICE_ROLE=all
# Celery worker tuning (only used when SERVICE_ROLE=worker or all) # Celery worker tuning (only used when SERVICE_ROLE=worker or all)

View file

@ -5,6 +5,17 @@ queries via Zero, instead of replicating all tables in public schema.
See: https://zero.rocicorp.dev/docs/zero-cache-config#app-publications See: https://zero.rocicorp.dev/docs/zero-cache-config#app-publications
NOTE for future migration authors: this is the ONLY migration allowed
to use bare ``CREATE PUBLICATION``. All subsequent mutations of
``zero_publication`` MUST use the ``COMMENT ON PUBLICATION`` bookend
pattern wrapping an ``ALTER PUBLICATION ... SET TABLE`` -- copy the
``upgrade()`` function from migration
``143_force_zero_publication_resync.py`` as your starting template.
Raw ``DROP``/``CREATE PUBLICATION`` in new migrations would
re-introduce bug #1355 (zero-cache stuck on a stale replica snapshot
because Zero >= 1.0's change-streamer never sees the schema-change
event).
Revision ID: 116 Revision ID: 116
Revises: 115 Revises: 115
""" """

View file

@ -17,6 +17,16 @@ IMPORTANT — before AND after running this migration:
3. Delete / reset the zero-cache data volume 3. Delete / reset the zero-cache data volume
4. Restart zero-cache (it will do a fresh initial sync) 4. Restart zero-cache (it will do a fresh initial sync)
DO NOT COPY THIS PATTERN. The ``DROP PUBLICATION`` + ``CREATE
PUBLICATION`` dance below is the pre-#1355 anti-pattern: on Zero >=
1.0 it does not reliably wake the zero-cache change-streamer and can
leave the replica pinned to a stale snapshot. This file is
grandfathered in because it has already shipped to users; new
publication mutations MUST use the ``COMMENT ON PUBLICATION`` bookend
pattern wrapping an ``ALTER PUBLICATION ... SET TABLE`` -- copy the
``upgrade()`` function from migration
``143_force_zero_publication_resync.py`` as your starting template.
Revision ID: 117 Revision ID: 117
Revises: 116 Revises: 116
""" """

View file

@ -1,5 +1,16 @@
"""Add LOCAL_FOLDER_FILE document type, folder metadata, and document_versions table """Add LOCAL_FOLDER_FILE document type, folder metadata, and document_versions table
DO NOT COPY THIS PATTERN. The bare ``ALTER PUBLICATION ... ADD/DROP
TABLE`` calls below pre-date the ``COMMENT ON PUBLICATION`` bookend
fix for bug #1355: on Zero >= 1.0 they do not reliably wake the
zero-cache change-streamer and can leave the replica pinned to a
stale snapshot. This file is grandfathered in because it has already
shipped to users; new publication mutations MUST use the
``COMMENT ON PUBLICATION`` bookend pattern wrapping an
``ALTER PUBLICATION ... SET TABLE`` -- copy the ``upgrade()`` function
from migration ``143_force_zero_publication_resync.py`` as your
starting template.
Revision ID: 118 Revision ID: 118
Revises: 117 Revises: 117
""" """

View file

@ -21,6 +21,16 @@ IMPORTANT - before AND after running this migration:
3. Delete / reset the zero-cache data volume 3. Delete / reset the zero-cache data volume
4. Restart zero-cache (it will do a fresh initial sync) 4. Restart zero-cache (it will do a fresh initial sync)
DO NOT COPY THIS PATTERN. The ``DROP PUBLICATION`` + ``CREATE
PUBLICATION`` dance below is the pre-#1355 anti-pattern: on Zero >=
1.0 it does not reliably wake the zero-cache change-streamer and can
leave the replica pinned to a stale snapshot. This file is
grandfathered in because it has already shipped to users; new
publication mutations MUST use the ``COMMENT ON PUBLICATION`` bookend
pattern wrapping an ``ALTER PUBLICATION ... SET TABLE`` -- copy the
``upgrade()`` function from migration
``143_force_zero_publication_resync.py`` as your starting template.
Revision ID: 139 Revision ID: 139
Revises: 138 Revises: 138
""" """

View file

@ -32,6 +32,16 @@ Skipping the zero-cache stop will deadlock at the ACCESS EXCLUSIVE LOCK on
"user". Skipping the data-volume reset will leave IndexedDB clients seeing "user". Skipping the data-volume reset will leave IndexedDB clients seeing
column-not-found errors from a stale catalog snapshot. column-not-found errors from a stale catalog snapshot.
DO NOT COPY THIS PATTERN. The ``DROP PUBLICATION`` + ``CREATE
PUBLICATION`` dance below is the pre-#1355 anti-pattern: on Zero >=
1.0 it does not reliably wake the zero-cache change-streamer and can
leave the replica pinned to a stale snapshot. This file is
grandfathered in because it has already shipped to users; new
publication mutations MUST use the ``COMMENT ON PUBLICATION`` bookend
pattern wrapping an ``ALTER PUBLICATION ... SET TABLE`` -- copy the
``upgrade()`` function from migration
``143_force_zero_publication_resync.py`` as your starting template.
Revision ID: 140 Revision ID: 140
Revises: 139 Revises: 139
""" """

View file

@ -0,0 +1,142 @@
"""force zero-cache to resync after upgrading to Zero >= 1.0
Re-emits the current ``zero_publication`` shape using
``ALTER PUBLICATION ... SET TABLE`` wrapped in
``COMMENT ON PUBLICATION`` bookends. This is the publication-change
hook documented for Zero ``>=1.0``:
https://zero.rocicorp.dev/docs/connecting-to-postgres#publication-changes
Background
----------
Migrations 117 / 139 / 140 mutated ``zero_publication`` using
``DROP PUBLICATION`` + ``CREATE PUBLICATION``. On Zero 0.26.2 that
sequence did not reliably wake the zero-cache change-streamer, so
affected installs ended up with a SQLite replica file (in the
``surfsense-zero-cache`` volume) that was snapshotted against the
pre-``user`` publication. The frontend Zero schema includes a
``userTable`` query, which then failed with
``SchemaVersionNotSupported`` and triggered the default
``onUpdateNeeded`` -> ``location.reload()`` every WebSocket keepalive
interval (~60s). See bug #1355.
This migration emits the canonical publication shape one more time,
this time using a pattern that fires Postgres event triggers and
Zero's schema-change hook. With ``ZERO_AUTO_RESET=true`` (the default)
and Zero ``>=1.0``, zero-cache responds by wiping its replica and
doing a fresh initial sync from the corrected publication.
The publication shape itself is unchanged versus migration 140 -- on
installs whose replica is already correct, this is a no-op aside
from the harmless event-trigger fire.
Revision ID: 143
Revises: 142
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "143"
down_revision: str | None = "142"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
PUBLICATION_NAME = "zero_publication"
# Must stay in sync with the column lists in migrations 117 / 139 / 140.
DOCUMENT_COLS = [
"id",
"title",
"document_type",
"search_space_id",
"folder_id",
"created_by_id",
"status",
"created_at",
"updated_at",
]
USER_COLS = [
"id",
"pages_limit",
"pages_used",
"premium_credit_micros_limit",
"premium_credit_micros_used",
]
def _has_zero_version(conn, table: str) -> bool:
return (
conn.execute(
sa.text(
"SELECT 1 FROM information_schema.columns "
"WHERE table_name = :tbl AND column_name = '_0_version'"
),
{"tbl": table},
).fetchone()
is not None
)
def _build_set_table_ddl(
*, documents_has_zero_ver: bool, user_has_zero_ver: bool
) -> str:
doc_cols = DOCUMENT_COLS + (['"_0_version"'] if documents_has_zero_ver else [])
user_cols = USER_COLS + (['"_0_version"'] if user_has_zero_ver else [])
doc_col_list = ", ".join(doc_cols)
user_col_list = ", ".join(user_cols)
return (
f"ALTER PUBLICATION {PUBLICATION_NAME} SET TABLE "
f"notifications, "
f"documents ({doc_col_list}), "
f"folders, "
f"search_source_connectors, "
f"new_chat_messages, "
f"chat_comments, "
f"chat_session_state, "
f'"user" ({user_col_list})'
)
def upgrade() -> None:
conn = op.get_bind()
exists = conn.execute(
sa.text("SELECT 1 FROM pg_publication WHERE pubname = :name"),
{"name": PUBLICATION_NAME},
).fetchone()
if not exists:
return
documents_has_zero_ver = _has_zero_version(conn, "documents")
user_has_zero_ver = _has_zero_version(conn, "user")
# The COMMENT-ALTER-COMMENT trio MUST run in a single transaction so
# Zero observes them as one schema-change event. Alembic's outer
# transaction already covers us, but a SAVEPOINT keeps the trio
# atomic with asyncpg, matching the pattern used in migrations
# 117 / 139 / 140.
tx = conn.begin_nested() if conn.in_transaction() else conn.begin()
with tx:
conn.execute(
sa.text(f"COMMENT ON PUBLICATION {PUBLICATION_NAME} IS 'pre-143-resync'")
)
conn.execute(
sa.text(
_build_set_table_ddl(
documents_has_zero_ver=documents_has_zero_ver,
user_has_zero_ver=user_has_zero_ver,
)
)
)
conn.execute(
sa.text(f"COMMENT ON PUBLICATION {PUBLICATION_NAME} IS 'post-143-resync'")
)
def downgrade() -> None:
"""No-op. The publication shape is unchanged versus migration 140."""

View file

@ -9,6 +9,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.db import SurfsenseDocsChunk, SurfsenseDocsDocument from app.db import SurfsenseDocsChunk, SurfsenseDocsDocument
from app.utils.document_converters import embed_text from app.utils.document_converters import embed_text
from app.utils.surfsense_docs import surfsense_docs_public_url
def format_surfsense_docs_results(results: list[tuple]) -> str: def format_surfsense_docs_results(results: list[tuple]) -> str:
@ -19,13 +20,14 @@ def format_surfsense_docs_results(results: list[tuple]) -> str:
# Group chunks by document # Group chunks by document
grouped: dict[int, dict] = {} grouped: dict[int, dict] = {}
for chunk, doc in results: for chunk, doc in results:
public_url = surfsense_docs_public_url(doc.source)
if doc.id not in grouped: if doc.id not in grouped:
grouped[doc.id] = { grouped[doc.id] = {
"document_id": f"doc-{doc.id}", "document_id": f"doc-{doc.id}",
"document_type": "SURFSENSE_DOCS", "document_type": "SURFSENSE_DOCS",
"title": doc.title, "title": doc.title,
"url": doc.source, "url": public_url,
"metadata": {"source": doc.source}, "metadata": {"source": doc.source, "public_url": public_url},
"chunks": [], "chunks": [],
} }
grouped[doc.id]["chunks"].append( grouped[doc.id]["chunks"].append(

View file

@ -17,6 +17,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.db import SurfsenseDocsChunk, SurfsenseDocsDocument, async_session_maker from app.db import SurfsenseDocsChunk, SurfsenseDocsDocument, async_session_maker
from app.utils.document_converters import embed_text from app.utils.document_converters import embed_text
from app.utils.surfsense_docs import surfsense_docs_public_url
def format_surfsense_docs_results(results: list[tuple]) -> str: def format_surfsense_docs_results(results: list[tuple]) -> str:
@ -40,13 +41,14 @@ def format_surfsense_docs_results(results: list[tuple]) -> str:
# Group chunks by document # Group chunks by document
grouped: dict[int, dict] = {} grouped: dict[int, dict] = {}
for chunk, doc in results: for chunk, doc in results:
public_url = surfsense_docs_public_url(doc.source)
if doc.id not in grouped: if doc.id not in grouped:
grouped[doc.id] = { grouped[doc.id] = {
"document_id": f"doc-{doc.id}", "document_id": f"doc-{doc.id}",
"document_type": "SURFSENSE_DOCS", "document_type": "SURFSENSE_DOCS",
"title": doc.title, "title": doc.title,
"url": doc.source, "url": public_url,
"metadata": {"source": doc.source}, "metadata": {"source": doc.source, "public_url": public_url},
"chunks": [], "chunks": [],
} }
grouped[doc.id]["chunks"].append( grouped[doc.id]["chunks"].append(

View file

@ -945,6 +945,36 @@ async def health_check():
return {"status": "ok"} return {"status": "ok"}
@app.get("/ready", tags=["health"])
@limiter.exempt
async def readiness_check():
"""Readiness probe.
Verifies that the schema state required by downstream services is
present. Specifically checks that the ``zero_publication`` Postgres
logical-replication publication exists; without it zero-cache crash-loops
on `Unknown or invalid publications`.
Returns 200 when ready, 503 otherwise. Used by the docker-compose
backend healthcheck and by ``install.ps1`` / ``install.sh`` post-up
verification.
"""
from sqlalchemy import text
from app.db import async_session_maker
async with async_session_maker() as session:
result = await session.execute(
text("SELECT 1 FROM pg_publication WHERE pubname = 'zero_publication'")
)
if result.first() is None:
raise HTTPException(
status_code=503,
detail="zero_publication missing; run alembic upgrade head",
)
return {"status": "ready"}
@app.get("/verify-token") @app.get("/verify-token")
async def authenticated_route( async def authenticated_route(
user: User = Depends(current_active_user), user: User = Depends(current_active_user),

View file

@ -24,6 +24,7 @@ from app.schemas.surfsense_docs import (
SurfsenseDocsDocumentWithChunksRead, SurfsenseDocsDocumentWithChunksRead,
) )
from app.users import current_active_user from app.users import current_active_user
from app.utils.surfsense_docs import surfsense_docs_public_url
router = APIRouter() router = APIRouter()
@ -76,6 +77,7 @@ async def get_surfsense_doc_by_chunk_id(
id=document.id, id=document.id,
title=document.title, title=document.title,
source=document.source, source=document.source,
public_url=surfsense_docs_public_url(document.source),
content=document.content, content=document.content,
chunks=[ chunks=[
SurfsenseDocsChunkRead(id=c.id, content=c.content) SurfsenseDocsChunkRead(id=c.id, content=c.content)
@ -146,6 +148,7 @@ async def list_surfsense_docs(
id=doc.id, id=doc.id,
title=doc.title, title=doc.title,
source=doc.source, source=doc.source,
public_url=surfsense_docs_public_url(doc.source),
content=doc.content, content=doc.content,
created_at=doc.created_at, created_at=doc.created_at,
updated_at=doc.updated_at, updated_at=doc.updated_at,

View file

@ -22,6 +22,7 @@ class SurfsenseDocsDocumentRead(BaseModel):
id: int id: int
title: str title: str
source: str source: str
public_url: str
content: str content: str
created_at: datetime | None = None created_at: datetime | None = None
updated_at: datetime | None = None updated_at: datetime | None = None
@ -35,6 +36,7 @@ class SurfsenseDocsDocumentWithChunksRead(BaseModel):
id: int id: int
title: str title: str
source: str source: str
public_url: str
content: str content: str
chunks: list[SurfsenseDocsChunkRead] chunks: list[SurfsenseDocsChunkRead]

View file

@ -81,6 +81,7 @@ from app.tasks.chat.streaming.helpers.interrupt_inspector import (
) )
from app.utils.content_utils import bootstrap_history_from_db from app.utils.content_utils import bootstrap_history_from_db
from app.utils.perf import get_perf_logger, log_system_snapshot, trim_native_heap from app.utils.perf import get_perf_logger, log_system_snapshot, trim_native_heap
from app.utils.surfsense_docs import surfsense_docs_public_url
from app.utils.user_message_multimodal import build_human_message_content from app.utils.user_message_multimodal import build_human_message_content
_background_tasks: set[asyncio.Task] = set() _background_tasks: set[asyncio.Task] = set()
@ -216,14 +217,17 @@ def format_mentioned_surfsense_docs_as_context(
) )
for doc in documents: for doc in documents:
metadata_json = json.dumps({"source": doc.source}, ensure_ascii=False) public_url = surfsense_docs_public_url(doc.source)
metadata_json = json.dumps(
{"source": doc.source, "public_url": public_url}, ensure_ascii=False
)
context_parts.append("<document>") context_parts.append("<document>")
context_parts.append("<document_metadata>") context_parts.append("<document_metadata>")
context_parts.append(f" <document_id>doc-{doc.id}</document_id>") context_parts.append(f" <document_id>doc-{doc.id}</document_id>")
context_parts.append(" <document_type>SURFSENSE_DOCS</document_type>") context_parts.append(" <document_type>SURFSENSE_DOCS</document_type>")
context_parts.append(f" <title><![CDATA[{doc.title}]]></title>") context_parts.append(f" <title><![CDATA[{doc.title}]]></title>")
context_parts.append(f" <url><![CDATA[{doc.source}]]></url>") context_parts.append(f" <url><![CDATA[{public_url}]]></url>")
context_parts.append( context_parts.append(
f" <metadata_json><![CDATA[{metadata_json}]]></metadata_json>" f" <metadata_json><![CDATA[{metadata_json}]]></metadata_json>"
) )
@ -1487,14 +1491,20 @@ async def stream_new_chat(
# Resolve @-mention chips to canonical virtual paths and rewrite # Resolve @-mention chips to canonical virtual paths and rewrite
# the user-typed text so the LLM sees ``\`/documents/...\``` instead # the user-typed text so the LLM sees ``\`/documents/...\``` instead
# of bare ``@title``. The persisted user-message text keeps # of bare ``@title``. The substitution lands in ``agent_user_query``
# ``@title`` so chip rendering on reload is unchanged — see # ONLY — the original ``user_query`` (with ``@title`` tokens) flows
# ``persistence._build_user_content``. # untouched into ``persist_user_turn`` below so chip rendering on
# reload still works (``UserTextPart`` → ``parseMentionSegments``
# matches ``@title``, not ``\`/documents/...\```). It also feeds
# the human-readable surfaces — SSE "Processing X" status, auto
# thread title, memory seed — which all want what the user typed.
# See ``persistence._build_user_content``.
# #
# Cloud mode only: local-folder mode keeps the legacy # Cloud mode only: local-folder mode keeps the legacy
# ``@title`` text path; mention support there is a follow-up # ``@title`` text path; mention support there is a follow-up
# task because the path scheme (mount-rooted) and the picker # task because the path scheme (mount-rooted) and the picker
# UI both need separate work. # UI both need separate work.
agent_user_query = user_query
accepted_folder_ids: list[int] = [] accepted_folder_ids: list[int] = []
if fs_mode == FilesystemMode.CLOUD.value and ( if fs_mode == FilesystemMode.CLOUD.value and (
mentioned_document_ids mentioned_document_ids
@ -1529,11 +1539,13 @@ async def stream_new_chat(
mentioned_surfsense_doc_ids=mentioned_surfsense_doc_ids, mentioned_surfsense_doc_ids=mentioned_surfsense_doc_ids,
mentioned_folder_ids=mentioned_folder_ids, mentioned_folder_ids=mentioned_folder_ids,
) )
user_query = substitute_in_text(user_query, resolved.token_to_path) agent_user_query = substitute_in_text(user_query, resolved.token_to_path)
accepted_folder_ids = resolved.mentioned_folder_ids accepted_folder_ids = resolved.mentioned_folder_ids
# Format the user query with context (SurfSense docs + reports only) # Format the user query with context (SurfSense docs + reports only).
final_query = user_query # Uses ``agent_user_query`` so the LLM sees backtick-wrapped paths
# instead of bare ``@title`` tokens.
final_query = agent_user_query
context_parts = [] context_parts = []
if mentioned_surfsense_docs: if mentioned_surfsense_docs:
@ -1564,7 +1576,7 @@ async def stream_new_chat(
if context_parts: if context_parts:
context = "\n\n".join(context_parts) context = "\n\n".join(context_parts)
final_query = f"{context}\n\n<user_query>{user_query}</user_query>" final_query = f"{context}\n\n<user_query>{agent_user_query}</user_query>"
if visibility == ChatVisibility.SEARCH_SPACE and current_user_display_name: if visibility == ChatVisibility.SEARCH_SPACE and current_user_display_name:
final_query = f"**[{current_user_display_name}]:** {final_query}" final_query = f"**[{current_user_display_name}]:** {final_query}"

View file

@ -0,0 +1,13 @@
"""Utilities for SurfSense's built-in documentation index."""
from pathlib import PurePosixPath
DOCS_PUBLIC_ROOT = PurePosixPath("/docs")
def surfsense_docs_public_url(source: str) -> str:
"""Return the public docs route for an indexed documentation source path."""
docs_path = PurePosixPath(source).with_suffix("")
if docs_path.name == "index":
docs_path = docs_path.parent
return (DOCS_PUBLIC_ROOT / docs_path).as_posix()

View file

@ -1,6 +1,6 @@
[project] [project]
name = "surf-new-backend" name = "surf-new-backend"
version = "0.0.23" version = "0.0.24"
description = "SurfSense Backend" description = "SurfSense Backend"
requires-python = ">=3.12" requires-python = ">=3.12"
dependencies = [ dependencies = [

View file

@ -4,10 +4,15 @@ set -e
# ───────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────
# SERVICE_ROLE controls which process(es) this container runs. # SERVICE_ROLE controls which process(es) this container runs.
# #
# api FastAPI backend only (runs migrations on startup) # migrate Run `alembic upgrade head`, verify zero_publication,
# then exit 0. Used by the dedicated `migrations` service
# in docker-compose.yml so downstream services can gate
# on `condition: service_completed_successfully`.
# api FastAPI backend only (does NOT run migrations)
# worker Celery worker only # worker Celery worker only
# beat Celery beat scheduler only # beat Celery beat scheduler only
# all All three in one container (legacy / dev default) # all migrations + api + worker + beat in one container
# (legacy / dev default; fails fast on migration error)
# #
# Set SERVICE_ROLE as an environment variable in Coolify for # Set SERVICE_ROLE as an environment variable in Coolify for
# each service deployment. # each service deployment.
@ -41,7 +46,13 @@ cleanup() {
trap cleanup SIGTERM SIGINT trap cleanup SIGTERM SIGINT
# ── Database migrations (only for api / all) ───────────────── # ── Database migrations (only for migrate / all) ─────────────
# Fail-fast contract:
# - alembic upgrade head must succeed within ${MIGRATION_TIMEOUT:-900}s
# - zero_publication must exist in pg_publication afterwards
# Either failure exits non-zero so the dedicated `migrations` compose
# service exits non-zero, halting the rest of the stack instead of
# silently producing a half-built system that crash-loops zero-cache.
run_migrations() { run_migrations() {
echo "Running database migrations..." echo "Running database migrations..."
for i in {1..30}; do for i in {1..30}; do
@ -53,11 +64,66 @@ run_migrations() {
sleep 1 sleep 1
done done
if timeout 300 alembic upgrade head 2>&1; then local timeout_secs="${MIGRATION_TIMEOUT:-900}"
echo "Migrations completed successfully." echo "Running alembic upgrade head (timeout=${timeout_secs}s)..."
else if ! timeout "${timeout_secs}" alembic upgrade head; then
echo "WARNING: Migration failed or timed out. Continuing anyway..." echo "ERROR: alembic upgrade head failed (or exceeded ${timeout_secs}s timeout)." >&2
echo "You may need to run migrations manually: alembic upgrade head" echo "Refusing to start. Inspect the error above and re-run." >&2
exit 1
fi
echo "Migrations completed successfully."
echo "Verifying zero_publication exists in Postgres..."
local pub_oid
pub_oid=$(python <<'PY' 2>/dev/null || true
import asyncio
import sys
from sqlalchemy import text
from app.db import engine
async def get_oid():
async with engine.connect() as conn:
result = await conn.execute(
text("SELECT oid FROM pg_publication WHERE pubname = 'zero_publication'")
)
row = result.first()
if row is None:
sys.exit(1)
print(int(row[0]))
asyncio.run(get_oid())
PY
)
if [ -z "${pub_oid}" ]; then
echo "ERROR: zero_publication is missing from Postgres after running alembic." >&2
echo "This usually means migration 116 (or a later publication migration) did not run." >&2
echo "Inspect alembic state with:" >&2
echo " docker compose exec db psql -U \"\$DB_USER\" -d \"\$DB_NAME\" -c 'SELECT * FROM alembic_version;'" >&2
exit 1
fi
echo "zero_publication verified (oid=${pub_oid})."
# Stale-replica safety net: if /zero-init is mounted (i.e. we are the
# dedicated `migrations` compose service), drop a marker file when the
# publication oid changed (or on first run) so the wrapped zero-cache
# entrypoint can wipe /data/zero.db before starting. This recovers from
# the case where a previous zero-cache crashed mid-init and left a
# half-built SQLite replica without a `_zero.tableMetadata` table.
if [ -d /zero-init ]; then
local stored_oid=""
[ -f /zero-init/last_pub_oid ] && stored_oid=$(cat /zero-init/last_pub_oid 2>/dev/null || true)
if [ -z "${stored_oid}" ] || [ "${stored_oid}" != "${pub_oid}" ]; then
echo "Publication oid changed (stored=${stored_oid:-<none>}, current=${pub_oid}); writing /zero-init/needs_reset."
: > /zero-init/needs_reset
chmod 666 /zero-init/needs_reset 2>/dev/null || true
fi
echo "${pub_oid}" > /zero-init/last_pub_oid
chmod 666 /zero-init/last_pub_oid 2>/dev/null || true
# World-writable dir so the (possibly non-root) zero-cache container
# can `rm -f /zero-init/needs_reset` after acting on the marker.
chmod 777 /zero-init 2>/dev/null || true
fi fi
} }
@ -102,8 +168,12 @@ start_beat() {
# ── Main: run based on role ────────────────────────────────── # ── Main: run based on role ──────────────────────────────────
case "${SERVICE_ROLE}" in case "${SERVICE_ROLE}" in
api) migrate)
run_migrations run_migrations
echo "Migrations complete; exiting cleanly."
exit 0
;;
api)
start_api start_api
;; ;;
worker) worker)
@ -121,7 +191,7 @@ case "${SERVICE_ROLE}" in
start_beat start_beat
;; ;;
*) *)
echo "ERROR: Unknown SERVICE_ROLE '${SERVICE_ROLE}'. Use: api, worker, beat, or all" echo "ERROR: Unknown SERVICE_ROLE '${SERVICE_ROLE}'. Use: migrate, api, worker, beat, or all"
exit 1 exit 1
;; ;;
esac esac

View file

@ -350,6 +350,25 @@ def _drive_list_files(args: dict[str, Any]) -> dict[str, Any]:
folder id and serve the matching fixture list. folder id and serve the matching fixture list.
""" """
q = args.get("q", "") q = args.get("q", "")
if "in owners" in q:
return {
"data": {
"files": [
{
"id": "fake-file-owner-probe",
"name": "owner-probe",
"owners": [
{
"me": True,
"emailAddress": "e2e-fake@surfsense.example",
}
],
}
],
"nextPageToken": None,
}
}
folder_id = "root" folder_id = "root"
if "in parents" in q: if "in parents" in q:
# q looks like: '<folder_id>' in parents and trashed = false ... # q looks like: '<folder_id>' in parents and trashed = false ...

View file

@ -1,13 +1,11 @@
"""Composio route integration fixtures. """Composio route integration fixtures.
The sys.modules hijack happens at module import time, before importing The `composio` sys.modules hijack lives in the parent integration conftest
app.app, so production `from composio import Composio` bindings resolve to so it runs before any sibling suite imports `app.routes`.
the strict E2E fake in this pytest process too.
""" """
from __future__ import annotations from __future__ import annotations
import sys
from collections.abc import AsyncGenerator from collections.abc import AsyncGenerator
import httpx import httpx
@ -16,19 +14,15 @@ import pytest_asyncio
from httpx import ASGITransport from httpx import ASGITransport
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from tests.e2e.fakes import composio_module as _fake_composio from app.app import app, limiter
from app.config import config
sys.modules["composio"] = _fake_composio from app.db import (
from app.app import app, limiter # noqa: E402
from app.config import config # noqa: E402
from app.db import ( # noqa: E402
SearchSourceConnector, SearchSourceConnector,
SearchSourceConnectorType, SearchSourceConnectorType,
User, User,
get_async_session, get_async_session,
) )
from app.users import current_active_user # noqa: E402 from app.users import current_active_user
pytestmark = pytest.mark.integration pytestmark = pytest.mark.integration

View file

@ -1,3 +1,5 @@
import importlib
import sys
import uuid import uuid
from unittest.mock import AsyncMock, MagicMock from unittest.mock import AsyncMock, MagicMock
@ -7,17 +9,27 @@ from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
from sqlalchemy.pool import NullPool from sqlalchemy.pool import NullPool
from app.config import config as app_config # Hijack `composio` before any `from app.*` import; the `from composio import
from app.db import ( # Composio` in app.services.composio_service binds once at first import.
Base, from tests.e2e.fakes import composio_module as _fake_composio
DocumentType,
SearchSourceConnector, sys.modules["composio"] = _fake_composio
SearchSourceConnectorType,
SearchSpace, app_config = importlib.import_module("app.config").config
User, app_db = importlib.import_module("app.db")
) Base = app_db.Base
from app.indexing_pipeline.connector_document import ConnectorDocument DocumentType = app_db.DocumentType
from tests.conftest import TEST_DATABASE_URL SearchSourceConnector = app_db.SearchSourceConnector
SearchSourceConnectorType = app_db.SearchSourceConnectorType
SearchSpace = app_db.SearchSpace
User = app_db.User
ConnectorDocument = importlib.import_module(
"app.indexing_pipeline.connector_document"
).ConnectorDocument
create_default_roles_and_membership = importlib.import_module(
"app.routes.search_spaces_routes"
).create_default_roles_and_membership
TEST_DATABASE_URL = importlib.import_module("tests.conftest").TEST_DATABASE_URL
_EMBEDDING_DIM = app_config.embedding_model_instance.dimension _EMBEDDING_DIM = app_config.embedding_model_instance.dimension
@ -105,6 +117,9 @@ async def db_search_space(db_session: AsyncSession, db_user: User) -> SearchSpac
) )
db_session.add(space) db_session.add(space)
await db_session.flush() await db_session.flush()
# Mirror POST /searchspaces so routes guarded by check_permission find a membership.
await create_default_roles_and_membership(db_session, space.id, db_user.id)
await db_session.flush()
return space return space
@ -145,6 +160,10 @@ def patched_chunk_text(monkeypatch) -> MagicMock:
"app.indexing_pipeline.indexing_pipeline_service.chunk_text", "app.indexing_pipeline.indexing_pipeline_service.chunk_text",
mock, mock,
) )
monkeypatch.setattr(
"app.indexing_pipeline.indexing_pipeline_service.chunk_text_hybrid",
mock,
)
return mock return mock

View file

@ -204,6 +204,7 @@ class TestStripeCheckoutSessionCreation:
assert ( assert (
fake_client.last_params["success_url"] fake_client.last_params["success_url"]
== f"http://localhost:3000/dashboard/{search_space_id}/purchase-success" == f"http://localhost:3000/dashboard/{search_space_id}/purchase-success"
"?session_id={CHECKOUT_SESSION_ID}"
) )
assert ( assert (
fake_client.last_params["cancel_url"] fake_client.last_params["cancel_url"]

View file

@ -7,7 +7,7 @@ mocked at their system boundaries.
from __future__ import annotations from __future__ import annotations
from unittest.mock import AsyncMock, MagicMock, patch from unittest.mock import ANY, AsyncMock, MagicMock, patch
import pytest import pytest
import pytest_asyncio import pytest_asyncio
@ -25,6 +25,7 @@ pytestmark = pytest.mark.integration
_COMPOSIO_ACCOUNT_ID = "composio-calendar-test-789" _COMPOSIO_ACCOUNT_ID = "composio-calendar-test-789"
_INDEXER_MODULE = "app.tasks.connector_indexers.google_calendar_indexer" _INDEXER_MODULE = "app.tasks.connector_indexers.google_calendar_indexer"
_GET_ACCESS_TOKEN = "app.services.composio_service.ComposioService.get_access_token"
@pytest_asyncio.fixture @pytest_asyncio.fixture
@ -69,32 +70,29 @@ async def native_calendar(async_engine):
await cleanup_space(async_engine, data["search_space_id"]) await cleanup_space(async_engine, data["search_space_id"])
@patch(_GET_ACCESS_TOKEN)
@patch(f"{_INDEXER_MODULE}.TaskLoggingService") @patch(f"{_INDEXER_MODULE}.TaskLoggingService")
@patch(f"{_INDEXER_MODULE}.GoogleCalendarConnector") @patch(f"{_INDEXER_MODULE}.GoogleCalendarConnector")
@patch(f"{_INDEXER_MODULE}.build_composio_credentials") @patch(f"{_INDEXER_MODULE}.ComposioService")
async def test_composio_calendar_uses_composio_credentials( async def test_composio_calendar_uses_composio_service(
mock_build_creds, mock_composio_service_cls,
mock_cal_cls, mock_cal_cls,
mock_tl_cls, mock_tl_cls,
mock_get_access_token,
async_engine, async_engine,
composio_calendar, composio_calendar,
): ):
"""Calendar indexer calls build_composio_credentials for a Composio connector.""" """Calendar indexer uses Composio tools directly for a Composio connector."""
from app.tasks.connector_indexers.google_calendar_indexer import ( from app.tasks.connector_indexers.google_calendar_indexer import (
index_google_calendar_events, index_google_calendar_events,
) )
data = composio_calendar data = composio_calendar
mock_creds = MagicMock(name="composio-creds") mock_composio_service = MagicMock()
mock_build_creds.return_value = mock_creds mock_composio_service.get_calendar_events = AsyncMock(return_value=([], None))
mock_composio_service_cls.return_value = mock_composio_service
mock_tl_cls.return_value = mock_task_logger() mock_tl_cls.return_value = mock_task_logger()
mock_cal_instance = MagicMock()
mock_cal_instance.get_all_primary_calendar_events = AsyncMock(
return_value=([], None)
)
mock_cal_cls.return_value = mock_cal_instance
maker = make_session_factory(async_engine) maker = make_session_factory(async_engine)
async with maker() as session: async with maker() as session:
await index_google_calendar_events( await index_google_calendar_events(
@ -104,17 +102,25 @@ async def test_composio_calendar_uses_composio_credentials(
user_id=data["user_id"], user_id=data["user_id"],
) )
mock_build_creds.assert_called_once_with(_COMPOSIO_ACCOUNT_ID) mock_composio_service_cls.assert_called_once()
mock_cal_cls.assert_called_once() mock_composio_service.get_calendar_events.assert_called_once_with(
_, kwargs = mock_cal_cls.call_args connected_account_id=_COMPOSIO_ACCOUNT_ID,
assert kwargs.get("credentials") is mock_creds entity_id=f"surfsense_{data['user_id']}",
time_min=ANY,
time_max=ANY,
max_results=250,
)
mock_cal_cls.assert_not_called()
mock_get_access_token.assert_not_called()
@patch(_GET_ACCESS_TOKEN)
@patch(f"{_INDEXER_MODULE}.TaskLoggingService") @patch(f"{_INDEXER_MODULE}.TaskLoggingService")
@patch(f"{_INDEXER_MODULE}.build_composio_credentials") @patch(f"{_INDEXER_MODULE}.ComposioService")
async def test_composio_calendar_without_account_id_returns_error( async def test_composio_calendar_without_account_id_returns_error(
mock_build_creds, mock_composio_service_cls,
mock_tl_cls, mock_tl_cls,
mock_get_access_token,
async_engine, async_engine,
composio_calendar_no_id, composio_calendar_no_id,
): ):
@ -138,20 +144,23 @@ async def test_composio_calendar_without_account_id_returns_error(
assert count == 0 assert count == 0
assert error is not None assert error is not None
assert "composio" in error.lower() assert "composio" in error.lower()
mock_build_creds.assert_not_called() mock_composio_service_cls.assert_not_called()
mock_get_access_token.assert_not_called()
@patch(_GET_ACCESS_TOKEN)
@patch(f"{_INDEXER_MODULE}.TaskLoggingService") @patch(f"{_INDEXER_MODULE}.TaskLoggingService")
@patch(f"{_INDEXER_MODULE}.ComposioService")
@patch(f"{_INDEXER_MODULE}.GoogleCalendarConnector") @patch(f"{_INDEXER_MODULE}.GoogleCalendarConnector")
@patch(f"{_INDEXER_MODULE}.build_composio_credentials") async def test_native_calendar_uses_google_calendar_connector(
async def test_native_calendar_does_not_use_composio_credentials(
mock_build_creds,
mock_cal_cls, mock_cal_cls,
mock_composio_service_cls,
mock_tl_cls, mock_tl_cls,
mock_get_access_token,
async_engine, async_engine,
native_calendar, native_calendar,
): ):
"""Calendar indexer does NOT call build_composio_credentials for a native connector.""" """Native Calendar connector uses GoogleCalendarConnector with no Composio path."""
from app.tasks.connector_indexers.google_calendar_indexer import ( from app.tasks.connector_indexers.google_calendar_indexer import (
index_google_calendar_events, index_google_calendar_events,
) )
@ -174,4 +183,6 @@ async def test_native_calendar_does_not_use_composio_credentials(
user_id=data["user_id"], user_id=data["user_id"],
) )
mock_build_creds.assert_not_called() mock_cal_cls.assert_called_once()
mock_composio_service_cls.assert_not_called()
mock_get_access_token.assert_not_called()

View file

@ -7,7 +7,7 @@ mocked at their system boundaries.
from __future__ import annotations from __future__ import annotations
from unittest.mock import AsyncMock, MagicMock, patch from unittest.mock import ANY, AsyncMock, MagicMock, patch
import pytest import pytest
import pytest_asyncio import pytest_asyncio
@ -25,6 +25,7 @@ pytestmark = pytest.mark.integration
_COMPOSIO_ACCOUNT_ID = "composio-gmail-test-456" _COMPOSIO_ACCOUNT_ID = "composio-gmail-test-456"
_INDEXER_MODULE = "app.tasks.connector_indexers.google_gmail_indexer" _INDEXER_MODULE = "app.tasks.connector_indexers.google_gmail_indexer"
_GET_ACCESS_TOKEN = "app.services.composio_service.ComposioService.get_access_token"
@pytest_asyncio.fixture @pytest_asyncio.fixture
@ -69,30 +70,32 @@ async def native_gmail(async_engine):
await cleanup_space(async_engine, data["search_space_id"]) await cleanup_space(async_engine, data["search_space_id"])
@patch(_GET_ACCESS_TOKEN)
@patch(f"{_INDEXER_MODULE}.TaskLoggingService") @patch(f"{_INDEXER_MODULE}.TaskLoggingService")
@patch(f"{_INDEXER_MODULE}.GoogleGmailConnector") @patch(f"{_INDEXER_MODULE}.GoogleGmailConnector")
@patch(f"{_INDEXER_MODULE}.build_composio_credentials") @patch(f"{_INDEXER_MODULE}.ComposioService")
async def test_composio_gmail_uses_composio_credentials( async def test_composio_gmail_uses_composio_service(
mock_build_creds, mock_composio_service_cls,
mock_gmail_cls, mock_gmail_cls,
mock_tl_cls, mock_tl_cls,
mock_get_access_token,
async_engine, async_engine,
composio_gmail, composio_gmail,
): ):
"""Gmail indexer calls build_composio_credentials for a Composio connector.""" """Gmail indexer uses Composio tools directly for a Composio connector."""
from app.tasks.connector_indexers.google_gmail_indexer import ( from app.tasks.connector_indexers.google_gmail_indexer import (
index_google_gmail_messages, index_google_gmail_messages,
) )
data = composio_gmail data = composio_gmail
mock_creds = MagicMock(name="composio-creds") mock_composio_service = MagicMock()
mock_build_creds.return_value = mock_creds mock_composio_service.get_gmail_messages = AsyncMock(
return_value=([], None, None, None)
)
mock_composio_service.get_gmail_message_detail = AsyncMock(return_value=({}, None))
mock_composio_service_cls.return_value = mock_composio_service
mock_tl_cls.return_value = mock_task_logger() mock_tl_cls.return_value = mock_task_logger()
mock_gmail_instance = MagicMock()
mock_gmail_instance.get_recent_messages = AsyncMock(return_value=([], None))
mock_gmail_cls.return_value = mock_gmail_instance
maker = make_session_factory(async_engine) maker = make_session_factory(async_engine)
async with maker() as session: async with maker() as session:
await index_google_gmail_messages( await index_google_gmail_messages(
@ -102,17 +105,25 @@ async def test_composio_gmail_uses_composio_credentials(
user_id=data["user_id"], user_id=data["user_id"],
) )
mock_build_creds.assert_called_once_with(_COMPOSIO_ACCOUNT_ID) mock_composio_service_cls.assert_called_once()
mock_gmail_cls.assert_called_once() mock_composio_service.get_gmail_messages.assert_called_once_with(
args, _ = mock_gmail_cls.call_args connected_account_id=_COMPOSIO_ACCOUNT_ID,
assert args[0] is mock_creds entity_id=f"surfsense_{data['user_id']}",
query=ANY,
max_results=ANY,
page_token=None,
)
mock_gmail_cls.assert_not_called()
mock_get_access_token.assert_not_called()
@patch(_GET_ACCESS_TOKEN)
@patch(f"{_INDEXER_MODULE}.TaskLoggingService") @patch(f"{_INDEXER_MODULE}.TaskLoggingService")
@patch(f"{_INDEXER_MODULE}.build_composio_credentials") @patch(f"{_INDEXER_MODULE}.ComposioService")
async def test_composio_gmail_without_account_id_returns_error( async def test_composio_gmail_without_account_id_returns_error(
mock_build_creds, mock_composio_service_cls,
mock_tl_cls, mock_tl_cls,
mock_get_access_token,
async_engine, async_engine,
composio_gmail_no_id, composio_gmail_no_id,
): ):
@ -136,20 +147,23 @@ async def test_composio_gmail_without_account_id_returns_error(
assert count == 0 assert count == 0
assert error is not None assert error is not None
assert "composio" in error.lower() assert "composio" in error.lower()
mock_build_creds.assert_not_called() mock_composio_service_cls.assert_not_called()
mock_get_access_token.assert_not_called()
@patch(_GET_ACCESS_TOKEN)
@patch(f"{_INDEXER_MODULE}.TaskLoggingService") @patch(f"{_INDEXER_MODULE}.TaskLoggingService")
@patch(f"{_INDEXER_MODULE}.ComposioService")
@patch(f"{_INDEXER_MODULE}.GoogleGmailConnector") @patch(f"{_INDEXER_MODULE}.GoogleGmailConnector")
@patch(f"{_INDEXER_MODULE}.build_composio_credentials") async def test_native_gmail_uses_google_gmail_connector(
async def test_native_gmail_does_not_use_composio_credentials(
mock_build_creds,
mock_gmail_cls, mock_gmail_cls,
mock_composio_service_cls,
mock_tl_cls, mock_tl_cls,
mock_get_access_token,
async_engine, async_engine,
native_gmail, native_gmail,
): ):
"""Gmail indexer does NOT call build_composio_credentials for a native connector.""" """Native Gmail connector uses GoogleGmailConnector with no Composio path."""
from app.tasks.connector_indexers.google_gmail_indexer import ( from app.tasks.connector_indexers.google_gmail_indexer import (
index_google_gmail_messages, index_google_gmail_messages,
) )
@ -170,4 +184,6 @@ async def test_native_gmail_does_not_use_composio_credentials(
user_id=data["user_id"], user_id=data["user_id"],
) )
mock_build_creds.assert_not_called() mock_gmail_cls.assert_called_once()
mock_composio_service_cls.assert_not_called()
mock_get_access_token.assert_not_called()

View file

@ -200,7 +200,7 @@ async def test_reindex_sets_status_ready(db_session, db_search_space, db_user, m
async def test_reindex_replaces_chunks(db_session, db_search_space, db_user, mocker): async def test_reindex_replaces_chunks(db_session, db_search_space, db_user, mocker):
"""Reindexing replaces old chunks with new content rather than appending.""" """Reindexing replaces old chunks with new content rather than appending."""
mocker.patch( mocker.patch(
"app.indexing_pipeline.indexing_pipeline_service.chunk_text", "app.indexing_pipeline.indexing_pipeline_service.chunk_text_hybrid",
side_effect=[["Original chunk."], ["Updated chunk."]], side_effect=[["Original chunk."], ["Updated chunk."]],
) )

View file

@ -37,7 +37,12 @@ def _make_orm_doc(connector_doc, doc_id):
async def test_index_calls_embed_and_chunk_via_to_thread( async def test_index_calls_embed_and_chunk_via_to_thread(
pipeline, make_connector_document, monkeypatch pipeline, make_connector_document, monkeypatch
): ):
"""index() runs embed_texts and the chunker via asyncio.to_thread, not blocking the loop.""" """index() runs the chunker and embed_texts via asyncio.to_thread, not blocking the loop.
Routing between ``chunk_text`` (code path) and ``chunk_text_hybrid`` (default
path, see issue #1334) is verified separately in
``test_non_code_documents_use_hybrid_chunker``.
"""
to_thread_calls = [] to_thread_calls = []
original_to_thread = asyncio.to_thread original_to_thread = asyncio.to_thread
@ -51,12 +56,6 @@ async def test_index_calls_embed_and_chunk_via_to_thread(
"app.indexing_pipeline.indexing_pipeline_service.summarize_document", "app.indexing_pipeline.indexing_pipeline_service.summarize_document",
AsyncMock(return_value="Summary."), AsyncMock(return_value="Summary."),
) )
mock_chunk = MagicMock(return_value=["chunk1"])
mock_chunk.__name__ = "chunk_text"
monkeypatch.setattr(
"app.indexing_pipeline.indexing_pipeline_service.chunk_text",
mock_chunk,
)
mock_chunk_hybrid = MagicMock(return_value=["chunk1"]) mock_chunk_hybrid = MagicMock(return_value=["chunk1"])
mock_chunk_hybrid.__name__ = "chunk_text_hybrid" mock_chunk_hybrid.__name__ = "chunk_text_hybrid"
monkeypatch.setattr( monkeypatch.setattr(
@ -71,6 +70,11 @@ async def test_index_calls_embed_and_chunk_via_to_thread(
"app.indexing_pipeline.indexing_pipeline_service.embed_texts", "app.indexing_pipeline.indexing_pipeline_service.embed_texts",
mock_embed, mock_embed,
) )
# Bypass set_committed_value, which requires a real ORM instance (not MagicMock).
monkeypatch.setattr(
"app.indexing_pipeline.indexing_pipeline_service.attach_chunks_to_document",
MagicMock(),
)
connector_doc = make_connector_document( connector_doc = make_connector_document(
document_type=DocumentType.GOOGLE_GMAIL_CONNECTOR, document_type=DocumentType.GOOGLE_GMAIL_CONNECTOR,
@ -83,11 +87,62 @@ async def test_index_calls_embed_and_chunk_via_to_thread(
await pipeline.index(document, connector_doc, llm=MagicMock()) await pipeline.index(document, connector_doc, llm=MagicMock())
# Non-code documents now route through the table-aware hybrid chunker # Either chunker entry point satisfies the "chunking runs off the event
# (see commit 2f3a33c9). Either chunker entry point satisfies the # loop" contract this test guards. Routing between the two is verified
# "chunking runs off the event loop" contract this test guards. # in test_non_code_documents_use_hybrid_chunker.
assert {"chunk_text", "chunk_text_hybrid"} & set(to_thread_calls) assert {"chunk_text", "chunk_text_hybrid"} & set(to_thread_calls)
assert "embed_texts" in to_thread_calls assert "embed_texts" in to_thread_calls
assert document.status == DocumentStatus.ready()
async def test_non_code_documents_use_hybrid_chunker(
pipeline, make_connector_document, monkeypatch
):
"""Non-code documents route through ``chunk_text_hybrid`` (issue #1334).
The hybrid chunker preserves Markdown table integrity by avoiding splits
mid-row. Only documents flagged with ``should_use_code_chunker=True``
should take the ``chunk_text`` path.
"""
monkeypatch.setattr(
"app.indexing_pipeline.indexing_pipeline_service.summarize_document",
AsyncMock(return_value="Summary."),
)
mock_chunk_hybrid = MagicMock(return_value=["chunk1"])
mock_chunk_hybrid.__name__ = "chunk_text_hybrid"
monkeypatch.setattr(
"app.indexing_pipeline.indexing_pipeline_service.chunk_text_hybrid",
mock_chunk_hybrid,
)
mock_chunk_code = MagicMock(return_value=["chunk1"])
mock_chunk_code.__name__ = "chunk_text"
monkeypatch.setattr(
"app.indexing_pipeline.indexing_pipeline_service.chunk_text",
mock_chunk_code,
)
monkeypatch.setattr(
"app.indexing_pipeline.indexing_pipeline_service.embed_texts",
MagicMock(side_effect=lambda texts: [[0.1] * _EMBEDDING_DIM for _ in texts]),
)
monkeypatch.setattr(
"app.indexing_pipeline.indexing_pipeline_service.attach_chunks_to_document",
MagicMock(),
)
connector_doc = make_connector_document(
document_type=DocumentType.GOOGLE_GMAIL_CONNECTOR,
unique_id="msg-1",
search_space_id=1,
should_use_code_chunker=False,
)
document = MagicMock(spec=Document)
document.id = 1
document.status = DocumentStatus.pending()
await pipeline.index(document, connector_doc, llm=MagicMock())
mock_chunk_hybrid.assert_called_once()
mock_chunk_code.assert_not_called()
def _mock_session_factory(orm_docs_by_id): def _mock_session_factory(orm_docs_by_id):

View file

@ -7947,7 +7947,7 @@ wheels = [
[[package]] [[package]]
name = "surf-new-backend" name = "surf-new-backend"
version = "0.0.23" version = "0.0.24"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "alembic" }, { name = "alembic" },

View file

@ -1,7 +1,7 @@
{ {
"name": "surfsense_browser_extension", "name": "surfsense_browser_extension",
"displayName": "Surfsense Browser Extension", "displayName": "Surfsense Browser Extension",
"version": "0.0.23", "version": "0.0.24",
"description": "Extension to collect Browsing History for SurfSense.", "description": "Extension to collect Browsing History for SurfSense.",
"author": "https://github.com/MODSetter", "author": "https://github.com/MODSetter",
"engines": { "engines": {

Binary file not shown.

After

Width:  |  Height:  |  Size: 287 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 684 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

View file

@ -1,6 +1,6 @@
{ {
"name": "surfsense-desktop", "name": "surfsense-desktop",
"version": "0.0.23", "version": "0.0.24",
"description": "SurfSense Desktop App", "description": "SurfSense Desktop App",
"main": "dist/main.js", "main": "dist/main.js",
"scripts": { "scripts": {

View file

@ -11,11 +11,20 @@ let registeredGeneralAssist: string | null = null;
let registeredScreenshotAssist: string | null = null; let registeredScreenshotAssist: string | null = null;
function getTrayIcon(): NativeImage { function getTrayIcon(): NativeImage {
const iconName = process.platform === 'win32' ? 'icon.ico' : 'icon.png'; const iconName =
process.platform === 'darwin'
? 'iconTemplate.png'
: process.platform === 'win32'
? 'icon.ico'
: 'icon.png';
const iconPath = app.isPackaged const iconPath = app.isPackaged
? path.join(process.resourcesPath, 'assets', iconName) ? path.join(process.resourcesPath, 'assets', iconName)
: path.join(__dirname, '..', 'assets', iconName); : path.join(__dirname, '..', 'assets', iconName);
const img = nativeImage.createFromPath(iconPath); const img = nativeImage.createFromPath(iconPath);
if (process.platform === 'darwin') {
img.setTemplateImage(true);
return img;
}
return img.resize({ width: 16, height: 16 }); return img.resize({ width: 16, height: 16 });
} }

View file

@ -7,6 +7,7 @@ import { setActiveSearchSpaceId } from './active-search-space';
const isDev = !app.isPackaged; const isDev = !app.isPackaged;
const HOSTED_FRONTEND_URL = process.env.HOSTED_FRONTEND_URL as string; const HOSTED_FRONTEND_URL = process.env.HOSTED_FRONTEND_URL as string;
const isMac = process.platform === 'darwin';
let mainWindow: BrowserWindow | null = null; let mainWindow: BrowserWindow | null = null;
let isQuitting = false; let isQuitting = false;
@ -35,7 +36,12 @@ export function createMainWindow(initialPath = '/dashboard'): BrowserWindow {
webviewTag: false, webviewTag: false,
}, },
show: false, show: false,
titleBarStyle: 'hiddenInset', ...(isMac
? {
titleBarStyle: 'hidden' as const,
trafficLightPosition: { x: 12, y: 10 },
}
: {}),
}); });
mainWindow.once('ready-to-show', () => { mainWindow.once('ready-to-show', () => {

View file

@ -2,20 +2,20 @@ import type { Metadata } from "next";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Announcements | SurfSense", title: "What's New | SurfSense",
description: "Latest product updates, feature releases, and news from SurfSense.", description: "Latest product updates, feature releases, and news from SurfSense.",
alternates: { alternates: {
canonical: "https://www.surfsense.com/announcements", canonical: "https://www.surfsense.com/announcements",
}, },
openGraph: { openGraph: {
title: "Announcements | SurfSense", title: "What's New | SurfSense",
description: "Latest product updates, feature releases, and news from SurfSense.", description: "Latest product updates, feature releases, and news from SurfSense.",
url: "https://www.surfsense.com/announcements", url: "https://www.surfsense.com/announcements",
type: "website", type: "website",
}, },
twitter: { twitter: {
card: "summary_large_image", card: "summary_large_image",
title: "Announcements | SurfSense", title: "What's New | SurfSense",
description: "Latest product updates, feature releases, and news from SurfSense.", description: "Latest product updates, feature releases, and news from SurfSense.",
}, },
}; };

View file

@ -24,7 +24,7 @@ export default function AnnouncementsPage() {
<div className="max-w-5xl mx-auto relative"> <div className="max-w-5xl mx-auto relative">
<div className="p-6"> <div className="p-6">
<h1 className="text-4xl font-bold tracking-tight bg-linear-to-r from-gray-900 to-gray-600 dark:from-white dark:to-gray-400 bg-clip-text text-transparent"> <h1 className="text-4xl font-bold tracking-tight bg-linear-to-r from-gray-900 to-gray-600 dark:from-white dark:to-gray-400 bg-clip-text text-transparent">
Announcements What's New
</h1> </h1>
</div> </div>
</div> </div>

View file

@ -1,15 +1,47 @@
"use client"; "use client";
import { IconBrandGoogleFilled } from "@tabler/icons-react";
import { motion } from "motion/react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useState } from "react";
import { Logo } from "@/components/Logo"; import { Logo } from "@/components/Logo";
import { Button } from "@/components/ui/button";
import { trackLoginAttempt } from "@/lib/posthog/events"; import { trackLoginAttempt } from "@/lib/posthog/events";
import { AmbientBackground } from "./AmbientBackground"; import { AmbientBackground } from "./AmbientBackground";
function GoogleGLogo({ className }: { className?: string }) {
return (
<svg
className={className}
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 48 48"
aria-hidden="true"
>
<path
fill="#EA4335"
d="M24 9.5c3.54 0 6.71 1.22 9.21 3.6l6.85-6.85C35.9 2.38 30.47 0 24 0 14.62 0 6.51 5.38 2.56 13.22l7.98 6.19C12.43 13.72 17.74 9.5 24 9.5z"
/>
<path
fill="#4285F4"
d="M46.98 24.55c0-1.57-.15-3.09-.38-4.55H24v9.02h12.94c-.58 2.96-2.26 5.48-4.78 7.18l7.73 6c4.51-4.18 7.09-10.36 7.09-17.65z"
/>
<path
fill="#FBBC05"
d="M10.53 28.59c-.48-1.45-.76-2.99-.76-4.59s.27-3.14.76-4.59l-7.98-6.19C.92 16.46 0 20.12 0 24c0 3.88.92 7.54 2.56 10.78l7.97-6.19z"
/>
<path
fill="#34A853"
d="M24 48c6.48 0 11.93-2.13 15.89-5.81l-7.73-6c-2.15 1.45-4.92 2.3-8.16 2.3-6.26 0-11.57-4.22-13.47-9.91l-7.98 6.19C6.51 42.62 14.62 48 24 48z"
/>
</svg>
);
}
export function GoogleLoginButton() { export function GoogleLoginButton() {
const t = useTranslations("auth"); const t = useTranslations("auth");
const [isRedirecting, setIsRedirecting] = useState(false);
const handleGoogleLogin = () => { const handleGoogleLogin = () => {
if (isRedirecting) return;
setIsRedirecting(true);
// Track Google login attempt // Track Google login attempt
trackLoginAttempt("google"); trackLoginAttempt("google");
@ -73,21 +105,15 @@ export function GoogleLoginButton() {
</motion.div> </motion.div>
</motion.div> */} </motion.div> */}
<motion.button <Button
whileHover={{ scale: 1.02 }} variant="outline"
whileTap={{ scale: 0.98 }} className="w-full max-w-md gap-2 rounded-lg border-white bg-white px-6 py-5 font-medium text-[#1f1f1f] shadow-sm hover:bg-zinc-100 hover:text-[#1f1f1f] dark:border-white md:py-5"
className="group/btn relative flex w-full items-center justify-center space-x-2 rounded-lg bg-white px-6 py-3 md:py-4 text-neutral-700 shadow-lg transition-all duration-200 hover:shadow-xl dark:bg-neutral-800 dark:text-neutral-200" disabled={isRedirecting}
onClick={handleGoogleLogin} onClick={handleGoogleLogin}
> >
<div className="absolute inset-0 h-full w-full transform opacity-0 transition duration-200 group-hover/btn:opacity-100"> <GoogleGLogo className="h-5 w-5" />
<div className="absolute -left-px -top-px h-4 w-4 rounded-tl-lg border-l-2 border-t-2 border-blue-500 bg-transparent transition-all duration-200 group-hover/btn:-left-2 group-hover/btn:-top-2"></div>
<div className="absolute -right-px -top-px h-4 w-4 rounded-tr-lg border-r-2 border-t-2 border-blue-500 bg-transparent transition-all duration-200 group-hover/btn:-right-2 group-hover/btn:-top-2"></div>
<div className="absolute -bottom-px -left-px h-4 w-4 rounded-bl-lg border-b-2 border-l-2 border-blue-500 bg-transparent transition-all duration-200 group-hover/btn:-bottom-2 group-hover/btn:-left-2"></div>
<div className="absolute -bottom-px -right-px h-4 w-4 rounded-br-lg border-b-2 border-r-2 border-blue-500 bg-transparent transition-all duration-200 group-hover/btn:-bottom-2 group-hover/btn:-right-2"></div>
</div>
<IconBrandGoogleFilled className="h-5 w-5 text-neutral-700 dark:text-neutral-200" />
<span className="text-base font-medium">{t("continue_with_google")}</span> <span className="text-base font-medium">{t("continue_with_google")}</span>
</motion.button> </Button>
</div> </div>
</div> </div>
); );

View file

@ -7,6 +7,7 @@ import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useState } from "react"; import { useState } from "react";
import { loginMutationAtom } from "@/atoms/auth/auth-mutation.atoms"; import { loginMutationAtom } from "@/atoms/auth/auth-mutation.atoms";
import { Button } from "@/components/ui/button";
import { Spinner } from "@/components/ui/spinner"; import { Spinner } from "@/components/ui/spinner";
import { getAuthErrorDetails, isNetworkError } from "@/lib/auth-errors"; import { getAuthErrorDetails, isNetworkError } from "@/lib/auth-errors";
import { AUTH_TYPE } from "@/lib/env-config"; import { AUTH_TYPE } from "@/lib/env-config";
@ -120,11 +121,13 @@ export function LocalLoginForm() {
<p className="text-sm font-semibold mb-1">{error.title}</p> <p className="text-sm font-semibold mb-1">{error.title}</p>
<p className="text-sm text-destructive">{error.message}</p> <p className="text-sm text-destructive">{error.message}</p>
</div> </div>
<button <Button
variant="ghost"
size="icon"
onClick={() => { onClick={() => {
setError({ title: null, message: null }); setError({ title: null, message: null });
}} }}
className="flex-shrink-0 text-destructive hover:text-destructive/90 transition-colors" className="size-6 flex-shrink-0 text-destructive hover:bg-transparent hover:text-destructive/90"
aria-label="Dismiss error" aria-label="Dismiss error"
type="button" type="button"
> >
@ -143,7 +146,7 @@ export function LocalLoginForm() {
<line x1="18" y1="6" x2="6" y2="18" /> <line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" /> <line x1="6" y1="6" x2="18" y2="18" />
</svg> </svg>
</button> </Button>
</div> </div>
</motion.div> </motion.div>
)} )}
@ -191,21 +194,23 @@ export function LocalLoginForm() {
}`} }`}
disabled={isLoggingIn} disabled={isLoggingIn}
/> />
<button <Button
type="button" type="button"
variant="ghost"
size="icon"
onClick={() => setShowPassword((prev) => !prev)} onClick={() => setShowPassword((prev) => !prev)}
className="absolute inset-y-0 right-0 flex items-center pr-3 text-muted-foreground hover:text-foreground" className="absolute inset-y-0 right-0 h-full w-10 text-muted-foreground hover:bg-transparent hover:text-foreground"
aria-label={showPassword ? t("hide_password") : t("show_password")} aria-label={showPassword ? t("hide_password") : t("show_password")}
> >
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />} {showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button> </Button>
</div> </div>
</div> </div>
<button <Button
type="submit" type="submit"
disabled={isLoggingIn} disabled={isLoggingIn}
className="relative w-full rounded-md bg-primary px-4 py-1.5 md:py-2 text-primary-foreground shadow-sm hover:bg-primary/90 focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 transition-all text-sm md:text-base flex items-center justify-center gap-2" className="relative h-auto w-full px-4 py-1.5 text-sm md:py-2 md:text-base"
> >
<span className={isLoggingIn ? "invisible" : ""}>{t("sign_in")}</span> <span className={isLoggingIn ? "invisible" : ""}>{t("sign_in")}</span>
{isLoggingIn && ( {isLoggingIn && (
@ -213,7 +218,7 @@ export function LocalLoginForm() {
<Spinner size="sm" className="text-primary-foreground" /> <Spinner size="sm" className="text-primary-foreground" />
</span> </span>
)} )}
</button> </Button>
</form> </form>
{authType === "LOCAL" && ( {authType === "LOCAL" && (

View file

@ -6,6 +6,7 @@ import { useTranslations } from "next-intl";
import { Suspense, useEffect, useState } from "react"; import { Suspense, useEffect, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { Logo } from "@/components/Logo"; import { Logo } from "@/components/Logo";
import { Button } from "@/components/ui/button";
import { useGlobalLoadingEffect } from "@/hooks/use-global-loading"; import { useGlobalLoadingEffect } from "@/hooks/use-global-loading";
import { getAuthErrorDetails, shouldRetry } from "@/lib/auth-errors"; import { getAuthErrorDetails, shouldRetry } from "@/lib/auth-errors";
import { setRedirectPath } from "@/lib/auth-utils"; import { setRedirectPath } from "@/lib/auth-utils";
@ -154,10 +155,12 @@ function LoginContent() {
<p className="text-sm font-semibold mb-1">{urlError.title}</p> <p className="text-sm font-semibold mb-1">{urlError.title}</p>
<p className="text-sm text-red-700 dark:text-red-300">{urlError.message}</p> <p className="text-sm text-red-700 dark:text-red-300">{urlError.message}</p>
</div> </div>
<button <Button
type="button" type="button"
variant="ghost"
size="icon"
onClick={() => setUrlError(null)} onClick={() => setUrlError(null)}
className="flex-shrink-0 text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-200 transition-colors" className="size-6 flex-shrink-0 text-red-500 hover:bg-transparent hover:text-red-700 dark:text-red-400 dark:hover:text-red-200"
aria-label="Dismiss error" aria-label="Dismiss error"
> >
<svg <svg
@ -175,7 +178,7 @@ function LoginContent() {
<line x1="18" y1="6" x2="6" y2="18" /> <line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" /> <line x1="6" y1="6" x2="18" y2="18" />
</svg> </svg>
</button> </Button>
</div> </div>
</motion.div> </motion.div>
)} )}

View file

@ -9,6 +9,7 @@ import { useEffect, useState } from "react";
import { type ExternalToast, toast } from "sonner"; import { type ExternalToast, toast } from "sonner";
import { registerMutationAtom } from "@/atoms/auth/auth-mutation.atoms"; import { registerMutationAtom } from "@/atoms/auth/auth-mutation.atoms";
import { Logo } from "@/components/Logo"; import { Logo } from "@/components/Logo";
import { Button } from "@/components/ui/button";
import { Spinner } from "@/components/ui/spinner"; import { Spinner } from "@/components/ui/spinner";
import { getAuthErrorDetails, isNetworkError, shouldRetry } from "@/lib/auth-errors"; import { getAuthErrorDetails, isNetworkError, shouldRetry } from "@/lib/auth-errors";
import { getBearerToken } from "@/lib/auth-utils"; import { getBearerToken } from "@/lib/auth-utils";
@ -199,11 +200,13 @@ export default function RegisterPage() {
<p className="text-sm font-semibold mb-1">{error.title}</p> <p className="text-sm font-semibold mb-1">{error.title}</p>
<p className="text-sm text-red-700 dark:text-red-300">{error.message}</p> <p className="text-sm text-red-700 dark:text-red-300">{error.message}</p>
</div> </div>
<button <Button
variant="ghost"
size="icon"
onClick={() => { onClick={() => {
setError({ title: null, message: null }); setError({ title: null, message: null });
}} }}
className="flex-shrink-0 text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-200 transition-colors" className="size-6 flex-shrink-0 text-red-500 hover:bg-transparent hover:text-red-700 dark:text-red-400 dark:hover:text-red-200"
aria-label="Dismiss error" aria-label="Dismiss error"
type="button" type="button"
> >
@ -222,7 +225,7 @@ export default function RegisterPage() {
<line x1="18" y1="6" x2="6" y2="18" /> <line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" /> <line x1="6" y1="6" x2="18" y2="18" />
</svg> </svg>
</button> </Button>
</div> </div>
</motion.div> </motion.div>
)} )}
@ -295,18 +298,18 @@ export default function RegisterPage() {
/> />
</div> </div>
<button <Button
type="submit" type="submit"
disabled={isRegistering} disabled={isRegistering}
className="relative w-full rounded-md bg-primary px-4 py-1.5 md:py-2 text-primary-foreground shadow-sm hover:bg-primary/90 focus:outline-none focus:ring-1 focus:ring-primary/40 disabled:cursor-not-allowed disabled:opacity-50 transition-all text-sm md:text-base flex items-center justify-center gap-2" className="relative h-auto w-full px-4 py-1.5 text-sm md:py-2 md:text-base"
> >
<span className={isRegistering ? "invisible" : ""}>{t("register")}</span> <span className={isRegistering ? "invisible" : ""}>{t("register")}</span>
{isRegistering && ( {isRegistering && (
<span className="absolute inset-0 flex items-center justify-center gap-2"> <span className="absolute inset-0 flex items-center justify-center gap-2">
<Spinner size="sm" className="text-white" /> <Spinner size="sm" className="text-primary-foreground" />
</span> </span>
)} )}
</button> </Button>
</form> </form>
<div className="mt-4 text-center text-sm"> <div className="mt-4 text-center text-sm">

View file

@ -4,7 +4,7 @@ import { motion } from "motion/react";
import { useState } from "react"; import { useState } from "react";
import { BuyPagesContent } from "@/components/settings/buy-pages-content"; import { BuyPagesContent } from "@/components/settings/buy-pages-content";
import { BuyTokensContent } from "@/components/settings/buy-tokens-content"; import { BuyTokensContent } from "@/components/settings/buy-tokens-content";
import { cn } from "@/lib/utils"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
const TABS = [ const TABS = [
{ id: "pages", label: "Pages" }, { id: "pages", label: "Pages" },
@ -17,33 +17,38 @@ export default function BuyMorePage() {
const [activeTab, setActiveTab] = useState<TabId>("pages"); const [activeTab, setActiveTab] = useState<TabId>("pages");
return ( return (
<div className="flex min-h-[calc(100vh-64px)] select-none items-center justify-center px-4 py-8"> <motion.div
<motion.div initial={{ opacity: 0, y: 20 }}
initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }}
animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.3 }}
transition={{ duration: 0.3 }} className="w-full select-none"
className="w-full max-w-md space-y-6" >
<Tabs
value={activeTab}
onValueChange={(value) => {
setActiveTab(value as TabId);
}}
className="relative min-h-[37rem] w-full"
> >
<div className="flex items-center justify-center rounded-lg border bg-muted/30 p-1"> <TabsList className="absolute top-20 left-1/2 -translate-x-1/2 rounded-xl bg-accent p-1">
{TABS.map((tab) => ( {TABS.map((tab) => (
<button <TabsTrigger
key={tab.id} key={tab.id}
type="button" value={tab.id}
onClick={() => setActiveTab(tab.id)} className="h-8 rounded-lg px-4 text-sm font-semibold text-accent-foreground transition-colors hover:bg-transparent hover:text-white data-[state=active]:bg-[#4a4a4a] data-[state=active]:text-white data-[state=active]:shadow-none"
className={cn(
"flex-1 rounded-md px-3 py-1.5 text-sm font-medium transition-colors",
activeTab === tab.id
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground"
)}
> >
{tab.label} {tab.label}
</button> </TabsTrigger>
))} ))}
</div> </TabsList>
{activeTab === "pages" ? <BuyPagesContent /> : <BuyTokensContent />} <TabsContent value="pages" className="mt-0 flex min-h-[37rem] items-center pt-14">
</motion.div> <BuyPagesContent />
</div> </TabsContent>
<TabsContent value="tokens" className="mt-0 flex min-h-[37rem] items-center pt-14">
<BuyTokensContent />
</TabsContent>
</Tabs>
</motion.div>
); );
} }

View file

@ -144,6 +144,19 @@ export function DashboardClientLayout({
const electronAPI = useElectronAPI(); const electronAPI = useElectronAPI();
useEffect(() => {
const htmlBackground = document.documentElement.style.backgroundColor;
const bodyBackground = document.body.style.backgroundColor;
document.documentElement.style.backgroundColor = "var(--panel)";
document.body.style.backgroundColor = "var(--panel)";
return () => {
document.documentElement.style.backgroundColor = htmlBackground;
document.body.style.backgroundColor = bodyBackground;
};
}, []);
useEffect(() => { useEffect(() => {
if (!electronAPI?.onChatScreenCapture) return; if (!electronAPI?.onChatScreenCapture) return;
return electronAPI.onChatScreenCapture((dataUrl: string) => { return electronAPI.onChatScreenCapture((dataUrl: string) => {
@ -163,12 +176,13 @@ export function DashboardClientLayout({
setActiveSearchSpaceIdState(activeSeacrhSpaceId); setActiveSearchSpaceIdState(activeSeacrhSpaceId);
// Sync to Electron store if stored value is null (first navigation) // Sync to Electron store if stored value is null (first navigation)
if (electronAPI?.setActiveSearchSpace) { if (electronAPI?.getActiveSearchSpace && electronAPI.setActiveSearchSpace) {
const setActiveSearchSpace = electronAPI.setActiveSearchSpace;
electronAPI electronAPI
.getActiveSearchSpace?.() .getActiveSearchSpace()
.then((stored) => { .then((stored: string | null) => {
if (!stored) { if (!stored) {
electronAPI.setActiveSearchSpace!(activeSeacrhSpaceId); setActiveSearchSpace(activeSeacrhSpaceId);
} }
}) })
.catch(() => {}); .catch(() => {});

View file

@ -17,7 +17,6 @@ import {
} from "@tanstack/react-table"; } from "@tanstack/react-table";
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
import { import {
Activity,
AlertCircle, AlertCircle,
AlertTriangle, AlertTriangle,
Bug, Bug,
@ -38,6 +37,7 @@ import {
RefreshCw, RefreshCw,
Terminal, Terminal,
Trash, Trash,
Workflow,
X, X,
Zap, Zap,
} from "lucide-react"; } from "lucide-react";
@ -133,7 +133,6 @@ const logStatusConfig = {
function MessageDetails({ function MessageDetails({
message, message,
taskName, taskName,
metadata,
createdAt, createdAt,
children, children,
}: { }: {
@ -623,7 +622,7 @@ function LogsSummaryDashboard({
<Card> <Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">{t("total_logs")}</CardTitle> <CardTitle className="text-sm font-medium">{t("total_logs")}</CardTitle>
<Activity className="h-4 w-4 text-muted-foreground" /> <Workflow className="h-4 w-4 text-muted-foreground" />
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="text-2xl font-bold">{summary.total_logs}</div> <div className="text-2xl font-bold">{summary.total_logs}</div>
@ -739,7 +738,7 @@ function LogsFilters({
</div> </div>
{Boolean(filterInput) && ( {Boolean(filterInput) && (
<Button <Button
className="absolute inset-y-0 end-0 flex h-full w-9 items-center justify-center rounded-e-lg text-muted-foreground/80 hover:text-foreground" className="absolute inset-y-0 end-0 flex h-full w-9 items-center justify-center rounded-e-lg text-muted-foreground/80 hover:text-accent-foreground"
variant="ghost" variant="ghost"
size="icon" size="icon"
onClick={() => { onClick={() => {
@ -1045,7 +1044,7 @@ function LogsTable({
}} }}
exit={{ opacity: 0, y: -10 }} exit={{ opacity: 0, y: -10 }}
className={cn( className={cn(
"border-b transition-colors hover:bg-muted/50", "border-b transition-colors hover:bg-accent hover:text-accent-foreground",
row.getIsSelected() ? "bg-muted/50" : "" row.getIsSelected() ? "bg-muted/50" : ""
)} )}
> >

View file

@ -53,7 +53,10 @@ export default function Loading() {
{/* Table Rows */} {/* Table Rows */}
{[...Array(6)].map((_, i) => ( {[...Array(6)].map((_, i) => (
<div key={i} className="border-b px-4 py-3 flex items-center gap-4 hover:bg-muted/50"> <div
key={i}
className="border-b px-4 py-3 flex items-center gap-4 hover:bg-accent hover:text-accent-foreground"
>
<Skeleton className="h-4 w-4" /> <Skeleton className="h-4 w-4" />
<Skeleton className="h-6 w-12 rounded-full" /> <Skeleton className="h-6 w-12 rounded-full" />
<Skeleton className="h-6 w-16 rounded-full" /> <Skeleton className="h-6 w-16 rounded-full" />

View file

@ -1,19 +1,11 @@
"use client"; "use client";
import { motion } from "motion/react";
import { MorePagesContent } from "@/components/settings/more-pages-content"; import { MorePagesContent } from "@/components/settings/more-pages-content";
export default function MorePagesPage() { export default function MorePagesPage() {
return ( return (
<div className="flex min-h-[calc(100vh-64px)] select-none items-center justify-center px-4 py-8"> <div className="w-full select-none space-y-6">
<motion.div <MorePagesContent />
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
className="w-full max-w-md space-y-6"
>
<MorePagesContent />
</motion.div>
</div> </div>
); );
} }

View file

@ -49,6 +49,7 @@ import {
type TokenUsageData, type TokenUsageData,
TokenUsageProvider, TokenUsageProvider,
} from "@/components/assistant-ui/token-usage-context"; } from "@/components/assistant-ui/token-usage-context";
import { Button } from "@/components/ui/button";
import { import {
type HitlDecision, type HitlDecision,
PendingInterruptProvider, PendingInterruptProvider,
@ -78,12 +79,7 @@ import {
setActivePodcastTaskId, setActivePodcastTaskId,
} from "@/lib/chat/podcast-state"; } from "@/lib/chat/podcast-state";
import { createStreamFlushHelpers } from "@/lib/chat/stream-flush"; import { createStreamFlushHelpers } from "@/lib/chat/stream-flush";
import { import { consumeSseEvents, processSharedStreamEvent } from "@/lib/chat/stream-pipeline";
consumeSseEvents,
hasPersistableContent,
markInterruptsCompleted,
processSharedStreamEvent,
} from "@/lib/chat/stream-pipeline";
import { import {
applyTurnIdToAssistantMessageList, applyTurnIdToAssistantMessageList,
mergeChatTurnIdIntoMessage, mergeChatTurnIdIntoMessage,
@ -92,7 +88,6 @@ import {
} from "@/lib/chat/stream-side-effects"; } from "@/lib/chat/stream-side-effects";
import { import {
addToolCall, addToolCall,
buildContentForPersistence,
buildContentForUI, buildContentForUI,
type ContentPartsState, type ContentPartsState,
type FrameBatchedUpdater, type FrameBatchedUpdater,
@ -453,7 +448,7 @@ export default function NewChatPage() {
}, [params.search_space_id]); }, [params.search_space_id]);
// Unified store for agent-action rows (the same react-query cache // Unified store for agent-action rows (the same react-query cache
// the agent-actions sheet, the inline Revert button, and the // the agent-actions dialog, the inline Revert button, and the
// per-turn Revert button all read). Hydrates from // per-turn Revert button all read). Hydrates from
// ``GET /threads/{id}/actions`` and is updated incrementally by the // ``GET /threads/{id}/actions`` and is updated incrementally by the
// SSE handlers + revert-batch results below — no atom side-channel. // SSE handlers + revert-batch results below — no atom side-channel.
@ -1762,8 +1757,19 @@ export default function NewChatPage() {
} }
const byTcId = new Map<string, (typeof incoming)[number]>(); const byTcId = new Map<string, (typeof incoming)[number]>();
for (let i = 0; i < tcIds.length; i++) byTcId.set(tcIds[i], incoming[i]); const submittedDecisions: typeof incoming = [];
const submittedDecisions = tcIds.map((id) => byTcId.get(id)!); for (let i = 0; i < tcIds.length; i++) {
const tcId = tcIds[i];
const decision = incoming[i];
if (tcId === undefined || decision === undefined) {
toast.error(
`Cannot resume: ${incoming.length} decision(s) submitted for ${N} pending actions.`
);
return;
}
byTcId.set(tcId, decision);
submittedDecisions.push(decision);
}
// All pending cards belong to the same assistant message, so a // All pending cards belong to the same assistant message, so a
// single content-update pass suffices. // single content-update pass suffices.
@ -2407,16 +2413,15 @@ export default function NewChatPage() {
return ( return (
<div className="flex h-full flex-col items-center justify-center gap-4"> <div className="flex h-full flex-col items-center justify-center gap-4">
<div className="text-destructive">Failed to load chat</div> <div className="text-destructive">Failed to load chat</div>
<button <Button
type="button" type="button"
onClick={() => { onClick={() => {
setIsInitializing(true); setIsInitializing(true);
initializeThread(); initializeThread();
}} }}
className="rounded-md bg-primary px-4 py-2 text-primary-foreground hover:bg-primary/90"
> >
Try Again Try Again
</button> </Button>
</div> </div>
); );
} }

View file

@ -2,42 +2,59 @@ import { Skeleton } from "@/components/ui/skeleton";
export default function Loading() { export default function Loading() {
return ( return (
<div className="flex h-full flex-col bg-main-panel px-4"> <div
<div className="mx-auto w-full max-w-[44rem] flex flex-1 flex-col gap-6 py-8"> className="aui-root aui-thread-root @container flex h-full min-h-0 flex-col bg-main-panel"
{/* User message */} style={{
<div className="flex justify-end"> ["--thread-max-width" as string]: "42rem",
<Skeleton className="h-12 w-56 rounded-2xl" /> }}
>
<div
className="aui-thread-viewport relative flex flex-1 min-h-0 flex-col overflow-y-auto px-4 scroll-smooth"
style={{ scrollbarGutter: "stable" }}
>
<div
aria-hidden
className="aui-chat-viewport-top-fade pointer-events-none sticky top-0 z-10 -mx-4 h-2 shrink-0 bg-gradient-to-b from-main-panel from-20% to-transparent"
/>
<div className="mx-auto w-full max-w-(--thread-max-width) flex flex-1 flex-col gap-6 py-8">
{/* User message */}
<div className="flex justify-end">
<Skeleton className="h-12 w-56 rounded-2xl" />
</div>
{/* Assistant message */}
<div className="flex flex-col gap-2">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-[85%]" />
<Skeleton className="h-18 w-[40%]" />
</div>
{/* User message */}
<div className="flex gap-2 justify-end">
<Skeleton className="h-12 w-72 rounded-2xl" />
</div>
{/* Assistant message */}
<div className="flex flex-col gap-2">
<Skeleton className="h-10 w-[30%]" />
<Skeleton className="h-4 w-[90%]" />
<Skeleton className="h-6 w-[60%]" />
</div>
{/* User message */}
<div className="flex gap-2 justify-end">
<Skeleton className="h-12 w-96 rounded-2xl" />
</div>
</div> </div>
{/* Assistant message */} {/* Input bar */}
<div className="flex flex-col gap-2"> <div
<Skeleton className="h-4 w-full" /> className="aui-chat-composer-footer sticky bottom-0 z-20 -mx-4 mt-auto flex flex-col items-stretch bg-gradient-to-t from-main-panel from-60% to-transparent px-4 pt-6"
<Skeleton className="h-4 w-[85%]" /> style={{ paddingBottom: "max(0.5rem, env(safe-area-inset-bottom))" }}
<Skeleton className="h-18 w-[40%]" /> >
</div> <div className="aui-chat-composer-area relative mx-auto flex w-full max-w-(--thread-max-width) flex-col gap-3 overflow-visible">
<Skeleton className="h-28 w-full rounded-3xl" />
{/* User message */} </div>
<div className="flex gap-2 justify-end">
<Skeleton className="h-12 w-72 rounded-2xl" />
</div>
{/* Assistant message */}
<div className="flex flex-col gap-2">
<Skeleton className="h-10 w-[30%]" />
<Skeleton className="h-4 w-[90%]" />
<Skeleton className="h-6 w-[60%]" />
</div>
{/* User message */}
<div className="flex gap-2 justify-end">
<Skeleton className="h-12 w-96 rounded-2xl" />
</div>
</div>
{/* Input bar */}
<div className="sticky bottom-0 pb-6 bg-main-panel">
<div className="mx-auto w-full max-w-[44rem]">
<Skeleton className="h-24 w-full rounded-2xl" />
</div> </div>
</div> </div>
</div> </div>

View file

@ -151,7 +151,7 @@ export default function OnboardPage() {
} }
return ( return (
<div className="h-screen flex flex-col items-center p-4 bg-background dark:bg-neutral-900 select-none overflow-hidden"> <div className="h-screen flex flex-col items-center p-4 bg-main-panel select-none overflow-hidden">
<div className="w-full max-w-lg flex flex-col min-h-0 h-full gap-6 py-8"> <div className="w-full max-w-lg flex flex-col min-h-0 h-full gap-6 py-8">
{/* Header */} {/* Header */}
<div className="text-center space-y-3 shrink-0"> <div className="text-center space-y-3 shrink-0">
@ -165,7 +165,7 @@ export default function OnboardPage() {
</div> </div>
{/* Form card */} {/* Form card */}
<div className="rounded-xl border bg-background dark:bg-neutral-900 flex-1 min-h-0 overflow-y-auto px-6 py-6"> <div className="rounded-xl border bg-main-panel flex-1 min-h-0 overflow-y-auto px-6 py-6">
<LLMConfigForm <LLMConfigForm
searchSpaceId={searchSpaceId} searchSpaceId={searchSpaceId}
onSubmit={handleSubmit} onSubmit={handleSubmit}

View file

@ -0,0 +1,6 @@
import { GeneralSettingsManager } from "@/components/settings/general-settings-manager";
export default async function Page({ params }: { params: Promise<{ search_space_id: string }> }) {
const { search_space_id } = await params;
return <GeneralSettingsManager searchSpaceId={Number(search_space_id)} />;
}

View file

@ -0,0 +1,6 @@
import { ImageModelManager } from "@/components/settings/image-model-manager";
export default async function Page({ params }: { params: Promise<{ search_space_id: string }> }) {
const { search_space_id } = await params;
return <ImageModelManager searchSpaceId={Number(search_space_id)} />;
}

View file

@ -0,0 +1,179 @@
"use client";
import {
BookText,
Bot,
Brain,
CircleUser,
Earth,
ImageIcon,
ListChecks,
ScanEye,
UserKey,
} from "lucide-react";
import Link from "next/link";
import { useSelectedLayoutSegment } from "next/navigation";
import { useTranslations } from "next-intl";
import type React from "react";
import { useCallback, useMemo, useState } from "react";
import { Separator } from "@/components/ui/separator";
import { cn } from "@/lib/utils";
export type SearchSpaceSettingsTab =
| "general"
| "roles"
| "models"
| "image-models"
| "vision-models"
| "team-roles"
| "prompts"
| "team-memory"
| "public-links";
const DEFAULT_TAB: SearchSpaceSettingsTab = "general";
interface SearchSpaceSettingsLayoutShellProps {
searchSpaceId: string;
children: React.ReactNode;
}
export function SearchSpaceSettingsLayoutShell({
searchSpaceId,
children,
}: SearchSpaceSettingsLayoutShellProps) {
const t = useTranslations("searchSpaceSettings");
const segment = useSelectedLayoutSegment();
const [tabScrollPos, setTabScrollPos] = useState<"start" | "middle" | "end">("start");
const handleTabScroll = useCallback((e: React.UIEvent<HTMLDivElement>) => {
const el = e.currentTarget;
const atStart = el.scrollLeft <= 2;
const atEnd = el.scrollWidth - el.scrollLeft - el.clientWidth <= 2;
setTabScrollPos(atStart ? "start" : atEnd ? "end" : "middle");
}, []);
const navItems = useMemo(
() => [
{
value: "general" as const,
label: t("nav_general"),
icon: <CircleUser className="h-4 w-4" />,
},
{
value: "roles" as const,
label: t("nav_role_assignments"),
icon: <ListChecks className="h-4 w-4" />,
},
{
value: "models" as const,
label: t("nav_agent_models"),
icon: <Bot className="h-4 w-4" />,
},
{
value: "image-models" as const,
label: t("nav_image_models"),
icon: <ImageIcon className="h-4 w-4" />,
},
{
value: "vision-models" as const,
label: t("nav_vision_models"),
icon: <ScanEye className="h-4 w-4" />,
},
{
value: "team-roles" as const,
label: t("nav_team_roles"),
icon: <UserKey className="h-4 w-4" />,
},
{
value: "prompts" as const,
label: t("nav_system_instructions"),
icon: <BookText className="h-4 w-4" />,
},
{
value: "team-memory" as const,
label: "Team Memory",
icon: <Brain className="h-4 w-4" />,
},
{
value: "public-links" as const,
label: t("nav_public_links"),
icon: <Earth className="h-4 w-4" />,
},
],
[t]
);
const activeTab: SearchSpaceSettingsTab =
segment && navItems.some((item) => item.value === segment)
? (segment as SearchSpaceSettingsTab)
: DEFAULT_TAB;
const selectedLabel = navItems.find((item) => item.value === activeTab)?.label ?? t("title");
const hrefFor = (tab: SearchSpaceSettingsTab) =>
`/dashboard/${searchSpaceId}/search-space-settings/${tab}`;
return (
<section className="flex h-full min-h-[min(680px,calc(100vh-5rem))] w-full select-none flex-col gap-6 md:pt-6 md:flex-row">
<div className="md:w-[220px] md:shrink-0">
<h1 className="mb-4 px-1 text-2xl font-semibold tracking-tight">{t("title")}</h1>
<nav className="hidden flex-col gap-0.5 md:flex">
{navItems.map((item) => (
<Link
key={item.value}
href={hrefFor(item.value)}
replace
scroll={false}
prefetch
className={cn(
"inline-flex h-auto items-center justify-start gap-3 rounded-md px-3 py-2.5 text-left text-sm font-medium transition-colors duration-150 focus:outline-none focus-visible:outline-none",
activeTab === item.value
? "bg-accent text-accent-foreground"
: "bg-transparent text-muted-foreground hover:bg-accent hover:text-accent-foreground"
)}
>
{item.icon}
{item.label}
</Link>
))}
</nav>
<div
className="overflow-x-auto border-b border-border pb-3 md:hidden"
onScroll={handleTabScroll}
style={{
maskImage: `linear-gradient(to right, ${tabScrollPos === "start" ? "black" : "transparent"}, black 24px, black calc(100% - 24px), ${tabScrollPos === "end" ? "black" : "transparent"})`,
WebkitMaskImage: `linear-gradient(to right, ${tabScrollPos === "start" ? "black" : "transparent"}, black 24px, black calc(100% - 24px), ${tabScrollPos === "end" ? "black" : "transparent"})`,
}}
>
<div className="flex gap-1">
{navItems.map((item) => (
<Link
key={item.value}
href={hrefFor(item.value)}
replace
scroll={false}
prefetch
className={cn(
"inline-flex h-auto shrink-0 items-center gap-2 rounded-md px-3 py-1.5 text-xs font-medium transition-colors duration-150 focus:outline-none focus-visible:outline-none",
activeTab === item.value
? "bg-accent text-accent-foreground"
: "bg-transparent text-muted-foreground hover:bg-accent hover:text-accent-foreground"
)}
>
{item.icon}
{item.label}
</Link>
))}
</div>
</div>
</div>
<div className="min-w-0 flex-1">
<div className="hidden md:block">
<h2 className="text-lg font-semibold">{selectedLabel}</h2>
<Separator className="mt-4" />
</div>
<div className="min-w-0 pt-4 md:max-w-3xl">{children}</div>
</div>
</section>
);
}

View file

@ -0,0 +1,19 @@
import type React from "react";
import { use } from "react";
import { SearchSpaceSettingsLayoutShell } from "./layout-shell";
export default function SearchSpaceSettingsLayout({
params,
children,
}: {
params: Promise<{ search_space_id: string }>;
children: React.ReactNode;
}) {
const { search_space_id } = use(params);
return (
<SearchSpaceSettingsLayoutShell searchSpaceId={search_space_id}>
{children}
</SearchSpaceSettingsLayoutShell>
);
}

View file

@ -0,0 +1,6 @@
import { AgentModelManager } from "@/components/settings/agent-model-manager";
export default async function Page({ params }: { params: Promise<{ search_space_id: string }> }) {
const { search_space_id } = await params;
return <AgentModelManager searchSpaceId={Number(search_space_id)} />;
}

View file

@ -0,0 +1,10 @@
import { redirect } from "next/navigation";
export default async function SearchSpaceSettingsPage({
params,
}: {
params: Promise<{ search_space_id: string }>;
}) {
const { search_space_id } = await params;
redirect(`/dashboard/${search_space_id}/search-space-settings/general`);
}

View file

@ -0,0 +1,6 @@
import { PromptConfigManager } from "@/components/settings/prompt-config-manager";
export default async function Page({ params }: { params: Promise<{ search_space_id: string }> }) {
const { search_space_id } = await params;
return <PromptConfigManager searchSpaceId={Number(search_space_id)} />;
}

View file

@ -0,0 +1,6 @@
import { PublicChatSnapshotsManager } from "@/components/public-chat-snapshots/public-chat-snapshots-manager";
export default async function Page({ params }: { params: Promise<{ search_space_id: string }> }) {
const { search_space_id } = await params;
return <PublicChatSnapshotsManager searchSpaceId={Number(search_space_id)} />;
}

View file

@ -0,0 +1,6 @@
import { LLMRoleManager } from "@/components/settings/llm-role-manager";
export default async function Page({ params }: { params: Promise<{ search_space_id: string }> }) {
const { search_space_id } = await params;
return <LLMRoleManager key={search_space_id} searchSpaceId={Number(search_space_id)} />;
}

View file

@ -0,0 +1,6 @@
import { TeamMemoryManager } from "@/components/settings/team-memory-manager";
export default async function Page({ params }: { params: Promise<{ search_space_id: string }> }) {
const { search_space_id } = await params;
return <TeamMemoryManager searchSpaceId={Number(search_space_id)} />;
}

View file

@ -0,0 +1,6 @@
import { RolesManager } from "@/components/settings/roles-manager";
export default async function Page({ params }: { params: Promise<{ search_space_id: string }> }) {
const { search_space_id } = await params;
return <RolesManager searchSpaceId={Number(search_space_id)} />;
}

View file

@ -0,0 +1,6 @@
import { VisionModelManager } from "@/components/settings/vision-model-manager";
export default async function Page({ params }: { params: Promise<{ search_space_id: string }> }) {
const { search_space_id } = await params;
return <VisionModelManager searchSpaceId={Number(search_space_id)} />;
}

View file

@ -0,0 +1,15 @@
import { TeamContent } from "./team-content";
export default async function TeamPage({
params,
}: {
params: Promise<{ search_space_id: string }>;
}) {
const { search_space_id } = await params;
return (
<div className="w-full select-none space-y-6">
<TeamContent searchSpaceId={Number(search_space_id)} />
</div>
);
}

View file

@ -1,7 +1,7 @@
"use client"; "use client";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { useAtomValue, useSetAtom } from "jotai"; import { useAtomValue } from "jotai";
import { import {
Calendar, Calendar,
Check, Check,
@ -20,6 +20,7 @@ import {
UserPlus, UserPlus,
Users, Users,
} from "lucide-react"; } from "lucide-react";
import { useRouter } from "next/navigation";
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { import {
@ -31,7 +32,6 @@ import {
updateMemberMutationAtom, updateMemberMutationAtom,
} from "@/atoms/members/members-mutation.atoms"; } from "@/atoms/members/members-mutation.atoms";
import { membersAtom, myAccessAtom } from "@/atoms/members/members-query.atoms"; import { membersAtom, myAccessAtom } from "@/atoms/members/members-query.atoms";
import { searchSpaceSettingsDialogAtom } from "@/atoms/settings/settings-dialog.atoms";
import { import {
AlertDialog, AlertDialog,
AlertDialogAction, AlertDialogAction,
@ -240,46 +240,77 @@ export function TeamContent({ searchSpaceId }: TeamContentProps) {
if (accessLoading || membersLoading) { if (accessLoading || membersLoading) {
return ( return (
<div className="space-y-6"> <div className="space-y-4 md:space-y-6">
<div className="flex items-center justify-between"> <div className="flex items-center gap-2 flex-wrap">
<Skeleton className="h-9 w-36 rounded-md" /> <Button
<Skeleton className="h-4 w-20" /> type="button"
variant="outline"
size="sm"
aria-disabled="true"
tabIndex={-1}
className="pointer-events-none gap-1.5 md:gap-2 text-xs md:text-sm bg-black text-white dark:bg-white dark:text-black"
>
<UserPlus className="h-3.5 w-3.5 md:h-4 md:w-4" />
Invite members
</Button>
<Button
type="button"
variant="secondary"
size="sm"
aria-disabled="true"
tabIndex={-1}
className="pointer-events-none gap-1.5 md:gap-2 text-xs md:text-sm"
>
<Link2 className="h-3.5 w-3.5 md:h-4 md:w-4 rotate-315" />
Active invites
<span className="inline-flex items-center justify-center h-4 md:h-5 min-w-4 md:min-w-5 px-1 rounded-full bg-neutral-700 text-neutral-200">
<Skeleton className="h-2.5 w-2.5 rounded-sm bg-neutral-500/60" />
</span>
</Button>
<div className="flex items-center gap-1 text-xs md:text-sm text-muted-foreground whitespace-nowrap">
<Skeleton className="h-3 w-2 rounded-sm" />
members
</div>
</div> </div>
<div className="rounded-lg border border-border/40 bg-background overflow-hidden"> <div className="rounded-lg border border-border/60 bg-accent overflow-hidden">
<Table className="table-fixed w-full"> <Table className="table-fixed w-full">
<TableHeader> <TableHeader>
<TableRow className="hover:bg-transparent border-b border-border/40"> <TableRow className="hover:bg-transparent border-b border-border/60">
<TableHead className="w-[45%] px-4 md:px-6 border-r border-border/40"> <TableHead className="w-[45%] px-4 md:px-6 border-r border-border/60">
<Skeleton className="h-3 w-16" /> <span className="flex items-center gap-1.5 text-sm font-medium text-muted-foreground/70">
<User size={14} className="opacity-60 text-muted-foreground" />
Name
</span>
</TableHead> </TableHead>
<TableHead className="hidden md:table-cell w-[25%] border-r border-border/40"> <TableHead className="hidden md:table-cell w-[25%] border-r border-border/60">
<Skeleton className="h-3 w-24" /> <span className="flex items-center gap-1.5 text-sm font-medium text-muted-foreground/70">
<Clock size={14} className="opacity-60 text-muted-foreground" />
Last logged in
</span>
</TableHead> </TableHead>
<TableHead className="w-[30%] px-4 md:px-6"> <TableHead className="w-[30%] px-4 md:px-6">
<div className="flex justify-end"> <span className="flex items-center gap-1.5 text-sm font-medium text-muted-foreground/70 justify-end">
<Skeleton className="h-3 w-12" /> <ShieldUser size={14} className="opacity-60 text-muted-foreground" />
</div> Role
</span>
</TableHead> </TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{SKELETON_KEYS.map((id) => ( {SKELETON_KEYS.slice(0, 2).map((id) => (
<TableRow key={id} className="border-b border-border/40 hover:bg-transparent"> <TableRow key={id} className="border-b border-border/60 hover:bg-transparent">
<TableCell className="w-[45%] py-2.5 px-4 md:px-6 border-r border-border/40"> <TableCell className="w-[45%] py-2.5 px-4 md:px-6 border-r border-border/60">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Skeleton className="h-10 w-10 rounded-full shrink-0" /> <Skeleton className="h-10 w-10 rounded-full shrink-0" />
<div className="flex-1 min-w-0 space-y-1.5"> <Skeleton className="h-4 w-28 md:w-32" />
<Skeleton className="h-4 w-[60%]" />
<Skeleton className="h-3 w-[40%]" />
</div>
</div> </div>
</TableCell> </TableCell>
<TableCell className="hidden md:table-cell w-[25%] py-2.5 border-r border-border/40"> <TableCell className="hidden md:table-cell w-[25%] py-2.5 border-r border-border/60">
<Skeleton className="h-4 w-24" /> <Skeleton className="h-4 w-24" />
</TableCell> </TableCell>
<TableCell className="w-[30%] py-2.5 px-4 md:px-6"> <TableCell className="w-[30%] py-2.5 px-4 md:px-6">
<div className="flex justify-end"> <div className="flex justify-end">
<Skeleton className="h-4 w-16" /> <Skeleton className="h-4 w-12" />
</div> </div>
</TableCell> </TableCell>
</TableRow> </TableRow>
@ -294,41 +325,63 @@ export function TeamContent({ searchSpaceId }: TeamContentProps) {
return ( return (
<div className="space-y-4 md:space-y-6"> <div className="space-y-4 md:space-y-6">
<div className="flex items-center gap-2 flex-wrap"> <div className="flex items-center gap-2 flex-wrap">
{rolesLoading ? ( {canInvite &&
<Skeleton className="h-9 w-32 rounded-md" /> (rolesLoading ? (
) : ( <Button
canInvite && ( type="button"
variant="outline"
size="sm"
aria-disabled="true"
tabIndex={-1}
className="pointer-events-none gap-1.5 md:gap-2 text-xs md:text-sm bg-black text-white dark:bg-white dark:text-black"
>
<UserPlus className="h-3.5 w-3.5 md:h-4 md:w-4" />
Invite members
</Button>
) : (
<CreateInviteDialog <CreateInviteDialog
roles={roles} roles={roles}
onCreateInvite={handleCreateInvite} onCreateInvite={handleCreateInvite}
searchSpaceId={searchSpaceId} searchSpaceId={searchSpaceId}
/> />
) ))}
)} {canInvite &&
{invitesLoading ? ( (invitesLoading ? (
<Skeleton className="h-9 w-32 rounded-md" /> <Button
) : ( type="button"
canInvite && variant="secondary"
activeInvites.length > 0 && ( size="sm"
<AllInvitesDialog invites={activeInvites} onRevokeInvite={handleRevokeInvite} /> aria-disabled="true"
) tabIndex={-1}
)} className="pointer-events-none gap-1.5 md:gap-2 text-xs md:text-sm"
>
<Link2 className="h-3.5 w-3.5 md:h-4 md:w-4 rotate-315" />
Active invites
<span className="inline-flex items-center justify-center h-4 md:h-5 min-w-4 md:min-w-5 px-1 rounded-full bg-neutral-700 text-neutral-200">
<Skeleton className="h-2.5 w-2.5 rounded-sm bg-neutral-500/60" />
</span>
</Button>
) : (
activeInvites.length > 0 && (
<AllInvitesDialog invites={activeInvites} onRevokeInvite={handleRevokeInvite} />
)
))}
<p className="text-xs md:text-sm text-muted-foreground whitespace-nowrap"> <p className="text-xs md:text-sm text-muted-foreground whitespace-nowrap">
{members.length} {members.length === 1 ? "member" : "members"} {members.length} {members.length === 1 ? "member" : "members"}
</p> </p>
</div> </div>
<div className="rounded-lg border border-border/40 bg-background overflow-hidden"> <div className="rounded-lg border border-border/60 bg-accent overflow-hidden">
<Table className="table-fixed w-full"> <Table className="table-fixed w-full">
<TableHeader> <TableHeader>
<TableRow className="hover:bg-transparent border-b border-border/40"> <TableRow className="hover:bg-transparent border-b border-border/60">
<TableHead className="w-[45%] px-4 md:px-6 border-r border-border/40"> <TableHead className="w-[45%] px-4 md:px-6 border-r border-border/60">
<span className="flex items-center gap-1.5 text-sm font-medium text-muted-foreground/70"> <span className="flex items-center gap-1.5 text-sm font-medium text-muted-foreground/70">
<User size={14} className="opacity-60 text-muted-foreground" /> <User size={14} className="opacity-60 text-muted-foreground" />
Name Name
</span> </span>
</TableHead> </TableHead>
<TableHead className="hidden md:table-cell w-[25%] border-r border-border/40"> <TableHead className="hidden md:table-cell w-[25%] border-r border-border/60">
<span className="flex items-center gap-1.5 text-sm font-medium text-muted-foreground/70"> <span className="flex items-center gap-1.5 text-sm font-medium text-muted-foreground/70">
<Clock size={14} className="opacity-60 text-muted-foreground" /> <Clock size={14} className="opacity-60 text-muted-foreground" />
Last logged in Last logged in
@ -346,6 +399,7 @@ export function TeamContent({ searchSpaceId }: TeamContentProps) {
{owners.map((member) => ( {owners.map((member) => (
<MemberRow <MemberRow
key={`member-${member.id}`} key={`member-${member.id}`}
searchSpaceId={searchSpaceId}
member={member} member={member}
roles={roles} roles={roles}
canManageRoles={canManageRoles} canManageRoles={canManageRoles}
@ -357,6 +411,7 @@ export function TeamContent({ searchSpaceId }: TeamContentProps) {
{paginatedMembers.map((member) => ( {paginatedMembers.map((member) => (
<MemberRow <MemberRow
key={`member-${member.id}`} key={`member-${member.id}`}
searchSpaceId={searchSpaceId}
member={member} member={member}
roles={roles} roles={roles}
canManageRoles={canManageRoles} canManageRoles={canManageRoles}
@ -433,6 +488,7 @@ export function TeamContent({ searchSpaceId }: TeamContentProps) {
} }
function MemberRow({ function MemberRow({
searchSpaceId,
member, member,
roles, roles,
canManageRoles, canManageRoles,
@ -440,6 +496,7 @@ function MemberRow({
onUpdateRole, onUpdateRole,
onRemoveMember, onRemoveMember,
}: { }: {
searchSpaceId: number;
member: Membership; member: Membership;
roles: Role[]; roles: Role[];
canManageRoles: boolean; canManageRoles: boolean;
@ -447,21 +504,23 @@ function MemberRow({
onUpdateRole: (membershipId: number, roleId: number | null) => Promise<Membership>; onUpdateRole: (membershipId: number, roleId: number | null) => Promise<Membership>;
onRemoveMember: (membershipId: number) => Promise<boolean>; onRemoveMember: (membershipId: number) => Promise<boolean>;
}) { }) {
const setSearchSpaceSettingsDialog = useSetAtom(searchSpaceSettingsDialogAtom); const router = useRouter();
const initials = getAvatarInitials(member); const initials = getAvatarInitials(member);
const displayName = member.user_display_name || member.user_email || "Unknown"; const displayName = member.user_display_name || member.user_email || "Unknown";
const roleName = member.is_owner ? "Owner" : member.role?.name || "No role"; const roleName = member.is_owner ? "Owner" : member.role?.name || "No role";
const showActions = !member.is_owner && (canManageRoles || canRemove); const showActions = !member.is_owner && (canManageRoles || canRemove);
return ( return (
<TableRow className="border-b border-border/40 transition-colors hover:bg-muted/30"> <TableRow className="border-b border-border/60 transition-colors hover:bg-accent hover:text-accent-foreground">
<TableCell className="w-[45%] py-2.5 px-4 md:px-6 max-w-0 border-r border-border/40"> <TableCell className="w-[45%] py-2.5 px-4 md:px-6 max-w-0 border-r border-border/60">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Avatar className="size-10 shrink-0"> <Avatar className="size-10 shrink-0">
{member.user_avatar_url && ( {member.user_avatar_url && (
<AvatarImage src={member.user_avatar_url} alt={displayName} /> <AvatarImage src={member.user_avatar_url} alt={displayName} />
)} )}
<AvatarFallback className="text-sm">{initials}</AvatarFallback> <AvatarFallback className="bg-popover text-sm text-popover-foreground">
{initials}
</AvatarFallback>
</Avatar> </Avatar>
<div className="min-w-0"> <div className="min-w-0">
<p className="font-medium text-sm truncate select-text">{displayName}</p> <p className="font-medium text-sm truncate select-text">{displayName}</p>
@ -474,7 +533,7 @@ function MemberRow({
</div> </div>
</TableCell> </TableCell>
<TableCell className="hidden md:table-cell w-[25%] py-2.5 text-sm text-foreground border-r border-border/40"> <TableCell className="hidden md:table-cell w-[25%] py-2.5 text-sm text-foreground border-r border-border/60">
{member.user_last_login ? formatRelativeDate(member.user_last_login) : "Never"} {member.user_last_login ? formatRelativeDate(member.user_last_login) : "Never"}
</TableCell> </TableCell>
@ -482,18 +541,20 @@ function MemberRow({
{showActions ? ( {showActions ? (
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<button <Button
type="button" type="button"
className="inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors" variant="ghost"
size="sm"
className="h-auto w-[74px] justify-end gap-1.5 px-0 py-0 text-sm text-muted-foreground hover:bg-transparent hover:text-accent-foreground has-[>svg]:px-0"
> >
{roleName} {roleName}
<ChevronDown className="h-4 w-4" /> <ChevronDown className="h-4 w-4" />
</button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent <DropdownMenuContent
align="end" align="end"
onCloseAutoFocus={(e) => e.preventDefault()} onCloseAutoFocus={(e) => e.preventDefault()}
className="min-w-[120px] dark:bg-neutral-900 dark:border dark:border-white/5" className="min-w-[120px]"
> >
{canManageRoles && {canManageRoles &&
roles roles
@ -536,13 +597,10 @@ function MemberRow({
</AlertDialogContent> </AlertDialogContent>
</AlertDialog> </AlertDialog>
)} )}
<DropdownMenuSeparator className="dark:bg-white/5" /> <DropdownMenuSeparator className="bg-popover-border" />
<DropdownMenuItem <DropdownMenuItem
onClick={() => onClick={() =>
setSearchSpaceSettingsDialog({ router.push(`/dashboard/${searchSpaceId}/search-space-settings/team-roles`)
open: true,
initialTab: "team-roles",
})
} }
> >
Manage Roles Manage Roles
@ -707,7 +765,7 @@ function CreateInviteDialog({
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="invite-role">Role</Label> <Label htmlFor="invite-role">Role</Label>
<Select value={roleId} onValueChange={setRoleId}> <Select value={roleId} onValueChange={setRoleId}>
<SelectTrigger> <SelectTrigger className="border-popover-border">
<SelectValue placeholder="Assign a role" /> <SelectValue placeholder="Assign a role" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@ -743,7 +801,7 @@ function CreateInviteDialog({
<Button <Button
variant="outline" variant="outline"
className={cn( className={cn(
"w-full justify-start text-left font-normal bg-transparent", "w-full justify-start text-left font-normal bg-transparent border-popover-border",
!expiresAt && "text-muted-foreground" !expiresAt && "text-muted-foreground"
)} )}
> >

View file

@ -0,0 +1,5 @@
import { AgentPermissionsContent } from "../components/AgentPermissionsContent";
export default function Page() {
return <AgentPermissionsContent />;
}

View file

@ -0,0 +1,5 @@
import { AgentStatusContent } from "../components/AgentStatusContent";
export default function Page() {
return <AgentStatusContent />;
}

View file

@ -0,0 +1,5 @@
import { ApiKeyContent } from "../components/ApiKeyContent";
export default function Page() {
return <ApiKeyContent />;
}

View file

@ -0,0 +1,5 @@
import { CommunityPromptsContent } from "../components/CommunityPromptsContent";
export default function Page() {
return <CommunityPromptsContent />;
}

View file

@ -2,7 +2,7 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
import { AlertTriangle, Check, Plus, ShieldCheck, Trash2, X } from "lucide-react"; import { AlertTriangle, Info, ShieldCheck, Trash2 } from "lucide-react";
import { useCallback, useMemo, useState } from "react"; import { useCallback, useMemo, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { agentFlagsAtom } from "@/atoms/agent/agent-flags-query.atom"; import { agentFlagsAtom } from "@/atoms/agent/agent-flags-query.atom";
@ -20,6 +20,15 @@ import {
} from "@/components/ui/alert-dialog"; } from "@/components/ui/alert-dialog";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { import {
@ -29,6 +38,7 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import { Skeleton } from "@/components/ui/skeleton";
import { Spinner } from "@/components/ui/spinner"; import { Spinner } from "@/components/ui/spinner";
import { import {
type AgentPermissionAction, type AgentPermissionAction,
@ -67,20 +77,29 @@ function permissionRulesQueryKey(searchSpaceId: number) {
function ScopeBadge({ rule }: { rule: AgentPermissionRule }) { function ScopeBadge({ rule }: { rule: AgentPermissionRule }) {
if (rule.thread_id !== null) { if (rule.thread_id !== null) {
return ( return (
<Badge variant="outline" className="text-[10px]"> <Badge
variant="secondary"
className="text-[10px] px-1.5 py-0.5 border-0 text-muted-foreground bg-muted"
>
Thread #{rule.thread_id} Thread #{rule.thread_id}
</Badge> </Badge>
); );
} }
if (rule.user_id !== null) { if (rule.user_id !== null) {
return ( return (
<Badge variant="outline" className="text-[10px]"> <Badge
variant="secondary"
className="text-[10px] px-1.5 py-0.5 border-0 text-muted-foreground bg-muted"
>
User-specific User-specific
</Badge> </Badge>
); );
} }
return ( return (
<Badge variant="outline" className="text-[10px]"> <Badge
variant="secondary"
className="text-[10px] px-1.5 py-0.5 border-0 text-muted-foreground bg-muted"
>
Search space Search space
</Badge> </Badge>
); );
@ -170,8 +189,8 @@ export function AgentPermissionsContent() {
permission: formData.permission.trim(), permission: formData.permission.trim(),
pattern: formData.pattern.trim() || "*", pattern: formData.pattern.trim() || "*",
}); });
setShowForm(false);
setFormData(EMPTY_FORM); setFormData(EMPTY_FORM);
setShowForm(false);
} catch (err) { } catch (err) {
if (err instanceof AppError && err.message) { if (err instanceof AppError && err.message) {
// already toasted by onError // already toasted by onError
@ -190,13 +209,17 @@ export function AgentPermissionsContent() {
if (!featureEnabled) { if (!featureEnabled) {
return ( return (
<Alert className="border-dashed"> <Alert>
<ShieldCheck className="size-4" /> <Info />
<AlertTitle>Permission middleware is disabled</AlertTitle> <AlertTitle>Permission middleware is disabled</AlertTitle>
<AlertDescription> <AlertDescription>
Flip{" "} <p>
<code className="rounded bg-muted px-1 text-[10px]">SURFSENSE_ENABLE_PERMISSION</code> on Flip{" "}
the backend to manage allow/deny/ask rules from this panel. <code className="rounded bg-popover px-1 py-0.5 text-[10px] text-popover-foreground">
SURFSENSE_ENABLE_PERMISSION
</code>{" "}
on the backend to manage allow/deny/ask rules from this panel.
</p>
</AlertDescription> </AlertDescription>
</Alert> </Alert>
); );
@ -208,28 +231,8 @@ export function AgentPermissionsContent() {
); );
} }
if (isLoading) {
return (
<div className="flex items-center justify-center py-12">
<Spinner className="size-6" />
</div>
);
}
if (isError) {
return (
<div className="rounded-lg border border-dashed border-destructive/40 p-8 text-center">
<AlertTriangle className="mx-auto size-8 text-destructive/60" />
<p className="mt-2 text-sm text-destructive">Failed to load rules</p>
<p className="text-xs text-muted-foreground">
{error instanceof Error ? error.message : "Unknown error."}
</p>
</div>
);
}
return ( return (
<div className="min-w-0 space-y-6 overflow-hidden"> <div className="min-w-0 space-y-6 overflow-visible">
<div className="flex items-start justify-between gap-3"> <div className="flex items-start justify-between gap-3">
<div className="space-y-1"> <div className="space-y-1">
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
@ -237,27 +240,36 @@ export function AgentPermissionsContent() {
patterns and are evaluated at the most specific scope first. patterns and are evaluated at the most specific scope first.
</p> </p>
</div> </div>
{!showForm && ( <Button
<Button size="sm"
size="sm" onClick={() => {
onClick={() => { setShowForm(true);
setShowForm(true); setFormData(EMPTY_FORM);
setFormData(EMPTY_FORM); }}
}} className="shrink-0 gap-1.5"
className="shrink-0 gap-1.5" >
> New rule
<Plus className="size-3.5" /> </Button>
New rule
</Button>
)}
</div> </div>
{showForm && ( <Dialog
<div className="rounded-lg border border-border/60 bg-card p-6"> open={showForm}
<div className="space-y-4"> onOpenChange={(open) => {
<h3 className="text-sm font-semibold tracking-tight">New permission rule</h3> setShowForm(open);
if (!open) setFormData(EMPTY_FORM);
}}
>
<DialogContent className="max-w-lg bg-popover text-popover-foreground">
<DialogHeader>
<DialogTitle>New permission rule</DialogTitle>
<DialogDescription>
Tell the agent whether matching tool calls should be allowed, denied, or paused for
approval.
</DialogDescription>
</DialogHeader>
<div className="grid grid-cols-2 gap-3"> <div className="space-y-4">
<div className="grid gap-3">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="permission-name">Permission</Label> <Label htmlFor="permission-name">Permission</Label>
<Input <Input
@ -297,43 +309,69 @@ export function AgentPermissionsContent() {
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="allow">Allow run without asking</SelectItem> <SelectItem value="allow">Allow (run without asking)</SelectItem>
<SelectItem value="ask">Ask pause for approval</SelectItem> <SelectItem value="ask">Ask (pause for approval)</SelectItem>
<SelectItem value="deny">Deny block silently</SelectItem> <SelectItem value="deny">Deny (block silently)</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
<p className="text-[11px] text-muted-foreground"> <p className="text-[11px] text-muted-foreground">
{ACTION_DESCRIPTIONS[formData.action]} {ACTION_DESCRIPTIONS[formData.action]}
</p> </p>
</div> </div>
<div className="flex items-center justify-end gap-2 pt-2">
<Button
variant="ghost"
size="sm"
onClick={() => {
setShowForm(false);
setFormData(EMPTY_FORM);
}}
disabled={createMutation.isPending}
>
Cancel
</Button>
<Button
size="sm"
onClick={handleCreate}
disabled={createMutation.isPending || !formData.permission.trim()}
className="relative"
>
<span className={createMutation.isPending ? "opacity-0" : ""}>Create</span>
{createMutation.isPending && <Spinner className="absolute size-3.5" />}
</Button>
</div>
</div> </div>
<DialogFooter>
<Button
type="button"
variant="secondary"
size="sm"
onClick={() => {
setShowForm(false);
setFormData(EMPTY_FORM);
}}
disabled={createMutation.isPending}
className="text-sm h-9"
>
Cancel
</Button>
<Button
size="sm"
onClick={handleCreate}
disabled={createMutation.isPending || !formData.permission.trim()}
className="relative text-sm h-9 min-w-[96px]"
>
<span className={createMutation.isPending ? "opacity-0" : ""}>Create</span>
{createMutation.isPending && <Spinner size="sm" className="absolute" />}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{isLoading && (
<div className="-m-1 space-y-2 p-1">
{["skeleton-a", "skeleton-b", "skeleton-c"].map((key) => (
<Card key={key} className="border-accent bg-accent/20">
<CardContent className="p-4 flex flex-col gap-3 min-h-24">
<Skeleton className="h-4 w-32 md:w-40 bg-accent" />
<Skeleton className="h-3 w-full bg-accent" />
<Skeleton className="h-3 w-24 md:w-28 bg-accent mt-auto" />
</CardContent>
</Card>
))}
</div> </div>
)} )}
{sortedRules.length === 0 && !showForm && ( {isError && (
<Alert variant="destructive">
<AlertTriangle />
<AlertTitle>Failed to load rules</AlertTitle>
<AlertDescription>
{error instanceof Error ? error.message : "Unknown error."}
</AlertDescription>
</Alert>
)}
{!isLoading && !isError && sortedRules.length === 0 && !showForm && (
<div className="rounded-lg border border-dashed border-border/60 p-8 text-center"> <div className="rounded-lg border border-dashed border-border/60 p-8 text-center">
<ShieldCheck className="mx-auto size-8 text-muted-foreground/40" /> <ShieldCheck className="mx-auto size-8 text-muted-foreground/40" />
<p className="mt-2 text-sm text-muted-foreground">No rules yet</p> <p className="mt-2 text-sm text-muted-foreground">No rules yet</p>
@ -343,8 +381,8 @@ export function AgentPermissionsContent() {
</div> </div>
)} )}
{sortedRules.length > 0 && ( {!isLoading && !isError && sortedRules.length > 0 && (
<div className="space-y-2"> <div className="-m-1 space-y-2 p-1">
{sortedRules.map((rule) => { {sortedRules.map((rule) => {
const badge = ACTION_BADGE[rule.action]; const badge = ACTION_BADGE[rule.action];
const isUpdating = const isUpdating =
@ -352,14 +390,14 @@ export function AgentPermissionsContent() {
const isDeleting = deleteMutation.isPending && deleteMutation.variables === rule.id; const isDeleting = deleteMutation.isPending && deleteMutation.variables === rule.id;
return ( return (
<div <Card
key={rule.id} key={rule.id}
className="group flex flex-col gap-3 rounded-lg border border-border/60 bg-card p-4" className="group relative overflow-hidden transition-all duration-200 border-accent bg-accent/20 hover:shadow-md h-full"
> >
<div className="flex items-start justify-between gap-3"> <CardContent className="p-4 flex items-center justify-between gap-3 h-full">
<div className="flex min-w-0 flex-1 flex-col gap-1.5"> <div className="flex min-w-0 flex-1 flex-col gap-1.5">
<div className="flex flex-wrap items-center gap-1.5"> <div className="flex flex-wrap items-center gap-1.5">
<code className="truncate rounded bg-muted px-1.5 py-0.5 font-mono text-xs"> <code className="truncate font-mono text-sm font-medium text-foreground">
{rule.permission} {rule.permission}
</code> </code>
{rule.pattern !== "*" && ( {rule.pattern !== "*" && (
@ -374,7 +412,7 @@ export function AgentPermissionsContent() {
</p> </p>
</div> </div>
<div className="flex shrink-0 items-center gap-1"> <div className="flex shrink-0 items-center self-center gap-1">
<Select <Select
value={rule.action} value={rule.action}
onValueChange={(value) => onValueChange={(value) =>
@ -389,11 +427,7 @@ export function AgentPermissionsContent() {
className={cn("h-8 gap-1 border px-2 text-[11px]", badge.className)} className={cn("h-8 gap-1 border px-2 text-[11px]", badge.className)}
> >
<SelectValue> <SelectValue>
<span className="flex items-center gap-1"> <span className="flex items-center gap-1">{badge.label}</span>
{rule.action === "allow" && <Check className="size-3" />}
{rule.action === "deny" && <X className="size-3" />}
{badge.label}
</span>
</SelectValue> </SelectValue>
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@ -406,7 +440,7 @@ export function AgentPermissionsContent() {
<Button <Button
size="sm" size="sm"
variant="ghost" variant="ghost"
className="size-8 p-0 text-muted-foreground hover:text-destructive" className="h-7 w-7 rounded-lg p-0 text-muted-foreground hover:text-destructive"
onClick={() => setDeleteTarget(rule.id)} onClick={() => setDeleteTarget(rule.id)}
disabled={isUpdating || isDeleting} disabled={isUpdating || isDeleting}
aria-label="Delete rule" aria-label="Delete rule"
@ -414,8 +448,8 @@ export function AgentPermissionsContent() {
<Trash2 className="size-3.5" /> <Trash2 className="size-3.5" />
</Button> </Button>
</div> </div>
</div> </CardContent>
</div> </Card>
); );
})} })}
</div> </div>

View file

@ -1,8 +1,8 @@
"use client"; "use client";
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
import { CircleCheck, CircleSlash, Cog, RotateCcw } from "lucide-react"; import { AlertTriangle, CircleCheck, CircleSlash, Info } from "lucide-react";
import { useMemo } from "react"; import { Fragment, useMemo } from "react";
import { agentFlagsAtom } from "@/atoms/agent/agent-flags-query.atom"; import { agentFlagsAtom } from "@/atoms/agent/agent-flags-query.atom";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
@ -136,7 +136,7 @@ const FLAG_GROUPS: FlagGroup[] = [
{ {
id: "tier5", id: "tier5",
title: "Tier 5 — Audit + revert", title: "Tier 5 — Audit + revert",
subtitle: "Action log + revert route used by the Agent Actions sheet.", subtitle: "Action log + revert route used by the Agent Actions dialog.",
flags: [ flags: [
{ {
key: "enable_action_log", key: "enable_action_log",
@ -222,7 +222,7 @@ function FlagRow({ def, value }: { def: FlagDef; value: boolean }) {
} }
export function AgentStatusContent() { export function AgentStatusContent() {
const { data: flags, isLoading, isError, error, refetch } = useAtomValue(agentFlagsAtom); const { data: flags, isLoading, isError, error } = useAtomValue(agentFlagsAtom);
const enabledCount = useMemo(() => { const enabledCount = useMemo(() => {
if (!flags) return 0; if (!flags) return 0;
@ -243,17 +243,10 @@ export function AgentStatusContent() {
if (isError || !flags) { if (isError || !flags) {
return ( return (
<Alert variant="destructive"> <Alert variant="destructive">
<AlertTriangle />
<AlertTitle>Failed to load agent status</AlertTitle> <AlertTitle>Failed to load agent status</AlertTitle>
<AlertDescription className="flex items-center gap-2"> <AlertDescription>
{error instanceof Error ? error.message : "Unknown error."} {error instanceof Error ? error.message : "Unknown error."}
<button
type="button"
onClick={() => refetch()}
className="ml-auto inline-flex items-center gap-1 rounded-md border px-2 py-0.5 text-xs hover:bg-background"
>
<RotateCcw className="size-3" />
Retry
</button>
</AlertDescription> </AlertDescription>
</Alert> </Alert>
); );
@ -265,28 +258,36 @@ export function AgentStatusContent() {
<div className="space-y-6"> <div className="space-y-6">
{masterOff ? ( {masterOff ? (
<Alert variant="destructive"> <Alert variant="destructive">
<Cog className="size-4" /> <AlertTriangle />
<AlertTitle>Master kill-switch is on</AlertTitle> <AlertTitle>Master kill-switch is on</AlertTitle>
<AlertDescription> <AlertDescription>
<code className="rounded bg-muted px-1 text-[10px]"> <p>
SURFSENSE_DISABLE_NEW_AGENT_STACK=true Showing that{" "}
</code> <code className="rounded bg-muted px-1 text-[10px]">
forces every new middleware off, regardless of the individual flags below. Restart the SURFSENSE_DISABLE_NEW_AGENT_STACK=true
backend after changing it. </code>
, which forces every new middleware off, regardless of the individual flags below.
Restart the backend after changing it.
</p>
</AlertDescription> </AlertDescription>
</Alert> </Alert>
) : ( ) : (
<Alert> <Alert>
<Cog className="size-4" /> <Info />
<AlertTitle className="flex items-center gap-2"> <AlertTitle className="flex items-center gap-2">
Agent stack Agent stack
<Badge variant="secondary" className="text-[10px]"> <Badge
variant="secondary"
className="rounded bg-popover px-1 py-0.5 text-[9px] text-popover-foreground"
>
{enabledCount} on {enabledCount} on
</Badge> </Badge>
</AlertTitle> </AlertTitle>
<AlertDescription> <AlertDescription>
Read-only mirror of the backend's <code>AgentFeatureFlags</code>. Flip an env var and <p>
restart the backend to change a value. Showing a read-only mirror of the backend's <code>AgentFeatureFlags</code>. Flip an
env var and restart the backend to change a value.
</p>
</AlertDescription> </AlertDescription>
</Alert> </Alert>
)} )}
@ -295,9 +296,9 @@ export function AgentStatusContent() {
const allOff = group.flags.every((f) => !flags[f.key]); const allOff = group.flags.every((f) => !flags[f.key]);
return ( return (
<div key={group.id}> <div key={group.id}>
{groupIdx > 0 && <Separator className="my-4" />} {groupIdx > 0 && <Separator className="my-4 bg-border" />}
<div className="rounded-lg border border-border/60 bg-card"> <div className="rounded-lg border border-border/60 bg-card">
<div className="flex items-start justify-between gap-3 border-b px-4 py-3"> <div className="flex items-start justify-between gap-3 px-4 py-3">
<div> <div>
<p className="text-sm font-semibold">{group.title}</p> <p className="text-sm font-semibold">{group.title}</p>
<p className="text-xs text-muted-foreground">{group.subtitle}</p> <p className="text-xs text-muted-foreground">{group.subtitle}</p>
@ -308,9 +309,13 @@ export function AgentStatusContent() {
</Badge> </Badge>
)} )}
</div> </div>
<div className="divide-y divide-border/50 px-4"> <Separator className="bg-border" />
{group.flags.map((def) => ( <div className="px-4">
<FlagRow key={def.key} def={def} value={flags[def.key]} /> {group.flags.map((def, flagIdx) => (
<Fragment key={def.key}>
{flagIdx > 0 && <Separator className="bg-border" />}
<FlagRow def={def} value={flags[def.key]} />
</Fragment>
))} ))}
</div> </div>
</div> </div>

View file

@ -5,6 +5,7 @@ import { useTranslations } from "next-intl";
import { useCallback, useRef, useState } from "react"; import { useCallback, useRef, useState } from "react";
import { Alert, AlertDescription } from "@/components/ui/alert"; import { Alert, AlertDescription } from "@/components/ui/alert";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { useApiKey } from "@/hooks/use-api-key"; import { useApiKey } from "@/hooks/use-api-key";
import { copyToClipboard as copyToClipboardUtil } from "@/lib/utils"; import { copyToClipboard as copyToClipboardUtil } from "@/lib/utils";
@ -27,17 +28,20 @@ export function ApiKeyContent() {
return ( return (
<div className="space-y-6 min-w-0 overflow-hidden"> <div className="space-y-6 min-w-0 overflow-hidden">
<Alert className="bg-muted/50 py-3 md:py-4"> <Alert>
<Info className="h-3 w-3 md:h-4 md:w-4 shrink-0" /> <Info />
<AlertDescription className="text-xs md:text-sm"> <AlertDescription>{t("api_key_warning_description")}</AlertDescription>
{t("api_key_warning_description")}
</AlertDescription>
</Alert> </Alert>
<div className="rounded-lg border border-border/60 bg-card p-6 min-w-0 overflow-hidden"> <div className="min-w-0 overflow-hidden">
<h3 className="mb-4 text-sm font-semibold tracking-tight">{t("your_api_key")}</h3> <h3 className="mb-4 text-sm font-semibold tracking-tight">{t("your_api_key")}</h3>
{isLoading ? ( {isLoading ? (
<div className="h-12 w-full animate-pulse rounded-md border border-border/60 bg-muted/30" /> <div className="flex items-center gap-2 rounded-md border border-border/60 bg-muted/30 px-2.5 py-1.5">
<div className="min-w-0 flex-1 overflow-hidden">
<Skeleton className="h-3 w-full bg-accent" />
</div>
<div className="h-6 w-6 shrink-0" />
</div>
) : apiKey ? ( ) : apiKey ? (
<div className="flex items-center gap-2 rounded-md border border-border/60 bg-muted/30 px-2.5 py-1.5"> <div className="flex items-center gap-2 rounded-md border border-border/60 bg-muted/30 px-2.5 py-1.5">
<div className="min-w-0 flex-1 overflow-x-auto scrollbar-hide"> <div className="min-w-0 flex-1 overflow-x-auto scrollbar-hide">
@ -52,7 +56,7 @@ export function ApiKeyContent() {
variant="ghost" variant="ghost"
size="icon" size="icon"
onClick={copyToClipboard} onClick={copyToClipboard}
className="h-6 w-6 shrink-0 text-muted-foreground hover:text-foreground" className="h-6 w-6 shrink-0 text-muted-foreground hover:text-accent-foreground"
> >
{copied ? ( {copied ? (
<Check className="h-3 w-3 text-green-500" /> <Check className="h-3 w-3 text-green-500" />
@ -70,7 +74,7 @@ export function ApiKeyContent() {
)} )}
</div> </div>
<div className="rounded-lg border border-border/60 bg-card p-6 min-w-0 overflow-hidden"> <div className="min-w-0 overflow-hidden">
<h3 className="mb-2 text-sm font-semibold tracking-tight">{t("usage_title")}</h3> <h3 className="mb-2 text-sm font-semibold tracking-tight">{t("usage_title")}</h3>
<p className="mb-4 text-[11px] text-muted-foreground/60">{t("usage_description")}</p> <p className="mb-4 text-[11px] text-muted-foreground/60">{t("usage_description")}</p>
<div className="flex items-center gap-2 rounded-md border border-border/60 bg-muted/30 px-2.5 py-1.5"> <div className="flex items-center gap-2 rounded-md border border-border/60 bg-muted/30 px-2.5 py-1.5">
@ -86,7 +90,7 @@ export function ApiKeyContent() {
variant="ghost" variant="ghost"
size="icon" size="icon"
onClick={copyUsageToClipboard} onClick={copyUsageToClipboard}
className="h-6 w-6 shrink-0 text-muted-foreground hover:text-foreground" className="h-6 w-6 shrink-0 text-muted-foreground hover:text-accent-foreground"
> >
{copiedUsage ? ( {copiedUsage ? (
<Check className="h-3 w-3 text-green-500" /> <Check className="h-3 w-3 text-green-500" />

View file

@ -1,11 +1,14 @@
"use client"; "use client";
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
import { AlertTriangle, Copy, Globe, Sparkles } from "lucide-react"; import { AlertTriangle, Copy, Library } from "lucide-react";
import { useCallback, useState } from "react"; import { useCallback, useState } from "react";
import { copyPromptMutationAtom } from "@/atoms/prompts/prompts-mutation.atoms"; import { copyPromptMutationAtom } from "@/atoms/prompts/prompts-mutation.atoms";
import { publicPromptsAtom } from "@/atoms/prompts/prompts-query.atoms"; import { publicPromptsAtom } from "@/atoms/prompts/prompts-query.atoms";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { Spinner } from "@/components/ui/spinner"; import { Spinner } from "@/components/ui/spinner";
export function CommunityPromptsContent() { export function CommunityPromptsContent() {
@ -34,33 +37,37 @@ export function CommunityPromptsContent() {
const list = prompts ?? []; const list = prompts ?? [];
if (isLoading) {
return (
<div className="flex items-center justify-center py-12">
<Spinner className="size-6" />
</div>
);
}
if (isError) {
return (
<div className="rounded-lg border border-dashed border-destructive/40 p-8 text-center">
<AlertTriangle className="mx-auto size-8 text-destructive/60" />
<p className="mt-2 text-sm text-destructive">Failed to load community prompts</p>
<p className="text-xs text-muted-foreground">Please try refreshing the page.</p>
</div>
);
}
return ( return (
<div className="space-y-6 min-w-0 overflow-hidden"> <div className="space-y-6 min-w-0 overflow-hidden">
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Prompts shared by other users. Add any to your collection with one click. Prompts shared by other users. Add any to your collection with one click.
</p> </p>
{list.length === 0 && ( {isLoading && (
<div className="space-y-2">
{["skeleton-a", "skeleton-b", "skeleton-c"].map((key) => (
<Card key={key} className="border-accent bg-accent/20">
<CardContent className="p-4 flex flex-col gap-3 min-h-24">
<Skeleton className="h-4 w-32 md:w-40 bg-accent" />
<Skeleton className="h-3 w-full bg-accent" />
<Skeleton className="h-3 w-24 md:w-28 bg-accent mt-auto" />
</CardContent>
</Card>
))}
</div>
)}
{isError && (
<Alert variant="destructive">
<AlertTriangle />
<AlertTitle>Failed to load community prompts</AlertTitle>
<AlertDescription>Please try refreshing the page.</AlertDescription>
</Alert>
)}
{!isLoading && !isError && list.length === 0 && (
<div className="rounded-lg border border-dashed border-border/60 p-8 text-center"> <div className="rounded-lg border border-dashed border-border/60 p-8 text-center">
<Globe className="mx-auto size-8 text-muted-foreground" /> <Library className="mx-auto size-8 text-muted-foreground" />
<p className="mt-2 text-sm text-muted-foreground">No community prompts yet</p> <p className="mt-2 text-sm text-muted-foreground">No community prompts yet</p>
<p className="text-xs text-muted-foreground/60"> <p className="text-xs text-muted-foreground/60">
Share your own prompts from the My Prompts tab Share your own prompts from the My Prompts tab
@ -68,58 +75,58 @@ export function CommunityPromptsContent() {
</div> </div>
)} )}
{list.length > 0 && ( {!isLoading && !isError && list.length > 0 && (
<div className="space-y-2"> <div className="space-y-2">
{list.map((prompt) => ( {list.map((prompt) => (
<div <Card
key={prompt.id} key={prompt.id}
className="group flex items-start gap-3 rounded-lg border border-border/60 bg-card p-4" className="group relative overflow-hidden transition-all duration-200 border-accent bg-accent/20 hover:shadow-md h-full"
> >
<div className="mt-0.5 shrink-0 text-muted-foreground"> <CardContent className="p-4 flex items-start gap-3 h-full">
<Sparkles className="size-4" /> <div className="flex-1 min-w-0">
</div> <div className="flex items-center gap-2">
<div className="flex-1 min-w-0"> <span className="text-sm font-medium">{prompt.name}</span>
<div className="flex items-center gap-2"> <span className="rounded-md border-0 bg-muted px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground">
<span className="text-sm font-medium">{prompt.name}</span> {prompt.mode}
<span className="rounded-full border px-2 py-0.5 text-[10px] text-muted-foreground">
{prompt.mode}
</span>
{prompt.author_name && (
<span className="text-[11px] text-muted-foreground/60">
by {prompt.author_name}
</span> </span>
{prompt.author_name && (
<span className="text-[11px] text-muted-foreground/60">
by {prompt.author_name}
</span>
)}
</div>
<p
className={`mt-1 text-xs text-muted-foreground ${expandedId === prompt.id ? "whitespace-pre-wrap" : "line-clamp-2"}`}
>
{prompt.prompt}
</p>
{prompt.prompt.length > 100 && (
<Button
type="button"
variant="link"
onClick={() => setExpandedId(expandedId === prompt.id ? null : prompt.id)}
className="mt-1 h-auto cursor-pointer px-0 py-0 text-[11px] text-primary"
>
{expandedId === prompt.id ? "See less" : "See more"}
</Button>
)} )}
</div> </div>
<p <Button
className={`mt-1 text-xs text-muted-foreground ${expandedId === prompt.id ? "whitespace-pre-wrap" : "line-clamp-2"}`} variant="ghost"
size="sm"
className="h-7 shrink-0 gap-1.5 rounded-lg px-2 text-muted-foreground hover:text-accent-foreground"
disabled={copyingIds.has(prompt.id)}
onClick={() => handleCopy(prompt.id)}
> >
{prompt.prompt} {copyingIds.has(prompt.id) ? (
</p> <Spinner className="size-3" />
{prompt.prompt.length > 100 && ( ) : (
<button <Copy className="size-3" />
type="button" )}
onClick={() => setExpandedId(expandedId === prompt.id ? null : prompt.id)} Add to mine
className="mt-1 text-[11px] text-primary hover:underline cursor-pointer" </Button>
> </CardContent>
{expandedId === prompt.id ? "See less" : "See more"} </Card>
</button>
)}
</div>
<Button
variant="outline"
size="sm"
className="shrink-0 gap-1.5"
disabled={copyingIds.has(prompt.id)}
onClick={() => handleCopy(prompt.id)}
>
{copyingIds.has(prompt.id) ? (
<Spinner className="size-3" />
) : (
<Copy className="size-3" />
)}
Add to mine
</Button>
</div>
))} ))}
</div> </div>
)} )}

View file

@ -2,7 +2,6 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { import {
Select, Select,
@ -11,7 +10,8 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import { Spinner } from "@/components/ui/spinner"; import { Separator } from "@/components/ui/separator";
import { Skeleton } from "@/components/ui/skeleton";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import type { SearchSpace } from "@/contracts/types/search-space.types"; import type { SearchSpace } from "@/contracts/types/search-space.types";
import { useElectronAPI } from "@/hooks/use-platform"; import { useElectronAPI } from "@/hooks/use-platform";
@ -77,8 +77,27 @@ export function DesktopContent() {
if (loading) { if (loading) {
return ( return (
<div className="flex items-center justify-center py-12"> <div className="flex flex-col gap-4 md:gap-6">
<Spinner size="md" className="text-muted-foreground" /> <section>
<div className="flex flex-col gap-2 pb-2 md:pb-3">
<Skeleton className="h-6 w-48 bg-accent" />
<Skeleton className="h-4 w-full max-w-2xl bg-accent" />
</div>
<Skeleton className="h-10 w-full bg-accent" />
</section>
<Separator className="bg-border" />
<section>
<div className="flex flex-col gap-2 pb-2 md:pb-3">
<Skeleton className="h-6 w-44 bg-accent" />
<Skeleton className="h-4 w-full max-w-3xl bg-accent" />
</div>
<div className="flex flex-col gap-3">
<Skeleton className="h-20 w-full bg-accent" />
<Skeleton className="h-20 w-full bg-accent" />
</div>
</section>
</div> </div>
); );
} }
@ -124,16 +143,16 @@ export function DesktopContent() {
}; };
return ( return (
<div className="space-y-4 md:space-y-6"> <div className="flex flex-col gap-4 md:gap-6">
<Card> <section>
<CardHeader className="px-3 md:px-6 pt-3 md:pt-6 pb-2 md:pb-3"> <div className="pb-2 md:pb-3">
<CardTitle className="text-base md:text-lg">Default Search Space</CardTitle> <h2 className="text-base md:text-lg font-semibold">Default Search Space</h2>
<CardDescription className="text-xs md:text-sm"> <p className="text-xs md:text-sm text-muted-foreground">
Choose which search space General Assist, Screenshot Assist, and Quick Assist use by Choose which search space General Assist, Screenshot Assist, and Quick Assist use by
default. default.
</CardDescription> </p>
</CardHeader> </div>
<CardContent className="px-3 md:px-6 pb-3 md:pb-6"> <div>
{searchSpaces.length > 0 ? ( {searchSpaces.length > 0 ? (
<Select value={activeSpaceId ?? undefined} onValueChange={handleSearchSpaceChange}> <Select value={activeSpaceId ?? undefined} onValueChange={handleSearchSpaceChange}>
<SelectTrigger className="w-full"> <SelectTrigger className="w-full">
@ -152,21 +171,23 @@ export function DesktopContent() {
No search spaces found. Create one first. No search spaces found. Create one first.
</p> </p>
)} )}
</CardContent> </div>
</Card> </section>
<Card> <Separator className="bg-border" />
<CardHeader className="px-3 md:px-6 pt-3 md:pt-6 pb-2 md:pb-3">
<CardTitle className="text-base md:text-lg flex items-center gap-2"> <section>
<div className="pb-2 md:pb-3">
<h2 className="text-base md:text-lg font-semibold flex items-center gap-2">
Launch on Startup Launch on Startup
</CardTitle> </h2>
<CardDescription className="text-xs md:text-sm"> <p className="text-xs md:text-sm text-muted-foreground">
Automatically start SurfSense when you sign in to your computer so global shortcuts and Automatically start SurfSense when you sign in to your computer so global shortcuts and
folder sync are always available. folder sync are always available.
</CardDescription> </p>
</CardHeader> </div>
<CardContent className="px-3 md:px-6 pb-3 md:pb-6 space-y-3"> <div className="flex flex-col gap-3">
<div className="flex items-center justify-between rounded-lg border p-4"> <div className="flex items-center justify-between rounded-lg bg-accent p-4">
<div className="space-y-0.5"> <div className="space-y-0.5">
<Label htmlFor="auto-launch-toggle" className="text-sm font-medium cursor-pointer"> <Label htmlFor="auto-launch-toggle" className="text-sm font-medium cursor-pointer">
Open SurfSense at login Open SurfSense at login
@ -184,7 +205,7 @@ export function DesktopContent() {
disabled={!autoLaunchSupported} disabled={!autoLaunchSupported}
/> />
</div> </div>
<div className="flex items-center justify-between rounded-lg border p-4"> <div className="flex items-center justify-between rounded-lg bg-accent p-4">
<div className="space-y-0.5"> <div className="space-y-0.5">
<Label <Label
htmlFor="auto-launch-hidden-toggle" htmlFor="auto-launch-hidden-toggle"
@ -193,7 +214,7 @@ export function DesktopContent() {
Start minimized to tray Start minimized to tray
</Label> </Label>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Skip the main window on boot SurfSense lives in the system tray until you need it. Skip the main window on boot. SurfSense lives in the system tray until you need it.
</p> </p>
</div> </div>
<Switch <Switch
@ -203,8 +224,8 @@ export function DesktopContent() {
disabled={!autoLaunchSupported || !autoLaunchEnabled} disabled={!autoLaunchSupported || !autoLaunchEnabled}
/> />
</div> </div>
</CardContent> </div>
</Card> </section>
</div> </div>
); );
} }

View file

@ -5,6 +5,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { DEFAULT_SHORTCUTS, keyEventToAccelerator } from "@/components/desktop/shortcut-recorder"; import { DEFAULT_SHORTCUTS, keyEventToAccelerator } from "@/components/desktop/shortcut-recorder";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import { ShortcutKbd } from "@/components/ui/shortcut-kbd"; import { ShortcutKbd } from "@/components/ui/shortcut-kbd";
import { Spinner } from "@/components/ui/spinner"; import { Spinner } from "@/components/ui/spinner";
import { useElectronAPI } from "@/hooks/use-platform"; import { useElectronAPI } from "@/hooks/use-platform";
@ -78,7 +79,7 @@ function HotkeyRow({
); );
return ( return (
<div className="flex items-center justify-between gap-2.5 border-border/60 border-b py-3 last:border-b-0"> <div className="flex items-center justify-between gap-2.5 py-3">
<div className="flex items-center gap-2.5 min-w-0"> <div className="flex items-center gap-2.5 min-w-0">
<div className="flex size-7 shrink-0 items-center justify-center rounded-md bg-primary/10 text-primary"> <div className="flex size-7 shrink-0 items-center justify-center rounded-md bg-primary/10 text-primary">
<Icon className="size-3.5" /> <Icon className="size-3.5" />
@ -90,38 +91,39 @@ function HotkeyRow({
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
className="size-7 text-muted-foreground hover:text-foreground" className="size-7 text-muted-foreground hover:text-accent-foreground"
onClick={onReset} onClick={onReset}
title="Reset to default" title="Reset to default"
> >
<RotateCcw className="size-3" /> <RotateCcw className="size-3" />
</Button> </Button>
)} )}
<button <Button
ref={inputRef} ref={inputRef}
type="button" type="button"
variant="ghost"
title={recording ? "Press shortcut keys" : "Click to edit shortcut"} title={recording ? "Press shortcut keys" : "Click to edit shortcut"}
onClick={() => setRecording(true)} onClick={() => setRecording(true)}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
onBlur={() => setRecording(false)} onBlur={() => setRecording(false)}
className={ className={
recording recording
? "flex h-7 items-center rounded-md border border-transparent bg-primary/5 outline-none ring-0 focus:outline-none focus-visible:outline-none focus-visible:ring-0" ? "h-7 border border-transparent bg-primary/5 px-0 outline-none ring-0 focus:outline-none focus-visible:outline-none focus-visible:ring-0"
: "flex h-7 cursor-pointer items-center rounded-md border border-transparent bg-transparent outline-none ring-0 transition-colors hover:bg-accent hover:text-accent-foreground focus:outline-none focus-visible:outline-none focus-visible:ring-0" : "h-7 cursor-pointer border border-transparent bg-transparent px-0 outline-none ring-0 transition-colors hover:bg-accent hover:text-accent-foreground focus:outline-none focus-visible:outline-none focus-visible:ring-0"
} }
> >
{recording ? ( {recording ? (
<span className="px-2 text-[9px] text-primary whitespace-nowrap">Press hotkeys...</span> <span className="px-2 text-[9px] text-primary whitespace-nowrap">Press hotkeys</span>
) : ( ) : (
<ShortcutKbd keys={displayKeys} className="ml-0 px-1.5 text-foreground/85" /> <ShortcutKbd keys={displayKeys} className="ml-0 px-1.5 text-foreground/85" />
)} )}
</button> </Button>
</div> </div>
</div> </div>
); );
} }
export function DesktopShortcutsContent() { export function HotkeysContent() {
const api = useElectronAPI(); const api = useElectronAPI();
const [shortcuts, setShortcuts] = useState(DEFAULT_SHORTCUTS); const [shortcuts, setShortcuts] = useState(DEFAULT_SHORTCUTS);
const [shortcutsLoaded, setShortcutsLoaded] = useState(false); const [shortcutsLoaded, setShortcutsLoaded] = useState(false);
@ -178,17 +180,19 @@ export function DesktopShortcutsContent() {
return shortcutsLoaded ? ( return shortcutsLoaded ? (
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<div> <div>
{HOTKEY_ROWS.map((row) => ( {HOTKEY_ROWS.map((row, index) => (
<HotkeyRow <div key={row.key}>
key={row.key} <HotkeyRow
label={row.label} label={row.label}
value={shortcuts[row.key]} value={shortcuts[row.key]}
defaultValue={DEFAULT_SHORTCUTS[row.key]} defaultValue={DEFAULT_SHORTCUTS[row.key]}
icon={row.icon} icon={row.icon}
isMac={isMac} isMac={isMac}
onChange={(accel) => updateShortcut(row.key, accel)} onChange={(accel) => updateShortcut(row.key, accel)}
onReset={() => resetShortcut(row.key)} onReset={() => resetShortcut(row.key)}
/> />
{index < HOTKEY_ROWS.length - 1 ? <Separator className="bg-border" /> : null}
</div>
))} ))}
</div> </div>
</div> </div>

View file

@ -177,9 +177,9 @@ export function MemoryContent() {
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<Alert className="bg-muted/50 py-3 md:py-4"> <Alert>
<Info className="h-3 w-3 md:h-4 md:w-4 shrink-0" /> <Info />
<AlertDescription className="text-xs md:text-sm"> <AlertDescription>
<p> <p>
SurfSense uses this personal memory to personalize your responses across all SurfSense uses this personal memory to personalize your responses across all
conversations. conversations.
@ -222,7 +222,9 @@ export function MemoryContent() {
onClick={handleEdit} onClick={handleEdit}
disabled={editing || !editQuery.trim()} disabled={editing || !editQuery.trim()}
className={`h-11 w-11 shrink-0 rounded-full ${ className={`h-11 w-11 shrink-0 rounded-full ${
editing ? "" : "bg-muted-foreground/15 hover:bg-muted-foreground/20" editing
? ""
: "bg-muted-foreground/15 hover:bg-accent hover:text-accent-foreground"
}`} }`}
> >
{editing ? ( {editing ? (

View file

@ -11,8 +11,17 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Spinner } from "@/components/ui/spinner"; import { Spinner } from "@/components/ui/spinner";
import { getUserAvatarColor, getUserInitials } from "@/lib/user-avatar";
function AvatarDisplay({ url, fallback }: { url?: string; fallback: string }) { function AvatarDisplay({
url,
fallback,
bgColor,
}: {
url?: string;
fallback: string;
bgColor: string;
}) {
const [errorUrl, setErrorUrl] = useState<string>(); const [errorUrl, setErrorUrl] = useState<string>();
const hasError = errorUrl === url; const hasError = errorUrl === url;
@ -23,15 +32,19 @@ function AvatarDisplay({ url, fallback }: { url?: string; fallback: string }) {
alt="Avatar" alt="Avatar"
width={64} width={64}
height={64} height={64}
className="h-16 w-16 rounded-xl object-cover" className="h-16 w-16 rounded-full object-cover select-none"
onError={() => setErrorUrl(url)} onError={() => setErrorUrl(url)}
referrerPolicy="no-referrer"
unoptimized unoptimized
/> />
); );
} }
return ( return (
<div className="flex h-16 w-16 items-center justify-center rounded-xl bg-muted text-xl font-semibold text-muted-foreground"> <div
className="flex h-16 w-16 shrink-0 items-center justify-center rounded-full text-xl font-semibold text-white select-none"
style={{ backgroundColor: bgColor }}
>
{fallback} {fallback}
</div> </div>
); );
@ -50,11 +63,6 @@ export function ProfileContent() {
} }
}, [user]); }, [user]);
const getInitials = (email: string) => {
const name = email.split("@")[0];
return name.slice(0, 2).toUpperCase();
};
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
@ -69,6 +77,7 @@ export function ProfileContent() {
}; };
const hasChanges = displayName !== (user?.display_name || ""); const hasChanges = displayName !== (user?.display_name || "");
const avatarBgColor = getUserAvatarColor(user?.email || "");
return ( return (
<div> <div>
@ -78,13 +87,13 @@ export function ProfileContent() {
</div> </div>
) : ( ) : (
<form onSubmit={handleSubmit} className="space-y-6"> <form onSubmit={handleSubmit} className="space-y-6">
<div className="rounded-lg bg-card"> <div className="rounded-lg bg-main-panel">
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
<div className="space-y-2"> <div className="space-y-2">
<Label>{t("profile_avatar")}</Label>
<AvatarDisplay <AvatarDisplay
url={user?.avatar_url || undefined} url={user?.avatar_url || undefined}
fallback={getInitials(user?.email || "")} fallback={getUserInitials(user?.email || "")}
bgColor={avatarBgColor}
/> />
</div> </div>
@ -114,7 +123,7 @@ export function ProfileContent() {
type="submit" type="submit"
variant="outline" variant="outline"
disabled={isPending || !hasChanges} disabled={isPending || !hasChanges}
className="relative gap-2 bg-white text-black hover:bg-neutral-100 dark:bg-white dark:text-black dark:hover:bg-neutral-200" className="relative gap-2 bg-white text-black hover:bg-accent hover:text-accent-foreground dark:bg-white dark:text-black"
> >
<span className={isPending ? "opacity-0" : ""}>{t("profile_save")}</span> <span className={isPending ? "opacity-0" : ""}>{t("profile_save")}</span>
{isPending && <Spinner size="sm" className="absolute" />} {isPending && <Spinner size="sm" className="absolute" />}

View file

@ -1,7 +1,7 @@
"use client"; "use client";
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
import { AlertTriangle, Globe, Lock, Pencil, Sparkles, Trash2 } from "lucide-react"; import { AlertTriangle, Globe, Lock, MoreHorizontal, Pencil, Sparkles, Trash2 } from "lucide-react";
import { useCallback, useState } from "react"; import { useCallback, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { import {
@ -10,6 +10,7 @@ import {
updatePromptMutationAtom, updatePromptMutationAtom,
} from "@/atoms/prompts/prompts-mutation.atoms"; } from "@/atoms/prompts/prompts-mutation.atoms";
import { promptsAtom } from "@/atoms/prompts/prompts-query.atoms"; import { promptsAtom } from "@/atoms/prompts/prompts-query.atoms";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { import {
AlertDialog, AlertDialog,
AlertDialogAction, AlertDialogAction,
@ -21,9 +22,32 @@ import {
AlertDialogTitle, AlertDialogTitle,
} from "@/components/ui/alert-dialog"; } from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { ShortcutKbd } from "@/components/ui/shortcut-kbd"; import { ShortcutKbd } from "@/components/ui/shortcut-kbd";
import { Skeleton } from "@/components/ui/skeleton";
import { Spinner } from "@/components/ui/spinner"; import { Spinner } from "@/components/ui/spinner";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import type { PromptRead } from "@/contracts/types/prompts.types"; import type { PromptRead } from "@/contracts/types/prompts.types";
@ -123,24 +147,6 @@ export function PromptsContent() {
const list = prompts ?? []; const list = prompts ?? [];
if (isLoading) {
return (
<div className="flex items-center justify-center py-12">
<Spinner className="size-6" />
</div>
);
}
if (isError) {
return (
<div className="rounded-lg border border-dashed border-destructive/40 p-8 text-center">
<AlertTriangle className="mx-auto size-8 text-destructive/60" />
<p className="mt-2 text-sm text-destructive">Failed to load prompts</p>
<p className="text-xs text-muted-foreground">Please try refreshing the page.</p>
</div>
);
}
return ( return (
<div className="space-y-6 min-w-0 overflow-hidden"> <div className="space-y-6 min-w-0 overflow-hidden">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
@ -148,97 +154,150 @@ export function PromptsContent() {
Create prompt templates triggered with <ShortcutKbd keys={["/"]} className="ml-0" /> in Create prompt templates triggered with <ShortcutKbd keys={["/"]} className="ml-0" /> in
the chat composer. the chat composer.
</p> </p>
{!showForm && ( <Button
<Button size="sm"
size="sm" onClick={() => {
onClick={() => { setShowForm(true);
setShowForm(true); setEditingId(null);
setEditingId(null); setFormData(EMPTY_FORM);
setFormData(EMPTY_FORM); }}
}} className="shrink-0 gap-1.5"
className="shrink-0 gap-1.5" >
> New
New </Button>
</Button>
)}
</div> </div>
{showForm && ( <Dialog
<div className="rounded-lg border border-border/60 bg-card p-6 space-y-4"> open={showForm}
<h3 className="text-sm font-semibold tracking-tight"> onOpenChange={(open) => {
{editingId !== null ? "Edit prompt" : "New prompt"} setShowForm(open);
</h3> if (!open) {
setFormData(EMPTY_FORM);
setEditingId(null);
}
}}
>
<DialogContent className="max-w-lg bg-popover text-popover-foreground">
<DialogHeader>
<DialogTitle>{editingId !== null ? "Edit prompt" : "New prompt"}</DialogTitle>
<DialogDescription>
Create prompt templates triggered with / in the chat composer.
</DialogDescription>
</DialogHeader>
<div className="space-y-2"> <div className="space-y-4">
<Label htmlFor="prompt-name">Name</Label> <div className="space-y-2">
<Input <Label htmlFor="prompt-name">Name</Label>
id="prompt-name" <Input
value={formData.name} id="prompt-name"
onChange={(e) => setFormData((p) => ({ ...p, name: e.target.value }))} value={formData.name}
placeholder="e.g. Fix grammar" onChange={(e) => setFormData((p) => ({ ...p, name: e.target.value }))}
/> placeholder="e.g. Fix grammar"
/>
</div>
<div className="space-y-2">
<Label htmlFor="prompt-template">Prompt template</Label>
<textarea
id="prompt-template"
value={formData.prompt}
onChange={(e) => setFormData((p) => ({ ...p, prompt: e.target.value }))}
placeholder="e.g. Fix the grammar in the following text:\n\n{selection}"
rows={4}
className="w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm outline-none resize-none focus:ring-1 focus:ring-ring"
/>
<p className="text-xs text-muted-foreground">
Use{" "}
<code className="rounded bg-muted px-1 py-0.5 font-mono text-[11px]">
{"{selection}"}
</code>{" "}
to insert the input text. If omitted, the text is appended automatically.
</p>
</div>
<div className="space-y-2">
<Label htmlFor="prompt-mode">Mode</Label>
<Select
value={formData.mode}
onValueChange={(value) =>
setFormData((p) => ({ ...p, mode: value as "transform" | "explore" }))
}
>
<SelectTrigger id="prompt-mode" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="transform">
Transform rewrites or modifies your text
</SelectItem>
<SelectItem value="explore">
Explore answers a question about your text
</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-2">
<Switch
id="prompt-public"
checked={formData.is_public}
onCheckedChange={(checked) => setFormData((p) => ({ ...p, is_public: checked }))}
/>
<Label htmlFor="prompt-public" className="text-sm font-normal">
Share with community
</Label>
</div>
</div> </div>
<div className="space-y-2"> <DialogFooter>
<Label htmlFor="prompt-template">Prompt template</Label> <Button
<textarea type="button"
id="prompt-template" variant="secondary"
value={formData.prompt} size="sm"
onChange={(e) => setFormData((p) => ({ ...p, prompt: e.target.value }))} onClick={handleCancel}
placeholder="e.g. Fix the grammar in the following text:\n\n{selection}" disabled={isSaving}
rows={4} className="text-sm h-9"
className="w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm outline-none resize-none focus:ring-1 focus:ring-ring"
/>
<p className="text-xs text-muted-foreground">
Use{" "}
<code className="rounded bg-muted px-1 py-0.5 font-mono text-[11px]">
{"{selection}"}
</code>{" "}
to insert the input text. If omitted, the text is appended automatically.
</p>
</div>
<div className="space-y-2">
<Label htmlFor="prompt-mode">Mode</Label>
<select
id="prompt-mode"
value={formData.mode}
onChange={(e) =>
setFormData((p) => ({ ...p, mode: e.target.value as "transform" | "explore" }))
}
className="w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-ring"
> >
<option value="transform">Transform rewrites or modifies your text</option>
<option value="explore">Explore answers a question about your text</option>
</select>
</div>
<div className="flex items-center gap-2">
<Switch
id="prompt-public"
checked={formData.is_public}
onCheckedChange={(checked) => setFormData((p) => ({ ...p, is_public: checked }))}
/>
<Label htmlFor="prompt-public" className="text-sm font-normal">
Share with community
</Label>
</div>
<div className="flex items-center justify-end gap-2 pt-2">
<Button variant="ghost" size="sm" onClick={handleCancel}>
Cancel Cancel
</Button> </Button>
<Button size="sm" onClick={handleSave} disabled={isSaving} className="relative"> <Button
size="sm"
onClick={handleSave}
disabled={isSaving}
className="relative text-sm h-9 min-w-[96px]"
>
<span className={isSaving ? "opacity-0" : ""}> <span className={isSaving ? "opacity-0" : ""}>
{editingId !== null ? "Update" : "Create"} {editingId !== null ? "Update" : "Create"}
</span> </span>
{isSaving && <Spinner className="size-3.5 absolute" />} {isSaving && <Spinner size="sm" className="absolute" />}
</Button> </Button>
</div> </DialogFooter>
</DialogContent>
</Dialog>
{isLoading && (
<div className="space-y-2">
{["skeleton-a", "skeleton-b", "skeleton-c"].map((key) => (
<Card key={key} className="border-accent bg-accent/20">
<CardContent className="p-4 flex flex-col gap-3 min-h-24">
<Skeleton className="h-4 w-32 md:w-40 bg-accent" />
<Skeleton className="h-3 w-full bg-accent" />
<Skeleton className="h-3 w-24 md:w-28 bg-accent mt-auto" />
</CardContent>
</Card>
))}
</div> </div>
)} )}
{list.length === 0 && !showForm && ( {isError && (
<Alert variant="destructive">
<AlertTriangle />
<AlertTitle>Failed to load prompts</AlertTitle>
<AlertDescription>Please try refreshing the page.</AlertDescription>
</Alert>
)}
{!isLoading && !isError && list.length === 0 && !showForm && (
<div className="rounded-lg border border-dashed border-border/60 p-8 text-center"> <div className="rounded-lg border border-dashed border-border/60 p-8 text-center">
<Sparkles className="mx-auto size-8 text-muted-foreground/40" /> <Sparkles className="mx-auto size-8 text-muted-foreground/40" />
<p className="mt-2 text-sm text-muted-foreground">No prompts yet</p> <p className="mt-2 text-sm text-muted-foreground">No prompts yet</p>
@ -248,24 +307,21 @@ export function PromptsContent() {
</div> </div>
)} )}
{list.length > 0 && ( {!isLoading && !isError && list.length > 0 && (
<div className="space-y-2"> <div className="space-y-2">
{list.map((prompt) => ( {list.map((prompt) => (
<div <div
key={prompt.id} key={prompt.id}
className="group flex items-start gap-3 rounded-lg border border-border/60 bg-card p-4" className="group relative flex items-start gap-3 overflow-hidden rounded-lg border border-accent bg-accent/20 p-4 transition-all duration-200 hover:shadow-md"
> >
<div className="mt-0.5 shrink-0 text-muted-foreground">
<Sparkles className="size-4" />
</div>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-sm font-medium">{prompt.name}</span> <span className="text-sm font-medium">{prompt.name}</span>
<span className="rounded-full border px-2 py-0.5 text-[10px] text-muted-foreground"> <span className="rounded-md border-0 bg-muted px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground">
{prompt.mode} {prompt.mode}
</span> </span>
{prompt.is_public && ( {prompt.is_public && (
<span className="flex items-center gap-1 rounded-full border border-primary/20 bg-primary/5 px-2 py-0.5 text-[10px] text-primary"> <span className="flex items-center gap-1 rounded-md border-0 bg-muted px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground">
<Globe className="size-2.5" /> <Globe className="size-2.5" />
Public Public
</span> </span>
@ -277,48 +333,55 @@ export function PromptsContent() {
{prompt.prompt} {prompt.prompt}
</p> </p>
{prompt.prompt.length > 100 && ( {prompt.prompt.length > 100 && (
<button <Button
type="button" type="button"
variant="link"
onClick={() => setExpandedId(expandedId === prompt.id ? null : prompt.id)} onClick={() => setExpandedId(expandedId === prompt.id ? null : prompt.id)}
className="mt-1 text-[11px] text-primary hover:underline cursor-pointer" className="mt-1 h-auto cursor-pointer px-0 py-0 text-[11px] text-primary"
> >
{expandedId === prompt.id ? "See less" : "See more"} {expandedId === prompt.id ? "See less" : "See more"}
</button> </Button>
)} )}
</div> </div>
<div className="hidden group-hover:flex items-center gap-1 shrink-0"> <DropdownMenu>
<button <DropdownMenuTrigger asChild>
type="button" <Button
title={prompt.is_public ? "Make private" : "Share with community"} type="button"
onClick={() => handleTogglePublic(prompt)} variant="ghost"
disabled={togglingPublicIds.has(prompt.id)} size="icon"
className="flex items-center justify-center size-7 rounded-md text-muted-foreground hover:text-foreground hover:bg-accent transition-colors disabled:opacity-50 disabled:pointer-events-none" className="h-7 w-7 shrink-0 self-center rounded-lg text-muted-foreground opacity-100 pointer-events-auto transition-opacity duration-150 hover:text-accent-foreground sm:opacity-0 sm:pointer-events-none sm:group-hover:opacity-100 sm:group-hover:pointer-events-auto"
> >
{togglingPublicIds.has(prompt.id) ? ( <MoreHorizontal className="size-3.5" />
<Spinner className="size-3.5" /> <span className="sr-only">Prompt actions</span>
) : prompt.is_public ? ( </Button>
<Lock className="size-3.5" /> </DropdownMenuTrigger>
) : ( <DropdownMenuContent align="end">
<Globe className="size-3.5" /> <DropdownMenuItem
)} onClick={() => handleTogglePublic(prompt)}
</button> disabled={togglingPublicIds.has(prompt.id)}
<Button >
variant="ghost" {togglingPublicIds.has(prompt.id) ? (
size="icon" <Spinner className="size-4" />
className="size-7" ) : prompt.is_public ? (
onClick={() => handleEdit(prompt)} <Lock className="size-4" />
> ) : (
<Pencil className="size-3.5" /> <Globe className="size-4" />
</Button> )}
<Button {prompt.is_public ? "Make private" : "Share with community"}
variant="ghost" </DropdownMenuItem>
size="icon" <DropdownMenuItem onClick={() => handleEdit(prompt)}>
className="size-7 text-destructive hover:text-destructive" <Pencil className="size-4" />
onClick={() => setDeleteTarget(prompt.id)} Edit
> </DropdownMenuItem>
<Trash2 className="size-3.5" /> <DropdownMenuItem
</Button> onClick={() => setDeleteTarget(prompt.id)}
</div> className="text-destructive focus:text-destructive"
>
<Trash2 className="size-4" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div> </div>
))} ))}
</div> </div>

View file

@ -0,0 +1,5 @@
import { DesktopContent } from "../components/DesktopContent";
export default function Page() {
return <DesktopContent />;
}

View file

@ -0,0 +1,5 @@
import { HotkeysContent } from "../components/HotkeysContent";
export default function Page() {
return <HotkeysContent />;
}

View file

@ -0,0 +1,188 @@
"use client";
import {
Brain,
CircleUser,
Keyboard,
KeyRound,
Library,
Monitor,
ReceiptText,
ShieldCheck,
WandSparkles,
Workflow,
} from "lucide-react";
import Link from "next/link";
import { useSelectedLayoutSegment } from "next/navigation";
import { useTranslations } from "next-intl";
import type React from "react";
import { useCallback, useMemo, useState } from "react";
import { Separator } from "@/components/ui/separator";
import { usePlatform } from "@/hooks/use-platform";
import { cn } from "@/lib/utils";
export type UserSettingsTab =
| "profile"
| "api-key"
| "prompts"
| "community-prompts"
| "memory"
| "agent-permissions"
| "agent-status"
| "purchases"
| "desktop"
| "hotkeys";
const DEFAULT_TAB: UserSettingsTab = "profile";
interface UserSettingsLayoutShellProps {
searchSpaceId: string;
children: React.ReactNode;
}
export function UserSettingsLayoutShell({ searchSpaceId, children }: UserSettingsLayoutShellProps) {
const t = useTranslations("userSettings");
const { isDesktop } = usePlatform();
const segment = useSelectedLayoutSegment();
const [tabScrollPos, setTabScrollPos] = useState<"start" | "middle" | "end">("start");
const handleTabScroll = useCallback((e: React.UIEvent<HTMLDivElement>) => {
const el = e.currentTarget;
const atStart = el.scrollLeft <= 2;
const atEnd = el.scrollWidth - el.scrollLeft - el.clientWidth <= 2;
setTabScrollPos(atStart ? "start" : atEnd ? "end" : "middle");
}, []);
const navItems = useMemo(
() => [
{
value: "profile" as const,
label: t("profile_nav_label"),
icon: <CircleUser className="h-4 w-4" />,
},
{
value: "api-key" as const,
label: t("api_key_nav_label"),
icon: <KeyRound className="h-4 w-4" />,
},
{
value: "prompts" as const,
label: "My Prompts",
icon: <WandSparkles className="h-4 w-4" />,
},
{
value: "community-prompts" as const,
label: "Community Prompts",
icon: <Library className="h-4 w-4" />,
},
{
value: "memory" as const,
label: "Memory",
icon: <Brain className="h-4 w-4" />,
},
{
value: "agent-permissions" as const,
label: "Agent Permissions",
icon: <ShieldCheck className="h-4 w-4" />,
},
{
value: "agent-status" as const,
label: "Agent Status",
icon: <Workflow className="h-4 w-4" />,
},
{
value: "purchases" as const,
label: "Purchase History",
icon: <ReceiptText className="h-4 w-4" />,
},
...(isDesktop
? [
{
value: "desktop" as const,
label: "App Preferences",
icon: <Monitor className="h-4 w-4" />,
},
{
value: "hotkeys" as const,
label: "Hotkeys",
icon: <Keyboard className="h-4 w-4" />,
},
]
: []),
],
[t, isDesktop]
);
const activeTab: UserSettingsTab =
segment && navItems.some((item) => item.value === segment)
? (segment as UserSettingsTab)
: DEFAULT_TAB;
const selectedLabel = navItems.find((item) => item.value === activeTab)?.label ?? t("title");
const hrefFor = (tab: UserSettingsTab) => `/dashboard/${searchSpaceId}/user-settings/${tab}`;
return (
<section className="flex h-full min-h-[min(680px,calc(100vh-5rem))] w-full select-none flex-col gap-6 md:pt-10 md:flex-row">
<div className="md:w-[220px] md:shrink-0">
<h1 className="mb-4 px-1 text-2xl font-semibold tracking-tight">{t("title")}</h1>
<nav className="hidden flex-col gap-0.5 md:flex">
{navItems.map((item) => (
<Link
key={item.value}
href={hrefFor(item.value)}
replace
scroll={false}
prefetch
className={cn(
"inline-flex h-auto items-center justify-start gap-3 rounded-md px-3 py-2.5 text-left text-sm font-medium transition-colors duration-150 focus:outline-none focus-visible:outline-none",
activeTab === item.value
? "bg-accent text-accent-foreground"
: "bg-transparent text-muted-foreground hover:bg-accent hover:text-accent-foreground"
)}
>
{item.icon}
{item.label}
</Link>
))}
</nav>
<div
className="overflow-x-auto border-b border-border pb-3 md:hidden"
onScroll={handleTabScroll}
style={{
maskImage: `linear-gradient(to right, ${tabScrollPos === "start" ? "black" : "transparent"}, black 24px, black calc(100% - 24px), ${tabScrollPos === "end" ? "black" : "transparent"})`,
WebkitMaskImage: `linear-gradient(to right, ${tabScrollPos === "start" ? "black" : "transparent"}, black 24px, black calc(100% - 24px), ${tabScrollPos === "end" ? "black" : "transparent"})`,
}}
>
<div className="flex gap-1">
{navItems.map((item) => (
<Link
key={item.value}
href={hrefFor(item.value)}
replace
scroll={false}
prefetch
className={cn(
"inline-flex h-auto shrink-0 items-center gap-2 rounded-md px-3 py-1.5 text-xs font-medium transition-colors duration-150 focus:outline-none focus-visible:outline-none",
activeTab === item.value
? "bg-accent text-accent-foreground"
: "bg-transparent text-muted-foreground hover:bg-accent hover:text-accent-foreground"
)}
>
{item.icon}
{item.label}
</Link>
))}
</div>
</div>
</div>
<div className="min-w-0 flex-1">
<div className="hidden md:block">
<h2 className="text-lg font-semibold">{selectedLabel}</h2>
<Separator className="mt-4 bg-border" />
</div>
<div className="min-w-0 pt-4 md:max-w-3xl">{children}</div>
</div>
</section>
);
}

View file

@ -0,0 +1,17 @@
import type React from "react";
import { use } from "react";
import { UserSettingsLayoutShell } from "./layout-shell";
export default function UserSettingsLayout({
params,
children,
}: {
params: Promise<{ search_space_id: string }>;
children: React.ReactNode;
}) {
const { search_space_id } = use(params);
return (
<UserSettingsLayoutShell searchSpaceId={search_space_id}>{children}</UserSettingsLayoutShell>
);
}

View file

@ -0,0 +1,5 @@
import { MemoryContent } from "../components/MemoryContent";
export default function Page() {
return <MemoryContent />;
}

View file

@ -0,0 +1,10 @@
import { redirect } from "next/navigation";
export default async function UserSettingsPage({
params,
}: {
params: Promise<{ search_space_id: string }>;
}) {
const { search_space_id } = await params;
redirect(`/dashboard/${search_space_id}/user-settings/profile`);
}

View file

@ -0,0 +1,5 @@
import { ProfileContent } from "../components/ProfileContent";
export default function Page() {
return <ProfileContent />;
}

View file

@ -0,0 +1,5 @@
import { PromptsContent } from "../components/PromptsContent";
export default function Page() {
return <PromptsContent />;
}

View file

@ -0,0 +1,5 @@
import { PurchaseHistoryContent } from "../components/PurchaseHistoryContent";
export default function Page() {
return <PurchaseHistoryContent />;
}

View file

@ -3,6 +3,7 @@
import { ExternalLink } from "lucide-react"; import { ExternalLink } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { useEffect, useMemo } from "react"; import { useEffect, useMemo } from "react";
import { Button } from "@/components/ui/button";
import { buildIssueUrl } from "@/lib/error-toast"; import { buildIssueUrl } from "@/lib/error-toast";
export default function DashboardError({ export default function DashboardError({
@ -39,13 +40,9 @@ export default function DashboardError({
)} )}
<div className="flex gap-2"> <div className="flex gap-2">
<button <Button type="button" onClick={reset}>
type="button"
onClick={reset}
className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 transition-colors"
>
Try again Try again
</button> </Button>
<Link <Link
href="/dashboard" href="/dashboard"
className="rounded-md border border-input bg-background px-4 py-2 text-sm font-medium hover:bg-accent hover:text-accent-foreground transition-colors" className="rounded-md border border-input bg-background px-4 py-2 text-sm font-medium hover:bg-accent hover:text-accent-foreground transition-colors"

View file

@ -1,6 +1,5 @@
"use client"; "use client";
import { IconBrandGoogleFilled } from "@tabler/icons-react";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { Crop, Eye, EyeOff, Rocket, RotateCcw, Zap } from "lucide-react"; import { Crop, Eye, EyeOff, Rocket, RotateCcw, Zap } from "lucide-react";
import Image from "next/image"; import Image from "next/image";
@ -24,6 +23,34 @@ const isGoogleAuth = AUTH_TYPE === "GOOGLE";
type ShortcutKey = "generalAssist" | "quickAsk" | "screenshotAssist"; type ShortcutKey = "generalAssist" | "quickAsk" | "screenshotAssist";
type ShortcutMap = typeof DEFAULT_SHORTCUTS; type ShortcutMap = typeof DEFAULT_SHORTCUTS;
function GoogleGLogo({ className }: { className?: string }) {
return (
<svg
className={className}
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 48 48"
aria-hidden="true"
>
<path
fill="#EA4335"
d="M24 9.5c3.54 0 6.71 1.22 9.21 3.6l6.85-6.85C35.9 2.38 30.47 0 24 0 14.62 0 6.51 5.38 2.56 13.22l7.98 6.19C12.43 13.72 17.74 9.5 24 9.5z"
/>
<path
fill="#4285F4"
d="M46.98 24.55c0-1.57-.15-3.09-.38-4.55H24v9.02h12.94c-.58 2.96-2.26 5.48-4.78 7.18l7.73 6c4.51-4.18 7.09-10.36 7.09-17.65z"
/>
<path
fill="#FBBC05"
d="M10.53 28.59c-.48-1.45-.76-2.99-.76-4.59s.27-3.14.76-4.59l-7.98-6.19C.92 16.46 0 20.12 0 24c0 3.88.92 7.54 2.56 10.78l7.97-6.19z"
/>
<path
fill="#34A853"
d="M24 48c6.48 0 11.93-2.13 15.89-5.81l-7.73-6c-2.15 1.45-4.92 2.3-8.16 2.3-6.26 0-11.57-4.22-13.47-9.91l-7.98 6.19C6.51 42.62 14.62 48 24 48z"
/>
</svg>
);
}
const HOTKEY_ROWS: Array<{ const HOTKEY_ROWS: Array<{
key: ShortcutKey; key: ShortcutKey;
label: string; label: string;
@ -134,25 +161,26 @@ function HotkeyRow({
<RotateCcw className="size-3" /> <RotateCcw className="size-3" />
</Button> </Button>
)} )}
<button <Button
ref={inputRef} ref={inputRef}
type="button" type="button"
variant="ghost"
title={recording ? "Press shortcut keys" : "Click to edit shortcut"} title={recording ? "Press shortcut keys" : "Click to edit shortcut"}
onClick={() => setRecording(true)} onClick={() => setRecording(true)}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
onBlur={() => setRecording(false)} onBlur={() => setRecording(false)}
className={ className={
recording recording
? "flex h-7 items-center rounded-md border border-transparent bg-primary/5 outline-none ring-0 focus:outline-none focus-visible:outline-none focus-visible:ring-0" ? "h-7 border border-transparent bg-primary/5 px-0 outline-none ring-0 focus:outline-none focus-visible:outline-none focus-visible:ring-0"
: "flex h-7 cursor-pointer items-center rounded-md border border-transparent bg-transparent outline-none ring-0 transition-colors hover:bg-accent hover:text-accent-foreground focus:outline-none focus-visible:outline-none focus-visible:ring-0" : "h-7 cursor-pointer border border-transparent bg-transparent px-0 outline-none ring-0 transition-colors hover:bg-accent hover:text-accent-foreground focus:outline-none focus-visible:outline-none focus-visible:ring-0"
} }
> >
{recording ? ( {recording ? (
<span className="px-2 text-[9px] text-primary whitespace-nowrap">Press hotkeys...</span> <span className="px-2 text-[9px] text-primary whitespace-nowrap">Press hotkeys</span>
) : ( ) : (
<ShortcutKbd keys={displayKeys} className="ml-0 px-1.5 text-foreground/85" /> <ShortcutKbd keys={displayKeys} className="ml-0 px-1.5 text-foreground/85" />
)} )}
</button> </Button>
</div> </div>
</div> </div>
); );
@ -167,6 +195,7 @@ export default function DesktopLoginPage() {
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [showPassword, setShowPassword] = useState(false); const [showPassword, setShowPassword] = useState(false);
const [loginError, setLoginError] = useState<string | null>(null); const [loginError, setLoginError] = useState<string | null>(null);
const [isGoogleRedirecting, setIsGoogleRedirecting] = useState(false);
const [shortcuts, setShortcuts] = useState(DEFAULT_SHORTCUTS); const [shortcuts, setShortcuts] = useState(DEFAULT_SHORTCUTS);
const [shortcutsLoaded, setShortcutsLoaded] = useState(false); const [shortcutsLoaded, setShortcutsLoaded] = useState(false);
@ -208,6 +237,8 @@ export default function DesktopLoginPage() {
); );
const handleGoogleLogin = () => { const handleGoogleLogin = () => {
if (isGoogleRedirecting) return;
setIsGoogleRedirecting(true);
window.location.href = `${BACKEND_URL}/auth/google/authorize-redirect`; window.location.href = `${BACKEND_URL}/auth/google/authorize-redirect`;
}; };
@ -255,8 +286,8 @@ export default function DesktopLoginPage() {
}; };
return ( return (
<div className="relative flex min-h-svh items-center justify-center bg-background p-4 sm:p-6 select-none"> <div className="relative flex min-h-svh items-center justify-center bg-main-panel p-4 sm:p-6 select-none">
<div className="relative flex w-full max-w-md flex-col overflow-hidden bg-card shadow-lg"> <div className="relative flex w-full max-w-md flex-col overflow-hidden bg-main-panel">
{/* Header */} {/* Header */}
<div className="flex flex-col items-center px-6 pt-6 pb-2 text-center"> <div className="flex flex-col items-center px-6 pt-6 pb-2 text-center">
<Image <Image
@ -313,8 +344,13 @@ export default function DesktopLoginPage() {
</p> */} </p> */}
{isGoogleAuth ? ( {isGoogleAuth ? (
<Button variant="outline" className="w-full gap-2 h-10" onClick={handleGoogleLogin}> <Button
<IconBrandGoogleFilled className="size-4" /> variant="outline"
className="w-full gap-2 h-10 bg-white text-[#1f1f1f] border-white hover:bg-zinc-100 hover:text-[#1f1f1f] dark:border-white shadow-sm font-medium"
disabled={isGoogleRedirecting}
onClick={handleGoogleLogin}
>
<GoogleGLogo className="size-4" />
Continue with Google Continue with Google
</Button> </Button>
) : ( ) : (
@ -357,10 +393,11 @@ export default function DesktopLoginPage() {
disabled={isLoggingIn} disabled={isLoggingIn}
className="h-9 pr-9" className="h-9 pr-9"
/> />
<button <Button
type="button" type="button"
variant="ghost"
onClick={() => setShowPassword((v) => !v)} onClick={() => setShowPassword((v) => !v)}
className="absolute inset-y-0 right-0 flex items-center pr-2.5 text-muted-foreground hover:text-foreground" className="absolute inset-y-0 right-0 h-auto bg-transparent px-2.5 py-0 text-muted-foreground hover:bg-transparent hover:text-foreground"
tabIndex={-1} tabIndex={-1}
> >
{showPassword ? ( {showPassword ? (
@ -368,7 +405,7 @@ export default function DesktopLoginPage() {
) : ( ) : (
<Eye className="size-3.5" /> <Eye className="size-3.5" />
)} )}
</button> </Button>
</div> </div>
</div> </div>

View file

@ -207,13 +207,14 @@ export default function DesktopPermissionsPage() {
<Button disabled className="text-sm h-9 min-w-[180px]"> <Button disabled className="text-sm h-9 min-w-[180px]">
Grant permissions to continue Grant permissions to continue
</Button> </Button>
<button <Button
type="button" type="button"
variant="link"
onClick={handleSkip} onClick={handleSkip}
className="block mx-auto text-xs text-muted-foreground hover:text-foreground transition-colors" className="mx-auto h-auto px-0 py-0 text-xs text-muted-foreground hover:text-foreground"
> >
Skip for now Skip for now
</button> </Button>
</> </>
)} )}
</div> </div>

View file

@ -2,6 +2,7 @@
import { ExternalLink } from "lucide-react"; import { ExternalLink } from "lucide-react";
import { useEffect, useMemo } from "react"; import { useEffect, useMemo } from "react";
import { Button } from "@/components/ui/button";
import { buildIssueUrl } from "@/lib/error-toast"; import { buildIssueUrl } from "@/lib/error-toast";
export default function ErrorPage({ export default function ErrorPage({
@ -37,13 +38,9 @@ export default function ErrorPage({
)} )}
<div className="flex gap-2"> <div className="flex gap-2">
<button <Button type="button" onClick={reset}>
type="button"
onClick={reset}
className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 transition-colors"
>
Try again Try again
</button> </Button>
<a <a
href={issueUrl} href={issueUrl}
target="_blank" target="_blank"

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