Merge remote-tracking branch 'upstream/dev' into improvement-agent-speed

This commit is contained in:
CREDO23 2026-05-20 19:22:49 +02:00
commit d5ee8cc4cd
287 changed files with 7551 additions and 6195 deletions

View file

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

View file

@ -100,7 +100,7 @@ use: {
Usage:
```typescript
// HTML: <button data-testid="submit-btn">Submit</button>
// React: <Button data-testid="submit-btn">Submit</Button>
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**
```tsx
import { Button } from '@/components/ui/button'
function EditorButton({ onClick }: { onClick: () => void }) {
const preload = () => {
if (typeof window !== 'undefined') {
@ -557,13 +559,13 @@ function EditorButton({ onClick }: { onClick: () => void }) {
}
return (
<button
<Button
onMouseEnter={preload}
onFocus={preload}
onClick={onClick}
>
Open Editor
</button>
</Button>
)
}
```
@ -1239,11 +1241,12 @@ function StaticContent() {
**For mutations:**
```tsx
import { Button } from '@/components/ui/button'
import { useSWRMutation } from 'swr/mutation'
function UpdateButton() {
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**
```tsx
import { Button } from '@/components/ui/button'
function ShareButton({ chatId }: { chatId: string }) {
const searchParams = useSearchParams()
@ -1377,13 +1382,15 @@ function ShareButton({ chatId }: { chatId: string }) {
shareChat(chatId, { ref })
}
return <button onClick={handleShare}>Share</button>
return <Button onClick={handleShare}>Share</Button>
}
```
**Correct: reads on demand, no subscription**
```tsx
import { Button } from '@/components/ui/button'
function ShareButton({ chatId }: { chatId: string }) {
const handleShare = () => {
const params = new URLSearchParams(window.location.search)
@ -1391,7 +1398,7 @@ function ShareButton({ chatId }: { chatId: string }) {
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**
```tsx
import { Button } from '@/components/ui/button'
function Form() {
const [submitted, setSubmitted] = useState(false)
const theme = useContext(ThemeContext)
@ -1560,13 +1569,15 @@ function Form() {
}
}, [submitted, theme])
return <button onClick={() => setSubmitted(true)}>Submit</button>
return <Button onClick={() => setSubmitted(true)}>Submit</Button>
}
```
**Correct: do it in the handler**
```tsx
import { Button } from '@/components/ui/button'
function Form() {
const theme = useContext(ThemeContext)
@ -1575,7 +1586,7 @@ function Form() {
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):**
```tsx
import { Button } from "@/components/ui/button"
function EditorButton({ onClick }: { onClick: () => void }) {
const preload = () => {
if (typeof window !== 'undefined') {
@ -20,13 +22,13 @@ function EditorButton({ onClick }: { onClick: () => void }) {
}
return (
<button
<Button
onMouseEnter={preload}
onFocus={preload}
onClick={onClick}
>
Open Editor
</button>
</Button>
)
}
```

View file

@ -45,11 +45,12 @@ function StaticContent() {
**For mutations:**
```tsx
import { Button } from '@/components/ui/button'
import { useSWRMutation } from 'swr/mutation'
function UpdateButton() {
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):**
```tsx
import { Button } from '@/components/ui/button'
function ShareButton({ chatId }: { chatId: string }) {
const searchParams = useSearchParams()
@ -20,13 +22,15 @@ function ShareButton({ chatId }: { chatId: string }) {
shareChat(chatId, { ref })
}
return <button onClick={handleShare}>Share</button>
return <Button onClick={handleShare}>Share</Button>
}
```
**Correct (reads on demand, no subscription):**
```tsx
import { Button } from '@/components/ui/button'
function ShareButton({ chatId }: { chatId: string }) {
const handleShare = () => {
const params = new URLSearchParams(window.location.search)
@ -34,6 +38,6 @@ function ShareButton({ chatId }: { chatId: string }) {
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):**
```tsx
import { Button } from '@/components/ui/button'
function Form() {
const [submitted, setSubmitted] = useState(false)
const theme = useContext(ThemeContext)
@ -23,13 +25,15 @@ function Form() {
}
}, [submitted, theme])
return <button onClick={() => setSubmitted(true)}>Submit</button>
return <Button onClick={() => setSubmitted(true)}>Submit</Button>
}
```
**Correct (do it in the handler):**
```tsx
import { Button } from '@/components/ui/button'
function Form() {
const theme = useContext(ThemeContext)
@ -38,7 +42,7 @@ function Form() {
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
.pnpm-store
.DS_Store
deepagents/
debug.log
opencode/
references/
references
# Playwright (E2E test artifacts)
surfsense_web/playwright/.auth/
surfsense_web/playwright-report/
surfsense_web/test-results/
surfsense_web/blob-report/
hermes-agent
hermes-agent/
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: CELERY_BROKER_URL / REDIS_APP_URL → redis://localhost:6379/0
# - 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
@ -82,6 +94,10 @@ services:
timeout: 5s
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:
image: rocicorp/zero:1.4.0
ports:

View file

@ -34,6 +34,25 @@ services:
timeout: 5s
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:
image: dpage/pgadmin4
ports:
@ -111,8 +130,10 @@ services:
condition: service_healthy
searxng:
condition: service_healthy
migrations:
condition: service_completed_successfully
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
test: ["CMD", "curl", "-f", "http://localhost:8000/ready"]
interval: 15s
timeout: 5s
retries: 30
@ -141,6 +162,8 @@ services:
condition: service_healthy
redis:
condition: service_healthy
migrations:
condition: service_completed_successfully
backend:
condition: service_healthy
@ -160,6 +183,8 @@ services:
condition: service_healthy
redis:
condition: service_healthy
migrations:
condition: service_completed_successfully
celery_worker:
condition: service_started
@ -185,8 +210,10 @@ services:
extra_hosts:
- "host.docker.internal:host-gateway"
depends_on:
backend:
db:
condition: service_healthy
migrations:
condition: service_completed_successfully
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_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}
volumes:
- 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
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:4848/keepalive"]
@ -238,3 +271,5 @@ volumes:
name: surfsense-dev-shared-temp
zero_cache_data:
name: surfsense-dev-zero-cache
zero_init:
name: surfsense-dev-zero-init

View file

@ -27,6 +27,28 @@ services:
timeout: 5s
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:
image: redis:8-alpine
volumes:
@ -88,9 +110,11 @@ services:
condition: service_healthy
searxng:
condition: service_healthy
migrations:
condition: service_completed_successfully
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
test: ["CMD", "curl", "-f", "http://localhost:8000/ready"]
interval: 15s
timeout: 5s
retries: 30
@ -118,6 +142,8 @@ services:
condition: service_healthy
redis:
condition: service_healthy
migrations:
condition: service_completed_successfully
backend:
condition: service_healthy
labels:
@ -140,6 +166,8 @@ services:
condition: service_healthy
redis:
condition: service_healthy
migrations:
condition: service_completed_successfully
celery_worker:
condition: service_started
labels:
@ -182,10 +210,21 @@ services:
ZERO_MUTATE_URL: ${ZERO_MUTATE_URL:-http://frontend:3000/api/zero/mutate}
volumes:
- 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
depends_on:
backend:
db:
condition: service_healthy
migrations:
condition: service_completed_successfully
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:4848/keepalive"]
interval: 10s
@ -221,3 +260,5 @@ volumes:
name: surfsense-shared-temp
zero_cache_data:
name: surfsense-zero-cache
zero_init:
name: surfsense-zero-init

View file

@ -97,6 +97,161 @@ function Wait-ForPostgres {
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 ──────────────────────────────────────────────────────────
Write-Step "Downloading SurfSense files"
@ -191,6 +346,8 @@ if (-not (Test-Path $envPath)) {
# ── Start containers ────────────────────────────────────────────────────────
Invoke-StaleZeroCacheCleanup
if ($MigrationMode) {
$envContent = Get-Content $envPath
$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
Invoke-NativeSafe { docker compose up -d }
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
@ -260,7 +423,13 @@ if ($MigrationMode) {
Push-Location $InstallDir
Invoke-NativeSafe { docker compose up -d }
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) ────────────────────────────────────────────────

View file

@ -97,6 +97,163 @@ wait_for_pg() {
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 ───────────────────────────────────────────────────────────
step "Downloading SurfSense files"
@ -186,6 +343,8 @@ fi
# ── Start containers ─────────────────────────────────────────────────────────
invoke_stale_zero_cache_cleanup
if $MIGRATION_MODE; then
# 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)
@ -243,7 +402,12 @@ if $MIGRATION_MODE; then
step "Starting all SurfSense services"
(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
rm -f "${KEY_FILE}"
@ -251,7 +415,12 @@ if $MIGRATION_MODE; then
else
step "Starting SurfSense"
(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
# ── 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
# 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
# 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
# Celery worker tuning (only used when SERVICE_ROLE=worker or all)

View file

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

View file

@ -945,6 +945,36 @@ async def health_check():
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")
async def authenticated_route(
user: User = Depends(current_active_user),

View file

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

View file

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

View file

@ -79,6 +79,7 @@ from app.tasks.chat.streaming.helpers.interrupt_inspector import (
)
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.surfsense_docs import surfsense_docs_public_url
from app.utils.user_message_multimodal import build_human_message_content
_background_tasks: set[asyncio.Task] = set()
@ -214,14 +215,17 @@ def format_mentioned_surfsense_docs_as_context(
)
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_metadata>")
context_parts.append(f" <document_id>doc-{doc.id}</document_id>")
context_parts.append(" <document_type>SURFSENSE_DOCS</document_type>")
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(
f" <metadata_json><![CDATA[{metadata_json}]]></metadata_json>"
)

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]
name = "surf-new-backend"
version = "0.0.23"
version = "0.0.24"
description = "SurfSense Backend"
requires-python = ">=3.12"
dependencies = [

View file

@ -4,10 +4,15 @@ set -e
# ─────────────────────────────────────────────────────────────
# 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
# 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
# each service deployment.
@ -41,7 +46,13 @@ cleanup() {
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() {
echo "Running database migrations..."
for i in {1..30}; do
@ -53,11 +64,66 @@ run_migrations() {
sleep 1
done
if timeout 300 alembic upgrade head 2>&1; then
local timeout_secs="${MIGRATION_TIMEOUT:-900}"
echo "Running alembic upgrade head (timeout=${timeout_secs}s)..."
if ! timeout "${timeout_secs}" alembic upgrade head; then
echo "ERROR: alembic upgrade head failed (or exceeded ${timeout_secs}s timeout)." >&2
echo "Refusing to start. Inspect the error above and re-run." >&2
exit 1
fi
echo "Migrations completed successfully."
else
echo "WARNING: Migration failed or timed out. Continuing anyway..."
echo "You may need to run migrations manually: alembic upgrade head"
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
}
@ -102,8 +168,12 @@ start_beat() {
# ── Main: run based on role ──────────────────────────────────
case "${SERVICE_ROLE}" in
api)
migrate)
run_migrations
echo "Migrations complete; exiting cleanly."
exit 0
;;
api)
start_api
;;
worker)
@ -121,7 +191,7 @@ case "${SERVICE_ROLE}" in
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
;;
esac

View file

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

View file

@ -1,7 +1,7 @@
{
"name": "surfsense_browser_extension",
"displayName": "Surfsense Browser Extension",
"version": "0.0.23",
"version": "0.0.24",
"description": "Extension to collect Browsing History for SurfSense.",
"author": "https://github.com/MODSetter",
"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",
"version": "0.0.23",
"version": "0.0.24",
"description": "SurfSense Desktop App",
"main": "dist/main.js",
"scripts": {

View file

@ -11,11 +11,20 @@ let registeredGeneralAssist: string | null = null;
let registeredScreenshotAssist: string | null = null;
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
? path.join(process.resourcesPath, 'assets', iconName)
: path.join(__dirname, '..', 'assets', iconName);
const img = nativeImage.createFromPath(iconPath);
if (process.platform === 'darwin') {
img.setTemplateImage(true);
return img;
}
return img.resize({ width: 16, height: 16 });
}

View file

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

View file

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

View file

@ -1,15 +1,47 @@
"use client";
import { IconBrandGoogleFilled } from "@tabler/icons-react";
import { motion } from "motion/react";
import { useTranslations } from "next-intl";
import { useState } from "react";
import { Logo } from "@/components/Logo";
import { Button } from "@/components/ui/button";
import { trackLoginAttempt } from "@/lib/posthog/events";
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() {
const t = useTranslations("auth");
const [isRedirecting, setIsRedirecting] = useState(false);
const handleGoogleLogin = () => {
if (isRedirecting) return;
setIsRedirecting(true);
// Track Google login attempt
trackLoginAttempt("google");
@ -73,21 +105,15 @@ export function GoogleLoginButton() {
</motion.div>
</motion.div> */}
<motion.button
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
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"
<Button
variant="outline"
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"
disabled={isRedirecting}
onClick={handleGoogleLogin}
>
<div className="absolute inset-0 h-full w-full transform opacity-0 transition duration-200 group-hover/btn:opacity-100">
<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" />
<GoogleGLogo className="h-5 w-5" />
<span className="text-base font-medium">{t("continue_with_google")}</span>
</motion.button>
</Button>
</div>
</div>
);

View file

@ -7,6 +7,7 @@ import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { useState } from "react";
import { loginMutationAtom } from "@/atoms/auth/auth-mutation.atoms";
import { Button } from "@/components/ui/button";
import { Spinner } from "@/components/ui/spinner";
import { getAuthErrorDetails, isNetworkError } from "@/lib/auth-errors";
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 text-destructive">{error.message}</p>
</div>
<button
<Button
variant="ghost"
size="icon"
onClick={() => {
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"
type="button"
>
@ -143,7 +146,7 @@ export function LocalLoginForm() {
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</Button>
</div>
</motion.div>
)}
@ -191,21 +194,23 @@ export function LocalLoginForm() {
}`}
disabled={isLoggingIn}
/>
<button
<Button
type="button"
variant="ghost"
size="icon"
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")}
>
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
</Button>
</div>
</div>
<button
<Button
type="submit"
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>
{isLoggingIn && (
@ -213,7 +218,7 @@ export function LocalLoginForm() {
<Spinner size="sm" className="text-primary-foreground" />
</span>
)}
</button>
</Button>
</form>
{authType === "LOCAL" && (

View file

@ -6,6 +6,7 @@ import { useTranslations } from "next-intl";
import { Suspense, useEffect, useState } from "react";
import { toast } from "sonner";
import { Logo } from "@/components/Logo";
import { Button } from "@/components/ui/button";
import { useGlobalLoadingEffect } from "@/hooks/use-global-loading";
import { getAuthErrorDetails, shouldRetry } from "@/lib/auth-errors";
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 text-red-700 dark:text-red-300">{urlError.message}</p>
</div>
<button
<Button
type="button"
variant="ghost"
size="icon"
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"
>
<svg
@ -175,7 +178,7 @@ function LoginContent() {
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</Button>
</div>
</motion.div>
)}

View file

@ -9,6 +9,7 @@ import { useEffect, useState } from "react";
import { type ExternalToast, toast } from "sonner";
import { registerMutationAtom } from "@/atoms/auth/auth-mutation.atoms";
import { Logo } from "@/components/Logo";
import { Button } from "@/components/ui/button";
import { Spinner } from "@/components/ui/spinner";
import { getAuthErrorDetails, isNetworkError, shouldRetry } from "@/lib/auth-errors";
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 text-red-700 dark:text-red-300">{error.message}</p>
</div>
<button
<Button
variant="ghost"
size="icon"
onClick={() => {
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"
type="button"
>
@ -222,7 +225,7 @@ export default function RegisterPage() {
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</Button>
</div>
</motion.div>
)}
@ -295,18 +298,18 @@ export default function RegisterPage() {
/>
</div>
<button
<Button
type="submit"
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>
{isRegistering && (
<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>
)}
</button>
</Button>
</form>
<div className="mt-4 text-center text-sm">

View file

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

View file

@ -144,6 +144,19 @@ export function DashboardClientLayout({
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(() => {
if (!electronAPI?.onChatScreenCapture) return;
return electronAPI.onChatScreenCapture((dataUrl: string) => {
@ -163,12 +176,13 @@ export function DashboardClientLayout({
setActiveSearchSpaceIdState(activeSeacrhSpaceId);
// Sync to Electron store if stored value is null (first navigation)
if (electronAPI?.setActiveSearchSpace) {
if (electronAPI?.getActiveSearchSpace && electronAPI.setActiveSearchSpace) {
const setActiveSearchSpace = electronAPI.setActiveSearchSpace;
electronAPI
.getActiveSearchSpace?.()
.then((stored) => {
.getActiveSearchSpace()
.then((stored: string | null) => {
if (!stored) {
electronAPI.setActiveSearchSpace!(activeSeacrhSpaceId);
setActiveSearchSpace(activeSeacrhSpaceId);
}
})
.catch(() => {});

View file

@ -17,7 +17,6 @@ import {
} from "@tanstack/react-table";
import { useAtomValue } from "jotai";
import {
Activity,
AlertCircle,
AlertTriangle,
Bug,
@ -38,6 +37,7 @@ import {
RefreshCw,
Terminal,
Trash,
Workflow,
X,
Zap,
} from "lucide-react";
@ -133,7 +133,6 @@ const logStatusConfig = {
function MessageDetails({
message,
taskName,
metadata,
createdAt,
children,
}: {
@ -623,7 +622,7 @@ function LogsSummaryDashboard({
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<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>
<CardContent>
<div className="text-2xl font-bold">{summary.total_logs}</div>
@ -739,7 +738,7 @@ function LogsFilters({
</div>
{Boolean(filterInput) && (
<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"
size="icon"
onClick={() => {
@ -1045,7 +1044,7 @@ function LogsTable({
}}
exit={{ opacity: 0, y: -10 }}
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" : ""
)}
>

View file

@ -53,7 +53,10 @@ export default function Loading() {
{/* Table Rows */}
{[...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-6 w-12 rounded-full" />
<Skeleton className="h-6 w-16 rounded-full" />

View file

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

View file

@ -49,6 +49,7 @@ import {
type TokenUsageData,
TokenUsageProvider,
} from "@/components/assistant-ui/token-usage-context";
import { Button } from "@/components/ui/button";
import {
type HitlDecision,
PendingInterruptProvider,
@ -78,12 +79,7 @@ import {
setActivePodcastTaskId,
} from "@/lib/chat/podcast-state";
import { createStreamFlushHelpers } from "@/lib/chat/stream-flush";
import {
consumeSseEvents,
hasPersistableContent,
markInterruptsCompleted,
processSharedStreamEvent,
} from "@/lib/chat/stream-pipeline";
import { consumeSseEvents, processSharedStreamEvent } from "@/lib/chat/stream-pipeline";
import {
applyTurnIdToAssistantMessageList,
mergeChatTurnIdIntoMessage,
@ -92,7 +88,6 @@ import {
} from "@/lib/chat/stream-side-effects";
import {
addToolCall,
buildContentForPersistence,
buildContentForUI,
type ContentPartsState,
type FrameBatchedUpdater,
@ -453,7 +448,7 @@ export default function NewChatPage() {
}, [params.search_space_id]);
// 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
// ``GET /threads/{id}/actions`` and is updated incrementally by the
// 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]>();
for (let i = 0; i < tcIds.length; i++) byTcId.set(tcIds[i], incoming[i]);
const submittedDecisions = tcIds.map((id) => byTcId.get(id)!);
const submittedDecisions: typeof incoming = [];
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
// single content-update pass suffices.
@ -2407,16 +2413,15 @@ export default function NewChatPage() {
return (
<div className="flex h-full flex-col items-center justify-center gap-4">
<div className="text-destructive">Failed to load chat</div>
<button
<Button
type="button"
onClick={() => {
setIsInitializing(true);
initializeThread();
}}
className="rounded-md bg-primary px-4 py-2 text-primary-foreground hover:bg-primary/90"
>
Try Again
</button>
</Button>
</div>
);
}

View file

@ -2,8 +2,21 @@ import { Skeleton } from "@/components/ui/skeleton";
export default function Loading() {
return (
<div className="flex h-full flex-col bg-main-panel px-4">
<div className="mx-auto w-full max-w-[44rem] flex flex-1 flex-col gap-6 py-8">
<div
className="aui-root aui-thread-root @container flex h-full min-h-0 flex-col bg-main-panel"
style={{
["--thread-max-width" as string]: "42rem",
}}
>
<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" />
@ -35,9 +48,13 @@ export default function Loading() {
</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
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"
style={{ paddingBottom: "max(0.5rem, env(safe-area-inset-bottom))" }}
>
<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" />
</div>
</div>
</div>
</div>

View file

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

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 { 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 { toast } from "sonner";
import { agentFlagsAtom } from "@/atoms/agent/agent-flags-query.atom";
@ -20,6 +20,15 @@ import {
} from "@/components/ui/alert-dialog";
import { Badge } from "@/components/ui/badge";
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 { Label } from "@/components/ui/label";
import {
@ -29,6 +38,7 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Skeleton } from "@/components/ui/skeleton";
import { Spinner } from "@/components/ui/spinner";
import {
type AgentPermissionAction,
@ -67,20 +77,29 @@ function permissionRulesQueryKey(searchSpaceId: number) {
function ScopeBadge({ rule }: { rule: AgentPermissionRule }) {
if (rule.thread_id !== null) {
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}
</Badge>
);
}
if (rule.user_id !== null) {
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
</Badge>
);
}
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
</Badge>
);
@ -170,8 +189,8 @@ export function AgentPermissionsContent() {
permission: formData.permission.trim(),
pattern: formData.pattern.trim() || "*",
});
setShowForm(false);
setFormData(EMPTY_FORM);
setShowForm(false);
} catch (err) {
if (err instanceof AppError && err.message) {
// already toasted by onError
@ -190,13 +209,17 @@ export function AgentPermissionsContent() {
if (!featureEnabled) {
return (
<Alert className="border-dashed">
<ShieldCheck className="size-4" />
<Alert>
<Info />
<AlertTitle>Permission middleware is disabled</AlertTitle>
<AlertDescription>
<p>
Flip{" "}
<code className="rounded bg-muted px-1 text-[10px]">SURFSENSE_ENABLE_PERMISSION</code> on
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>
</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 (
<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="space-y-1">
<p className="text-sm text-muted-foreground">
@ -237,7 +240,6 @@ export function AgentPermissionsContent() {
patterns and are evaluated at the most specific scope first.
</p>
</div>
{!showForm && (
<Button
size="sm"
onClick={() => {
@ -246,18 +248,28 @@ export function AgentPermissionsContent() {
}}
className="shrink-0 gap-1.5"
>
<Plus className="size-3.5" />
New rule
</Button>
)}
</div>
{showForm && (
<div className="rounded-lg border border-border/60 bg-card p-6">
<div className="space-y-4">
<h3 className="text-sm font-semibold tracking-tight">New permission rule</h3>
<Dialog
open={showForm}
onOpenChange={(open) => {
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">
<Label htmlFor="permission-name">Permission</Label>
<Input
@ -297,25 +309,28 @@ export function AgentPermissionsContent() {
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="allow">Allow run without asking</SelectItem>
<SelectItem value="ask">Ask pause for approval</SelectItem>
<SelectItem value="deny">Deny block silently</SelectItem>
<SelectItem value="allow">Allow (run without asking)</SelectItem>
<SelectItem value="ask">Ask (pause for approval)</SelectItem>
<SelectItem value="deny">Deny (block silently)</SelectItem>
</SelectContent>
</Select>
<p className="text-[11px] text-muted-foreground">
{ACTION_DESCRIPTIONS[formData.action]}
</p>
</div>
</div>
<div className="flex items-center justify-end gap-2 pt-2">
<DialogFooter>
<Button
variant="ghost"
type="button"
variant="secondary"
size="sm"
onClick={() => {
setShowForm(false);
setFormData(EMPTY_FORM);
}}
disabled={createMutation.isPending}
className="text-sm h-9"
>
Cancel
</Button>
@ -323,17 +338,40 @@ export function AgentPermissionsContent() {
size="sm"
onClick={handleCreate}
disabled={createMutation.isPending || !formData.permission.trim()}
className="relative"
className="relative text-sm h-9 min-w-[96px]"
>
<span className={createMutation.isPending ? "opacity-0" : ""}>Create</span>
{createMutation.isPending && <Spinner className="absolute size-3.5" />}
{createMutation.isPending && <Spinner size="sm" className="absolute" />}
</Button>
</div>
</div>
</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>
)}
{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">
<ShieldCheck className="mx-auto size-8 text-muted-foreground/40" />
<p className="mt-2 text-sm text-muted-foreground">No rules yet</p>
@ -343,8 +381,8 @@ export function AgentPermissionsContent() {
</div>
)}
{sortedRules.length > 0 && (
<div className="space-y-2">
{!isLoading && !isError && sortedRules.length > 0 && (
<div className="-m-1 space-y-2 p-1">
{sortedRules.map((rule) => {
const badge = ACTION_BADGE[rule.action];
const isUpdating =
@ -352,14 +390,14 @@ export function AgentPermissionsContent() {
const isDeleting = deleteMutation.isPending && deleteMutation.variables === rule.id;
return (
<div
<Card
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 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}
</code>
{rule.pattern !== "*" && (
@ -374,7 +412,7 @@ export function AgentPermissionsContent() {
</p>
</div>
<div className="flex shrink-0 items-center gap-1">
<div className="flex shrink-0 items-center self-center gap-1">
<Select
value={rule.action}
onValueChange={(value) =>
@ -389,11 +427,7 @@ export function AgentPermissionsContent() {
className={cn("h-8 gap-1 border px-2 text-[11px]", badge.className)}
>
<SelectValue>
<span className="flex items-center gap-1">
{rule.action === "allow" && <Check className="size-3" />}
{rule.action === "deny" && <X className="size-3" />}
{badge.label}
</span>
<span className="flex items-center gap-1">{badge.label}</span>
</SelectValue>
</SelectTrigger>
<SelectContent>
@ -406,7 +440,7 @@ export function AgentPermissionsContent() {
<Button
size="sm"
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)}
disabled={isUpdating || isDeleting}
aria-label="Delete rule"
@ -414,8 +448,8 @@ export function AgentPermissionsContent() {
<Trash2 className="size-3.5" />
</Button>
</div>
</div>
</div>
</CardContent>
</Card>
);
})}
</div>

View file

@ -1,8 +1,8 @@
"use client";
import { useAtomValue } from "jotai";
import { CircleCheck, CircleSlash, Cog, RotateCcw } from "lucide-react";
import { useMemo } from "react";
import { AlertTriangle, CircleCheck, CircleSlash, Info } from "lucide-react";
import { Fragment, useMemo } from "react";
import { agentFlagsAtom } from "@/atoms/agent/agent-flags-query.atom";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Badge } from "@/components/ui/badge";
@ -136,7 +136,7 @@ const FLAG_GROUPS: FlagGroup[] = [
{
id: "tier5",
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: [
{
key: "enable_action_log",
@ -222,7 +222,7 @@ function FlagRow({ def, value }: { def: FlagDef; value: boolean }) {
}
export function AgentStatusContent() {
const { data: flags, isLoading, isError, error, refetch } = useAtomValue(agentFlagsAtom);
const { data: flags, isLoading, isError, error } = useAtomValue(agentFlagsAtom);
const enabledCount = useMemo(() => {
if (!flags) return 0;
@ -243,17 +243,10 @@ export function AgentStatusContent() {
if (isError || !flags) {
return (
<Alert variant="destructive">
<AlertTriangle />
<AlertTitle>Failed to load agent status</AlertTitle>
<AlertDescription className="flex items-center gap-2">
<AlertDescription>
{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>
</Alert>
);
@ -265,28 +258,36 @@ export function AgentStatusContent() {
<div className="space-y-6">
{masterOff ? (
<Alert variant="destructive">
<Cog className="size-4" />
<AlertTriangle />
<AlertTitle>Master kill-switch is on</AlertTitle>
<AlertDescription>
<p>
Showing that{" "}
<code className="rounded bg-muted px-1 text-[10px]">
SURFSENSE_DISABLE_NEW_AGENT_STACK=true
</code>
forces every new middleware off, regardless of the individual flags below. Restart the
backend after changing it.
, which forces every new middleware off, regardless of the individual flags below.
Restart the backend after changing it.
</p>
</AlertDescription>
</Alert>
) : (
<Alert>
<Cog className="size-4" />
<Info />
<AlertTitle className="flex items-center gap-2">
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
</Badge>
</AlertTitle>
<AlertDescription>
Read-only mirror of the backend's <code>AgentFeatureFlags</code>. Flip an env var and
restart the backend to change a value.
<p>
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>
</Alert>
)}
@ -295,9 +296,9 @@ export function AgentStatusContent() {
const allOff = group.flags.every((f) => !flags[f.key]);
return (
<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="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>
<p className="text-sm font-semibold">{group.title}</p>
<p className="text-xs text-muted-foreground">{group.subtitle}</p>
@ -308,9 +309,13 @@ export function AgentStatusContent() {
</Badge>
)}
</div>
<div className="divide-y divide-border/50 px-4">
{group.flags.map((def) => (
<FlagRow key={def.key} def={def} value={flags[def.key]} />
<Separator className="bg-border" />
<div className="px-4">
{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>

View file

@ -5,6 +5,7 @@ import { useTranslations } from "next-intl";
import { useCallback, useRef, useState } from "react";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { useApiKey } from "@/hooks/use-api-key";
import { copyToClipboard as copyToClipboardUtil } from "@/lib/utils";
@ -27,17 +28,20 @@ export function ApiKeyContent() {
return (
<div className="space-y-6 min-w-0 overflow-hidden">
<Alert className="bg-muted/50 py-3 md:py-4">
<Info className="h-3 w-3 md:h-4 md:w-4 shrink-0" />
<AlertDescription className="text-xs md:text-sm">
{t("api_key_warning_description")}
</AlertDescription>
<Alert>
<Info />
<AlertDescription>{t("api_key_warning_description")}</AlertDescription>
</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>
{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 ? (
<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">
@ -52,7 +56,7 @@ export function ApiKeyContent() {
variant="ghost"
size="icon"
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 ? (
<Check className="h-3 w-3 text-green-500" />
@ -70,7 +74,7 @@ export function ApiKeyContent() {
)}
</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>
<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">
@ -86,7 +90,7 @@ export function ApiKeyContent() {
variant="ghost"
size="icon"
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 ? (
<Check className="h-3 w-3 text-green-500" />

View file

@ -1,11 +1,14 @@
"use client";
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 { copyPromptMutationAtom } from "@/atoms/prompts/prompts-mutation.atoms";
import { publicPromptsAtom } from "@/atoms/prompts/prompts-query.atoms";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
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";
export function CommunityPromptsContent() {
@ -34,33 +37,37 @@ export function CommunityPromptsContent() {
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 (
<div className="space-y-6 min-w-0 overflow-hidden">
<p className="text-sm text-muted-foreground">
Prompts shared by other users. Add any to your collection with one click.
</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">
<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="text-xs text-muted-foreground/60">
Share your own prompts from the My Prompts tab
@ -68,20 +75,18 @@ export function CommunityPromptsContent() {
</div>
)}
{list.length > 0 && (
{!isLoading && !isError && list.length > 0 && (
<div className="space-y-2">
{list.map((prompt) => (
<div
<Card
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">
<Sparkles className="size-4" />
</div>
<CardContent className="p-4 flex items-start gap-3 h-full">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<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}
</span>
{prompt.author_name && (
@ -96,19 +101,20 @@ export function CommunityPromptsContent() {
{prompt.prompt}
</p>
{prompt.prompt.length > 100 && (
<button
<Button
type="button"
variant="link"
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"}
</button>
</Button>
)}
</div>
<Button
variant="outline"
variant="ghost"
size="sm"
className="shrink-0 gap-1.5"
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)}
>
@ -119,7 +125,8 @@ export function CommunityPromptsContent() {
)}
Add to mine
</Button>
</div>
</CardContent>
</Card>
))}
</div>
)}

View file

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

View file

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

View file

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

View file

@ -11,8 +11,17 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
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 hasError = errorUrl === url;
@ -23,15 +32,19 @@ function AvatarDisplay({ url, fallback }: { url?: string; fallback: string }) {
alt="Avatar"
width={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)}
referrerPolicy="no-referrer"
unoptimized
/>
);
}
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}
</div>
);
@ -50,11 +63,6 @@ export function ProfileContent() {
}
}, [user]);
const getInitials = (email: string) => {
const name = email.split("@")[0];
return name.slice(0, 2).toUpperCase();
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
@ -69,6 +77,7 @@ export function ProfileContent() {
};
const hasChanges = displayName !== (user?.display_name || "");
const avatarBgColor = getUserAvatarColor(user?.email || "");
return (
<div>
@ -78,13 +87,13 @@ export function ProfileContent() {
</div>
) : (
<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="space-y-2">
<Label>{t("profile_avatar")}</Label>
<AvatarDisplay
url={user?.avatar_url || undefined}
fallback={getInitials(user?.email || "")}
fallback={getUserInitials(user?.email || "")}
bgColor={avatarBgColor}
/>
</div>
@ -114,7 +123,7 @@ export function ProfileContent() {
type="submit"
variant="outline"
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>
{isPending && <Spinner size="sm" className="absolute" />}

View file

@ -1,7 +1,7 @@
"use client";
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 { toast } from "sonner";
import {
@ -10,6 +10,7 @@ import {
updatePromptMutationAtom,
} from "@/atoms/prompts/prompts-mutation.atoms";
import { promptsAtom } from "@/atoms/prompts/prompts-query.atoms";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import {
AlertDialog,
AlertDialogAction,
@ -21,9 +22,32 @@ import {
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
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 { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { ShortcutKbd } from "@/components/ui/shortcut-kbd";
import { Skeleton } from "@/components/ui/skeleton";
import { Spinner } from "@/components/ui/spinner";
import { Switch } from "@/components/ui/switch";
import type { PromptRead } from "@/contracts/types/prompts.types";
@ -123,24 +147,6 @@ export function PromptsContent() {
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 (
<div className="space-y-6 min-w-0 overflow-hidden">
<div className="flex items-center justify-between">
@ -148,7 +154,6 @@ export function PromptsContent() {
Create prompt templates triggered with <ShortcutKbd keys={["/"]} className="ml-0" /> in
the chat composer.
</p>
{!showForm && (
<Button
size="sm"
onClick={() => {
@ -160,15 +165,27 @@ export function PromptsContent() {
>
New
</Button>
)}
</div>
{showForm && (
<div className="rounded-lg border border-border/60 bg-card p-6 space-y-4">
<h3 className="text-sm font-semibold tracking-tight">
{editingId !== null ? "Edit prompt" : "New prompt"}
</h3>
<Dialog
open={showForm}
onOpenChange={(open) => {
setShowForm(open);
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-4">
<div className="space-y-2">
<Label htmlFor="prompt-name">Name</Label>
<Input
@ -200,17 +217,24 @@ export function PromptsContent() {
<div className="space-y-2">
<Label htmlFor="prompt-mode">Mode</Label>
<select
id="prompt-mode"
<Select
value={formData.mode}
onChange={(e) =>
setFormData((p) => ({ ...p, mode: e.target.value as "transform" | "explore" }))
onValueChange={(value) =>
setFormData((p) => ({ ...p, mode: 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>
<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">
@ -223,22 +247,57 @@ export function PromptsContent() {
Share with community
</Label>
</div>
</div>
<div className="flex items-center justify-end gap-2 pt-2">
<Button variant="ghost" size="sm" onClick={handleCancel}>
<DialogFooter>
<Button
type="button"
variant="secondary"
size="sm"
onClick={handleCancel}
disabled={isSaving}
className="text-sm h-9"
>
Cancel
</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" : ""}>
{editingId !== null ? "Update" : "Create"}
</span>
{isSaving && <Spinner className="size-3.5 absolute" />}
{isSaving && <Spinner size="sm" className="absolute" />}
</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>
)}
{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">
<Sparkles className="mx-auto size-8 text-muted-foreground/40" />
<p className="mt-2 text-sm text-muted-foreground">No prompts yet</p>
@ -248,24 +307,21 @@ export function PromptsContent() {
</div>
)}
{list.length > 0 && (
{!isLoading && !isError && list.length > 0 && (
<div className="space-y-2">
{list.map((prompt) => (
<div
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 items-center gap-2">
<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}
</span>
{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" />
Public
</span>
@ -277,48 +333,55 @@ export function PromptsContent() {
{prompt.prompt}
</p>
{prompt.prompt.length > 100 && (
<button
<Button
type="button"
variant="link"
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"}
</button>
</Button>
)}
</div>
<div className="hidden group-hover:flex items-center gap-1 shrink-0">
<button
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
title={prompt.is_public ? "Make private" : "Share with community"}
variant="ghost"
size="icon"
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"
>
<MoreHorizontal className="size-3.5" />
<span className="sr-only">Prompt actions</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() => handleTogglePublic(prompt)}
disabled={togglingPublicIds.has(prompt.id)}
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"
>
{togglingPublicIds.has(prompt.id) ? (
<Spinner className="size-3.5" />
<Spinner className="size-4" />
) : prompt.is_public ? (
<Lock className="size-3.5" />
<Lock className="size-4" />
) : (
<Globe className="size-3.5" />
<Globe className="size-4" />
)}
</button>
<Button
variant="ghost"
size="icon"
className="size-7"
onClick={() => handleEdit(prompt)}
>
<Pencil className="size-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
className="size-7 text-destructive hover:text-destructive"
{prompt.is_public ? "Make private" : "Share with community"}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleEdit(prompt)}>
<Pencil className="size-4" />
Edit
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => setDeleteTarget(prompt.id)}
className="text-destructive focus:text-destructive"
>
<Trash2 className="size-3.5" />
</Button>
</div>
<Trash2 className="size-4" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</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 Link from "next/link";
import { useEffect, useMemo } from "react";
import { Button } from "@/components/ui/button";
import { buildIssueUrl } from "@/lib/error-toast";
export default function DashboardError({
@ -39,13 +40,9 @@ export default function DashboardError({
)}
<div className="flex gap-2">
<button
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"
>
<Button type="button" onClick={reset}>
Try again
</button>
</Button>
<Link
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"

View file

@ -1,6 +1,5 @@
"use client";
import { IconBrandGoogleFilled } from "@tabler/icons-react";
import { useAtom } from "jotai";
import { Crop, Eye, EyeOff, Rocket, RotateCcw, Zap } from "lucide-react";
import Image from "next/image";
@ -24,6 +23,34 @@ const isGoogleAuth = AUTH_TYPE === "GOOGLE";
type ShortcutKey = "generalAssist" | "quickAsk" | "screenshotAssist";
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<{
key: ShortcutKey;
label: string;
@ -134,25 +161,26 @@ function HotkeyRow({
<RotateCcw className="size-3" />
</Button>
)}
<button
<Button
ref={inputRef}
type="button"
variant="ghost"
title={recording ? "Press shortcut keys" : "Click to edit shortcut"}
onClick={() => setRecording(true)}
onKeyDown={handleKeyDown}
onBlur={() => setRecording(false)}
className={
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"
: "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 border border-transparent bg-primary/5 px-0 outline-none ring-0 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 ? (
<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" />
)}
</button>
</Button>
</div>
</div>
);
@ -167,6 +195,7 @@ export default function DesktopLoginPage() {
const [password, setPassword] = useState("");
const [showPassword, setShowPassword] = useState(false);
const [loginError, setLoginError] = useState<string | null>(null);
const [isGoogleRedirecting, setIsGoogleRedirecting] = useState(false);
const [shortcuts, setShortcuts] = useState(DEFAULT_SHORTCUTS);
const [shortcutsLoaded, setShortcutsLoaded] = useState(false);
@ -208,6 +237,8 @@ export default function DesktopLoginPage() {
);
const handleGoogleLogin = () => {
if (isGoogleRedirecting) return;
setIsGoogleRedirecting(true);
window.location.href = `${BACKEND_URL}/auth/google/authorize-redirect`;
};
@ -255,8 +286,8 @@ export default function DesktopLoginPage() {
};
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 w-full max-w-md flex-col overflow-hidden bg-card shadow-lg">
<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-main-panel">
{/* Header */}
<div className="flex flex-col items-center px-6 pt-6 pb-2 text-center">
<Image
@ -313,8 +344,13 @@ export default function DesktopLoginPage() {
</p> */}
{isGoogleAuth ? (
<Button variant="outline" className="w-full gap-2 h-10" onClick={handleGoogleLogin}>
<IconBrandGoogleFilled className="size-4" />
<Button
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
</Button>
) : (
@ -357,10 +393,11 @@ export default function DesktopLoginPage() {
disabled={isLoggingIn}
className="h-9 pr-9"
/>
<button
<Button
type="button"
variant="ghost"
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}
>
{showPassword ? (
@ -368,7 +405,7 @@ export default function DesktopLoginPage() {
) : (
<Eye className="size-3.5" />
)}
</button>
</Button>
</div>
</div>

View file

@ -207,13 +207,14 @@ export default function DesktopPermissionsPage() {
<Button disabled className="text-sm h-9 min-w-[180px]">
Grant permissions to continue
</Button>
<button
<Button
type="button"
variant="link"
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
</button>
</Button>
</>
)}
</div>

View file

@ -2,6 +2,7 @@
import { ExternalLink } from "lucide-react";
import { useEffect, useMemo } from "react";
import { Button } from "@/components/ui/button";
import { buildIssueUrl } from "@/lib/error-toast";
export default function ErrorPage({
@ -37,13 +38,9 @@ export default function ErrorPage({
)}
<div className="flex gap-2">
<button
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"
>
<Button type="button" onClick={reset}>
Try again
</button>
</Button>
<a
href={issueUrl}
target="_blank"

View file

@ -18,8 +18,9 @@
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover: oklch(0.99 0 0);
--popover-foreground: oklch(0.145 0 0);
--popover-border: oklch(0.92 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
@ -39,7 +40,11 @@
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--radius: 0.625rem;
--sidebar: oklch(0.985 0 0);
/* Unified surface used by the left sidebar, main panel, and right panel. */
--panel: oklch(0.96 0 0);
/* Distinct (lighter) surface used by the leftmost icon rail. */
--rail: oklch(0.985 0 0);
--sidebar: var(--panel);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
@ -47,7 +52,7 @@
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
--main-panel: oklch(1 0 0);
--main-panel: var(--panel);
--syntax-bg: #f5f5f5;
--brand: oklch(0.623 0.214 259.815);
--highlight: oklch(0.852 0.199 91.936);
@ -58,8 +63,9 @@
--foreground: oklch(0.985 0 0);
--card: oklch(0.145 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.145 0 0);
--popover: oklch(0.32 0 0);
--popover-foreground: oklch(0.985 0 0);
--popover-border: oklch(0.4 0 0);
--primary: oklch(0.985 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
@ -70,23 +76,27 @@
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.577 0.245 27.325);
--destructive-foreground: oklch(0.985 0 0);
--border: oklch(0.269 0 0);
--input: oklch(0.269 0 0);
--border: oklch(0.32 0 0);
--input: oklch(0.32 0 0);
--ring: oklch(0.439 0 0);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.205 0 0);
/* Unified surface used by the left sidebar, main panel, and right panel. */
--panel: oklch(0.24 0 0);
/* Distinct (slightly darker) surface used by the leftmost icon rail. */
--rail: oklch(0.205 0 0);
--sidebar: var(--panel);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(0.269 0 0);
--sidebar-border: oklch(0.32 0 0);
--sidebar-ring: oklch(0.439 0 0);
--main-panel: oklch(0.18 0 0);
--main-panel: var(--panel);
--syntax-bg: #1e1e1e;
--brand: oklch(0.707 0.165 254.624);
--highlight: oklch(0.852 0.199 91.936);
@ -99,6 +109,7 @@
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-popover-border: var(--popover-border);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
@ -118,6 +129,8 @@
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--color-main-panel: var(--main-panel);
--color-panel: var(--panel);
--color-rail: var(--rail);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);

View file

@ -137,7 +137,7 @@ export default function RootLayout({
<WebSiteJsonLd />
<SoftwareApplicationJsonLd />
</head>
<body className={cn(roboto.className, "bg-white dark:bg-black antialiased h-full w-full ")}>
<body className={cn(roboto.className, "bg-main-panel antialiased h-full w-full ")}>
<PostHogProvider>
<LocaleProvider>
<I18nProvider>

View file

@ -1,6 +1,6 @@
import { atom } from "jotai";
import { atomWithQuery } from "jotai-tanstack-query";
import { type AgentToolInfo, agentToolsApiService } from "@/lib/apis/agent-tools-api.service";
import { agentToolsApiService } from "@/lib/apis/agent-tools-api.service";
import { cacheKeys } from "@/lib/query-client/cache-keys";
import { activeSearchSpaceIdAtom } from "../search-spaces/search-space-query.atoms";

View file

@ -0,0 +1,19 @@
import { atom } from "jotai";
interface ActionLogDialogState {
open: boolean;
threadId: number | null;
}
export const actionLogDialogAtom = atom<ActionLogDialogState>({
open: false,
threadId: null,
});
export const openActionLogDialogAtom = atom(null, (_get, set, threadId: number) => {
set(actionLogDialogAtom, { open: true, threadId });
});
export const closeActionLogDialogAtom = atom(null, (_get, set) => {
set(actionLogDialogAtom, { open: false, threadId: null });
});

View file

@ -1,19 +0,0 @@
import { atom } from "jotai";
interface ActionLogSheetState {
open: boolean;
threadId: number | null;
}
export const actionLogSheetAtom = atom<ActionLogSheetState>({
open: false,
threadId: null,
});
export const openActionLogSheetAtom = atom(null, (_get, set, threadId: number) => {
set(actionLogSheetAtom, { open: true, threadId });
});
export const closeActionLogSheetAtom = atom(null, (_get, set) => {
set(actionLogSheetAtom, { open: false, threadId: null });
});

View file

@ -0,0 +1,3 @@
import { atom } from "jotai";
export const announcementsDialogAtom = atom<boolean>(false);

View file

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

View file

@ -36,14 +36,16 @@ const initialState: TabsState = {
// Prevent race conditions where route-sync recreates a just-deleted chat tab.
const deletedChatIdsAtom = atom<Set<number>>(new Set<number>());
const sessionStorageAdapter = createJSONStorage<TabsState>(
() => (typeof window !== "undefined" ? sessionStorage : undefined) as Storage
// Persist tabs in localStorage so they survive a hard refresh and let the user
// keep tabs open across multiple search spaces (browser-like behavior).
const localStorageAdapter = createJSONStorage<TabsState>(
() => (typeof window !== "undefined" ? localStorage : undefined) as Storage
);
export const tabsStateAtom = atomWithStorage<TabsState>(
"surfsense:tabs",
initialState,
sessionStorageAdapter,
localStorageAdapter,
{ getOnInit: true }
);
@ -72,7 +74,17 @@ export const syncChatTabAtom = atom(
(
get,
set,
{ chatId, title, chatUrl }: { chatId: number | null; title?: string; chatUrl?: string }
{
chatId,
title,
chatUrl,
searchSpaceId,
}: {
chatId: number | null;
title?: string;
chatUrl?: string;
searchSpaceId: number;
}
) => {
if (chatId && get(deletedChatIdsAtom).has(chatId)) {
return;
@ -87,20 +99,32 @@ export const syncChatTabAtom = atom(
...state,
activeTabId: tabId,
tabs: state.tabs.map((t) =>
t.id === tabId ? { ...t, title: title || t.title, chatUrl: chatUrl || t.chatUrl } : t
t.id === tabId
? {
...t,
title: title || t.title,
chatUrl: chatUrl || t.chatUrl,
searchSpaceId: searchSpaceId ?? t.searchSpaceId,
}
: t
),
});
return;
}
// If navigating to a new chat (no chatId), ensure there's a "new chat" tab
// scoped to the current search space.
if (!chatId) {
const hasNewChatTab = state.tabs.some((t) => t.id === "chat-new");
if (hasNewChatTab) {
set(tabsStateAtom, { ...state, activeTabId: "chat-new" });
set(tabsStateAtom, {
...state,
activeTabId: "chat-new",
tabs: state.tabs.map((t) => (t.id === "chat-new" ? { ...t, searchSpaceId, chatUrl } : t)),
});
} else {
set(tabsStateAtom, {
tabs: [...state.tabs, INITIAL_CHAT_TAB],
tabs: [...state.tabs, { ...INITIAL_CHAT_TAB, searchSpaceId, chatUrl }],
activeTabId: "chat-new",
});
}
@ -112,9 +136,10 @@ export const syncChatTabAtom = atom(
const newTab: Tab = {
id: tabId,
type: "chat",
title: title || `Chat ${chatId}`,
title: title || "New Chat",
chatId,
chatUrl,
searchSpaceId,
};
let updatedTabs: Tab[];

View file

@ -1,9 +1,9 @@
"use client";
import { useAtomValue, useSetAtom } from "jotai";
import { Activity } from "lucide-react";
import { Workflow } from "lucide-react";
import { useCallback } from "react";
import { openActionLogSheetAtom } from "@/atoms/agent/action-log-sheet.atom";
import { openActionLogDialogAtom } from "@/atoms/agent/action-log-dialog.atom";
import { agentFlagsAtom } from "@/atoms/agent/agent-flags-query.atom";
import { Button } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
@ -13,7 +13,7 @@ interface ActionLogButtonProps {
}
/**
* Header button that opens the agent action log sheet for the current
* Header button that opens the agent action log dialog for the current
* thread. Renders nothing when:
* - the action log feature flag is off (graceful no-op for older
* deployments), OR
@ -21,7 +21,7 @@ interface ActionLogButtonProps {
*/
export function ActionLogButton({ threadId }: ActionLogButtonProps) {
const { data: flags } = useAtomValue(agentFlagsAtom);
const open = useSetAtom(openActionLogSheetAtom);
const open = useSetAtom(openActionLogDialogAtom);
const enabled = !!flags?.enable_action_log && !flags?.disable_new_agent_stack;
@ -41,7 +41,7 @@ export function ActionLogButton({ threadId }: ActionLogButtonProps) {
aria-label="Open agent action log"
onClick={handleClick}
>
<Activity className="size-4" />
<Workflow className="size-4 text-muted-foreground" />
</Button>
</TooltipTrigger>
<TooltipContent>Agent actions</TooltipContent>

View file

@ -2,35 +2,25 @@
import { useQueryClient } from "@tanstack/react-query";
import { useAtom, useAtomValue } from "jotai";
import { Activity, RefreshCcw } from "lucide-react";
import { RefreshCcw, Workflow } from "lucide-react";
import { useCallback } from "react";
import { actionLogSheetAtom } from "@/atoms/agent/action-log-sheet.atom";
import { actionLogDialogAtom } from "@/atoms/agent/action-log-dialog.atom";
import { agentFlagsAtom } from "@/atoms/agent/agent-flags-query.atom";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogDescription, DialogTitle } from "@/components/ui/dialog";
import { Separator } from "@/components/ui/separator";
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import { Skeleton } from "@/components/ui/skeleton";
import { agentActionsQueryKey, useAgentActionsQuery } from "@/hooks/use-agent-actions-query";
import { ActionLogItem } from "./action-log-item";
function EmptyState() {
return (
<div className="flex flex-1 flex-col items-center justify-center gap-3 px-6 text-center">
<div className="flex size-12 items-center justify-center rounded-full bg-muted">
<Activity className="size-5 text-muted-foreground" />
</div>
<div className="flex flex-col gap-1">
<p className="text-sm font-medium">No actions logged yet</p>
<p className="text-xs text-muted-foreground">
Once the agent calls a tool in this thread, it will show up here. From the log you can
inspect arguments and revert reversible actions.
<div className="flex flex-1 flex-col items-center justify-center gap-4 px-6 pb-12 text-center">
<div className="flex max-w-[260px] flex-col gap-1.5">
<p className="text-sm font-semibold tracking-tight">No actions logged yet</p>
<p className="text-xs leading-relaxed text-muted-foreground">
A complete audit trail of every tool the agent uses in this thread will appear here
</p>
</div>
</div>
@ -39,15 +29,15 @@ function EmptyState() {
function DisabledState() {
return (
<div className="flex flex-1 flex-col items-center justify-center gap-3 px-6 text-center">
<div className="flex size-12 items-center justify-center rounded-full bg-muted">
<Activity className="size-5 text-muted-foreground" />
<div className="flex flex-1 flex-col items-center justify-center gap-4 px-6 pb-12 text-center">
<div className="flex size-12 items-center justify-center rounded-full border border-popover-border bg-muted/40">
<Workflow className="size-5 text-muted-foreground" strokeWidth={1.75} />
</div>
<div className="flex flex-col gap-1">
<p className="text-sm font-medium">Action log is disabled</p>
<p className="text-xs text-muted-foreground">
This deployment hasn't enabled the agent action log. An admin can flip
<code className="ml-1 rounded bg-muted px-1 text-[10px]">
<div className="flex max-w-[280px] flex-col gap-1.5">
<p className="text-sm font-semibold tracking-tight">Action log is disabled</p>
<p className="text-xs leading-relaxed text-muted-foreground">
This deployment hasn't enabled the agent action log. An admin can enable{" "}
<code className="rounded bg-muted px-1 py-0.5 font-mono text-[10px] text-foreground">
SURFSENSE_ENABLE_ACTION_LOG
</code>
.
@ -69,13 +59,12 @@ function LoadingState() {
);
}
export function ActionLogSheet() {
const [state, setState] = useAtom(actionLogSheetAtom);
export function ActionLogDialog() {
const [state, setState] = useAtom(actionLogDialogAtom);
const queryClient = useQueryClient();
const { data: flags } = useAtomValue(agentFlagsAtom);
const actionLogEnabled = !!flags?.enable_action_log && !flags?.disable_new_agent_stack;
const revertEnabled = !!flags?.enable_revert_route && !flags?.disable_new_agent_stack;
const threadId = state.threadId;
@ -84,6 +73,13 @@ export function ActionLogSheet() {
{ enabled: state.open && actionLogEnabled }
);
const handleOpenChange = useCallback(
(open: boolean) => {
setState((current) => (open ? { ...current, open } : { open: false, threadId: null }));
},
[setState]
);
const handleRevertSuccess = useCallback(() => {
if (threadId !== null) {
queryClient.invalidateQueries({ queryKey: agentActionsQueryKey(threadId) });
@ -91,42 +87,33 @@ export function ActionLogSheet() {
}, [queryClient, threadId]);
return (
<Sheet open={state.open} onOpenChange={(open) => setState((s) => ({ ...s, open }))}>
<SheetContent
side="right"
className="flex h-full w-full flex-col gap-0 overflow-hidden p-0 sm:max-w-md"
>
<SheetHeader className="shrink-0 border-b px-4 py-4">
<div className="flex items-center justify-between gap-2">
<Dialog open={state.open} onOpenChange={handleOpenChange}>
<DialogContent className="select-none flex h-[90vh] max-h-[640px] w-[95vw] max-w-[900px] flex-col gap-0 overflow-hidden p-0 [--card:var(--popover)] md:h-[80vh]">
<div className="shrink-0 px-6 pb-3 pt-6 pr-28">
<div className="flex items-center gap-2">
<Activity className="size-4 text-muted-foreground" />
<SheetTitle className="text-base font-semibold">Agent actions</SheetTitle>
{data?.total !== undefined && data.total > 0 && (
<DialogTitle className="text-lg font-semibold">Agent actions</DialogTitle>
{data?.total !== undefined && data.total > 0 ? (
<Badge variant="secondary" className="text-[10px]">
{data.total}
</Badge>
)}
) : null}
</div>
<DialogDescription className="sr-only">
Audit trail of every tool call the agent made in this thread.
</DialogDescription>
<Separator className="mt-4" />
</div>
<Button
size="sm"
variant="ghost"
onClick={() => refetch()}
disabled={isFetching || !actionLogEnabled}
className="size-8 p-0"
className="absolute right-14 top-4 size-8 rounded-full p-0 text-muted-foreground hover:bg-accent hover:text-accent-foreground"
aria-label="Refresh action log"
>
<RefreshCcw className={isFetching ? "size-3.5 animate-spin" : "size-3.5"} />
</Button>
</div>
<SheetDescription className="text-xs text-muted-foreground">
Audit trail of every tool call the agent made in this thread.
{revertEnabled
? " Reversible actions can be undone in place."
: " Reverts are read-only on this deployment."}
</SheetDescription>
</SheetHeader>
<Separator />
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto scrollbar-thin">
{!actionLogEnabled ? (
@ -148,7 +135,7 @@ export function ActionLogSheet() {
) : items.length === 0 ? (
<EmptyState />
) : (
<div className="flex flex-col gap-2 p-3">
<div className="flex flex-col gap-2 px-4 pb-4">
{items.map((action) => (
<ActionLogItem
key={action.id}
@ -157,15 +144,15 @@ export function ActionLogSheet() {
onRevertSuccess={handleRevertSuccess}
/>
))}
{data?.has_more && (
{data?.has_more ? (
<p className="py-2 text-center text-[11px] text-muted-foreground">
Showing {items.length} of {data.total}. Older actions are paginated.
</p>
)}
) : null}
</div>
)}
</div>
</SheetContent>
</Sheet>
</DialogContent>
</Dialog>
);
}

View file

@ -1,6 +1,6 @@
"use client";
import { ChevronRight, RotateCcw, ShieldOff, Undo2 } from "lucide-react";
import { Check, ChevronRight, Copy, RotateCcw, Undo2 } from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import {
@ -16,7 +16,6 @@ import {
} from "@/components/ui/alert-dialog";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import { getToolDisplayName, getToolIcon } from "@/contracts/enums/toolIcons";
import { type AgentAction, agentActionsApiService } from "@/lib/apis/agent-actions-api.service";
import { AppError } from "@/lib/error";
@ -29,10 +28,55 @@ interface ActionLogItemProps {
onRevertSuccess: () => void;
}
function formatPrimitiveValue(value: unknown) {
if (value === null) return "null";
if (value === undefined) return "undefined";
if (typeof value === "string") return value;
if (typeof value === "number" || typeof value === "boolean") return String(value);
return JSON.stringify(value, null, 2);
}
function ArgumentValue({ value }: { value: unknown }) {
const formatted = formatPrimitiveValue(value);
const isBlockValue =
typeof value === "object" ||
(typeof value === "string" && (value.includes("\n") || value.length > 120));
if (isBlockValue) {
return (
<pre className="mt-2 whitespace-pre-wrap break-words bg-popover px-4 py-3 text-[11px] leading-relaxed text-popover-foreground/80">
{formatted}
</pre>
);
}
return (
<p className="mt-1 break-words font-mono text-[11px] leading-relaxed text-popover-foreground/80">
{formatted}
</p>
);
}
function StructuredArguments({ args }: { args: Record<string, unknown> }) {
return (
<div className="divide-y divide-popover-border border-t border-popover-border">
{Object.entries(args).map(([key, value]) => (
<div key={key} className="bg-popover">
<div className="px-4 py-3">
<p className="font-mono text-[10px] font-medium text-muted-foreground">{key}</p>
<ArgumentValue value={value} />
</div>
</div>
))}
</div>
);
}
export function ActionLogItem({ action, threadId, onRevertSuccess }: ActionLogItemProps) {
const [isExpanded, setIsExpanded] = useState(false);
const [isReverting, setIsReverting] = useState(false);
const [confirmOpen, setConfirmOpen] = useState(false);
const [copiedSection, setCopiedSection] = useState<"arguments" | null>(null);
const isAlreadyReverted = action.reverted_by_action_id !== null;
const isRevertAction = action.is_revert_action;
@ -42,11 +86,22 @@ export function ActionLogItem({ action, threadId, onRevertSuccess }: ActionLogIt
const displayName = getToolDisplayName(action.tool_name);
const argsPreview = action.args ? JSON.stringify(action.args, null, 2) : null;
const truncatedArgs =
argsPreview && argsPreview.length > 600 ? `${argsPreview.slice(0, 600)}` : argsPreview;
const canRevert = action.reversible && !isAlreadyReverted && !isRevertAction && !hasError;
const handleCopyArguments = async () => {
if (!argsPreview) return;
try {
await navigator.clipboard.writeText(argsPreview);
setCopiedSection("arguments");
toast.success("Arguments copied");
window.setTimeout(() => setCopiedSection(null), 1200);
} catch {
toast.error("Failed to copy arguments.");
}
};
const handleRevert = async () => {
setIsReverting(true);
try {
@ -70,17 +125,18 @@ export function ActionLogItem({ action, threadId, onRevertSuccess }: ActionLogIt
return (
<div
className={cn(
"rounded-lg border bg-card transition-colors",
"overflow-hidden rounded-lg border border-popover-border bg-popover text-popover-foreground transition-colors",
isAlreadyReverted && "opacity-70"
)}
>
<button
<Button
type="button"
variant="ghost"
onClick={() => setIsExpanded((v) => !v)}
className="flex w-full items-start gap-3 p-3 text-left hover:bg-muted/40"
className="h-auto w-full items-start justify-start gap-3 rounded-none p-3 text-left hover:bg-accent hover:text-accent-foreground"
aria-expanded={isExpanded}
>
<div className="flex size-8 shrink-0 items-center justify-center rounded-md bg-muted">
<div className="flex size-8 shrink-0 items-center justify-center rounded-md bg-accent">
{isRevertAction ? (
<Undo2 className="size-4 text-muted-foreground" />
) : (
@ -101,7 +157,10 @@ export function ActionLogItem({ action, threadId, onRevertSuccess }: ActionLogIt
</Badge>
)}
{!isRevertAction && action.reversible && !isAlreadyReverted && (
<Badge variant="outline" className="text-[10px]">
<Badge
variant="secondary"
className="border-0 bg-neutral-200 px-1.5 py-0.5 text-[10px] text-neutral-700 dark:bg-neutral-700 dark:text-neutral-200"
>
Reversible
</Badge>
)}
@ -115,55 +174,67 @@ export function ActionLogItem({ action, threadId, onRevertSuccess }: ActionLogIt
</div>
<ChevronRight
className={cn(
"size-4 shrink-0 text-muted-foreground transition-transform",
"size-4 shrink-0 self-center text-muted-foreground transition-transform",
isExpanded && "rotate-90"
)}
/>
</button>
</Button>
{isExpanded && (
<div className="flex flex-col gap-3 border-t bg-muted/20 p-3">
{truncatedArgs && (
<div>
<p className="mb-1 text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
<div className="flex flex-col border-t border-popover-border bg-accent/80">
{action.args && argsPreview && (
<div className="border-b border-popover-border">
<div className="flex items-center justify-between px-4 py-2">
<p className="text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
Arguments
</p>
<pre className="max-h-48 overflow-auto rounded-md bg-background p-2 text-[11px] text-foreground/80">
{truncatedArgs}
</pre>
<Button
type="button"
size="sm"
variant="ghost"
onClick={handleCopyArguments}
className="size-6 rounded-lg p-0 text-muted-foreground hover:bg-popover hover:text-popover-foreground"
aria-label={copiedSection === "arguments" ? "Arguments copied" : "Copy arguments"}
>
{copiedSection === "arguments" ? (
<Check className="size-3" />
) : (
<Copy className="size-3" />
)}
</Button>
</div>
<StructuredArguments args={action.args} />
</div>
)}
{action.error && (
<div>
<p className="mb-1 text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
<div className="border-b border-popover-border">
<p className="px-4 py-2 text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
Error
</p>
<pre className="max-h-32 overflow-auto rounded-md bg-destructive/10 p-2 text-[11px] text-destructive">
<pre className="max-h-32 overflow-auto border-t border-popover-border bg-destructive/10 px-4 py-3 text-[11px] text-destructive">
{JSON.stringify(action.error, null, 2)}
</pre>
</div>
)}
{action.reverse_descriptor && (
<div>
<p className="mb-1 text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
<div className="border-b border-popover-border">
<p className="px-4 py-2 text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
Reverse plan
</p>
<pre className="max-h-32 overflow-auto rounded-md bg-background p-2 text-[11px] text-foreground/80">
<pre className="max-h-32 overflow-auto border-t border-popover-border bg-popover px-4 py-3 text-[11px] text-popover-foreground/80">
{JSON.stringify(action.reverse_descriptor, null, 2)}
</pre>
</div>
)}
<Separator />
<div className="flex items-center justify-between">
<div className="flex items-center justify-between px-4 py-3">
<p className="text-[10px] text-muted-foreground">
Action ID: <span className="font-mono">{action.id}</span>
</p>
{canRevert ? (
<AlertDialog open={confirmOpen} onOpenChange={setConfirmOpen}>
<AlertDialogTrigger asChild>
<Button size="sm" variant="outline" className="gap-1.5">
<Button size="sm" variant="secondary" className="gap-1.5">
<RotateCcw className="size-3.5" />
Revert
</Button>
@ -185,6 +256,7 @@ export function ActionLogItem({ action, threadId, onRevertSuccess }: ActionLogIt
handleRevert();
}}
disabled={isReverting}
className="bg-secondary text-secondary-foreground hover:bg-secondary/80 focus-visible:ring-0"
>
{isReverting ? "Reverting…" : "Revert"}
</AlertDialogAction>
@ -193,7 +265,6 @@ export function ActionLogItem({ action, threadId, onRevertSuccess }: ActionLogIt
</AlertDialog>
) : (
<div className="flex items-center gap-1.5 text-[11px] text-muted-foreground">
<ShieldOff className="size-3.5" />
{isAlreadyReverted
? "Already reverted"
: isRevertAction

View file

@ -0,0 +1,50 @@
"use client";
import { useAtom } from "jotai";
import { useEffect } from "react";
import { announcementsDialogAtom } from "@/atoms/layout/dialogs.atom";
import { AnnouncementCard } from "@/components/announcements/AnnouncementCard";
import { AnnouncementsEmptyState } from "@/components/announcements/AnnouncementsEmptyState";
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
import { Separator } from "@/components/ui/separator";
import { useAnnouncements } from "@/hooks/use-announcements";
export function AnnouncementsDialog() {
const [open, setOpen] = useAtom(announcementsDialogAtom);
const { announcements, markAllRead } = useAnnouncements();
// Auto-mark all visible announcements as read when the dialog opens
useEffect(() => {
if (open) {
markAllRead();
}
}, [open, markAllRead]);
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="select-none max-w-[900px] w-[95vw] md:w-[90vw] h-[90vh] md:h-[80vh] max-h-[640px] flex flex-col p-0 gap-0 overflow-hidden bg-popover text-popover-foreground">
<DialogTitle className="sr-only">What's New</DialogTitle>
<div className="flex flex-1 flex-col overflow-hidden min-w-0">
<div className="px-6 md:px-8 pt-6 pb-2 shrink-0">
<h2 className="text-lg font-semibold">What's New</h2>
<Separator className="mt-4" />
</div>
<div className="flex-1 overflow-y-auto overflow-x-hidden">
<div className="px-4 md:px-8 pt-4 pb-6 min-w-0">
{announcements.length === 0 ? (
<AnnouncementsEmptyState />
) : (
<div className="flex flex-col gap-4">
{announcements.map((announcement) => (
<AnnouncementCard key={announcement.id} announcement={announcement} />
))}
</div>
)}
</div>
</div>
</div>
</DialogContent>
</Dialog>
);
}

View file

@ -2,13 +2,13 @@ import { BellOff } from "lucide-react";
export function AnnouncementsEmptyState() {
return (
<div className="flex flex-col items-center justify-center py-16 text-center">
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-muted mb-4">
<BellOff className="h-7 w-7 text-muted-foreground" />
<div className="flex flex-col items-center justify-center py-12 text-center">
<div className="mb-3 flex h-12 w-12 items-center justify-center rounded-full bg-muted">
<BellOff className="h-5 w-5 text-muted-foreground" />
</div>
<h3 className="text-lg font-semibold">No announcements</h3>
<p className="mt-1 text-sm text-muted-foreground max-w-sm">
You're all caught up! New announcements will appear here.
<h3 className="text-sm font-semibold">Nothing new yet</h3>
<p className="mt-1 max-w-xs text-xs text-muted-foreground">
You're all caught up! New updates will appear here.
</p>
</div>
);

View file

@ -24,8 +24,6 @@ import dynamic from "next/dynamic";
import type { FC } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { commentsEnabledAtom, targetCommentIdAtom } from "@/atoms/chat/current-thread.atom";
import { tryGetHostname } from "@/lib/url";
import {
globalNewLLMConfigsAtom,
newLLMConfigsAtom,
@ -60,6 +58,7 @@ import { useComments } from "@/hooks/use-comments";
import { useMediaQuery } from "@/hooks/use-media-query";
import { useElectronAPI } from "@/hooks/use-platform";
import { getProviderIcon } from "@/lib/provider-icons";
import { tryGetHostname } from "@/lib/url";
import { cn } from "@/lib/utils";
// Captured once at module load — survives client-side navigations that strip the query param.
@ -138,14 +137,15 @@ const MobileCitationDrawer: FC = () => {
return (
<>
<button
<Button
type="button"
variant="ghost"
onClick={() => setOpen(true)}
className={cn(
"isolate inline-flex cursor-pointer items-center gap-2 rounded-lg px-3 py-2",
"isolate h-auto cursor-pointer gap-2 rounded-lg px-3 py-2",
"bg-muted/40 outline-none",
"transition-colors duration-150",
"hover:bg-muted/70",
"hover:bg-accent hover:text-accent-foreground",
"focus-visible:ring-ring focus-visible:ring-2"
)}
>
@ -188,7 +188,7 @@ const MobileCitationDrawer: FC = () => {
<span className="text-muted-foreground text-sm tabular-nums">
{citations.length} source{citations.length !== 1 && "s"}
</span>
</button>
</Button>
<Drawer open={open} onOpenChange={setOpen}>
<DrawerContent className="max-h-[85vh] flex flex-col">
@ -198,11 +198,12 @@ const MobileCitationDrawer: FC = () => {
</DrawerHeader>
<div className="overflow-y-auto flex-1 min-h-0 px-1 pb-6">
{citations.map((citation) => (
<button
<Button
key={citation.id}
type="button"
variant="ghost"
onClick={() => handleNavigate(citation)}
className="group flex w-full items-center gap-2.5 rounded-md px-3 py-2.5 text-left transition-colors hover:bg-muted focus-visible:bg-muted focus-visible:outline-none"
className="group h-auto w-full justify-start gap-2.5 px-3 py-2.5 text-left hover:bg-accent hover:text-accent-foreground focus-visible:bg-muted"
>
{citation.favicon ? (
// biome-ignore lint/performance/noImgElement: external favicon from arbitrary domain
@ -224,7 +225,7 @@ const MobileCitationDrawer: FC = () => {
<p className="text-muted-foreground truncate text-xs">{citation.domain}</p>
</div>
<ExternalLink className="text-muted-foreground size-3.5 shrink-0 opacity-0 transition-opacity group-hover:opacity-100" />
</button>
</Button>
))}
</div>
</DrawerContent>
@ -266,7 +267,7 @@ function formatTurnCost(micros: number): string {
return "$0";
}
const MessageInfoDropdown: FC = () => {
const MessageInfoDropdown: FC<{ chatTurnId: string | null | undefined }> = ({ chatTurnId }) => {
const messageId = useAuiState(({ message }) => message?.id);
const createdAt = useAuiState(({ message }) => message?.createdAt);
const usage = useTokenUsage(messageId);
@ -305,7 +306,7 @@ const MessageInfoDropdown: FC = () => {
</ActionBarMorePrimitive.Trigger>
<ActionBarMorePrimitive.Content
align="start"
className="bg-muted text-popover-foreground z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[180px] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border dark:border-neutral-700 p-1 shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2"
className="bg-popover text-popover-foreground z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[180px] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md p-1 shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2"
>
{createdAt && (
<DropdownMenuLabel className="text-xs text-muted-foreground font-normal select-none">
@ -314,7 +315,7 @@ const MessageInfoDropdown: FC = () => {
)}
{hasUsage && (
<>
<ActionBarMorePrimitive.Separator className="bg-border mx-2 my-1 h-px" />
<ActionBarMorePrimitive.Separator className="bg-popover-border mx-1 my-1 h-px" />
{models.length > 0 ? (
models.map(([model, counts]) => {
const { name, icon } = resolveModel(model);
@ -322,7 +323,7 @@ const MessageInfoDropdown: FC = () => {
return (
<ActionBarMorePrimitive.Item
key={model}
className="focus:bg-neutral-200 dark:focus:bg-neutral-700 relative flex cursor-default flex-col items-start gap-0.5 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none"
className="focus:bg-accent focus:text-accent-foreground relative flex cursor-default flex-col items-start gap-0.5 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none"
onSelect={(e) => e.preventDefault()}
>
<span className="flex items-center gap-1.5 text-xs font-medium">
@ -338,7 +339,7 @@ const MessageInfoDropdown: FC = () => {
})
) : (
<ActionBarMorePrimitive.Item
className="focus:bg-neutral-200 dark:focus:bg-neutral-700 relative flex cursor-default flex-col items-start gap-0.5 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none"
className="focus:bg-accent focus:text-accent-foreground relative flex cursor-default flex-col items-start gap-0.5 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none"
onSelect={(e) => e.preventDefault()}
>
<span className="text-xs text-muted-foreground">
@ -351,6 +352,7 @@ const MessageInfoDropdown: FC = () => {
)}
</>
)}
<RevertTurnButton chatTurnId={chatTurnId} variant="menu-item" />
</ActionBarMorePrimitive.Content>
</ActionBarMorePrimitive.Root>
);
@ -500,9 +502,10 @@ export const AssistantMessage: FC = () => {
>
{/* Fixed trigger slot prevents any vertical reflow when visibility changes */}
<div className="mr-2 mb-1 flex h-7 justify-end">
<button
<Button
ref={isDesktop ? commentTriggerRef : undefined}
type="button"
variant="ghost"
onClick={
showCommentTrigger
? isDesktop
@ -513,14 +516,14 @@ export const AssistantMessage: FC = () => {
aria-hidden={!showCommentTrigger}
tabIndex={showCommentTrigger ? 0 : -1}
className={cn(
"flex items-center gap-1.5 rounded-full px-3 py-1 text-sm transition-colors",
"h-auto gap-1.5 rounded-full px-3 py-1 text-sm transition-colors",
"opacity-0 pointer-events-none",
showCommentTrigger && "opacity-100 pointer-events-auto",
isDesktop && isInlineOpen
? "bg-primary/10 text-primary"
: hasComments
? "text-primary hover:bg-primary/10"
: "text-muted-foreground hover:text-foreground hover:bg-muted"
: "text-muted-foreground hover:text-accent-foreground hover:bg-accent hover:text-accent-foreground"
)}
>
<MessageCircleReply className={cn("size-3.5", hasComments && "fill-current")} />
@ -531,7 +534,7 @@ export const AssistantMessage: FC = () => {
) : (
<span>Add comment</span>
)}
</button>
</Button>
</div>
{/* Desktop floating comment panel — overlays on top of chat content */}
@ -582,7 +585,7 @@ const AssistantActionBar: FC = () => {
className="aui-assistant-action-bar-root -ml-1 col-start-3 row-start-2 flex gap-1 text-muted-foreground md:data-floating:absolute md:data-floating:rounded-md md:data-floating:p-1 [&>button]:opacity-100 md:[&>button]:opacity-[var(--aui-button-opacity,1)]"
>
<ActionBarPrimitive.Copy asChild>
<TooltipIconButton tooltip="Copy to clipboard">
<TooltipIconButton tooltip="Copy">
<AuiIf condition={({ message }) => message.isCopied}>
<CheckIcon />
</AuiIf>
@ -614,10 +617,7 @@ const AssistantActionBar: FC = () => {
<ClipboardPaste />
</TooltipIconButton>
)}
<MessageInfoDropdown />
<div className="ml-auto">
<RevertTurnButton chatTurnId={chatTurnId} />
</div>
<MessageInfoDropdown chatTurnId={chatTurnId} />
</ActionBarPrimitive.Root>
);
};

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