Merge pull request #1403 from AnishSarkar22/feat/ui-revamp

feat: UI revamp
This commit is contained in:
Rohan Verma 2026-05-19 14:06:16 -07:00 committed by GitHub
commit 8174949b38
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
269 changed files with 6662 additions and 6150 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>
}
```

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

@ -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

@ -81,6 +81,7 @@ from app.tasks.chat.streaming.helpers.interrupt_inspector import (
)
from app.utils.content_utils import bootstrap_history_from_db
from app.utils.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()
@ -216,14 +217,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()

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

@ -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"
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
className="w-full select-none"
>
<Tabs
value={activeTab}
onValueChange={(value) => {
setActiveTab(value as TabId);
}}
className="relative min-h-[37rem] w-full"
>
<div className="flex items-center justify-center rounded-lg border bg-muted/30 p-1">
<TabsList className="absolute top-20 left-1/2 -translate-x-1/2 rounded-xl bg-accent p-1">
{TABS.map((tab) => (
<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 />}
</motion.div>
</div>
<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>
);
}

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"
>
<MorePagesContent />
</motion.div>
<div className="w-full select-none space-y-6">
<MorePagesContent />
</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,42 +2,59 @@ 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">
{/* User message */}
<div className="flex justify-end">
<Skeleton className="h-12 w-56 rounded-2xl" />
<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" />
</div>
{/* Assistant message */}
<div className="flex flex-col gap-2">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-[85%]" />
<Skeleton className="h-18 w-[40%]" />
</div>
{/* User message */}
<div className="flex gap-2 justify-end">
<Skeleton className="h-12 w-72 rounded-2xl" />
</div>
{/* Assistant message */}
<div className="flex flex-col gap-2">
<Skeleton className="h-10 w-[30%]" />
<Skeleton className="h-4 w-[90%]" />
<Skeleton className="h-6 w-[60%]" />
</div>
{/* User message */}
<div className="flex gap-2 justify-end">
<Skeleton className="h-12 w-96 rounded-2xl" />
</div>
</div>
{/* Assistant message */}
<div className="flex flex-col gap-2">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-[85%]" />
<Skeleton className="h-18 w-[40%]" />
</div>
{/* User message */}
<div className="flex gap-2 justify-end">
<Skeleton className="h-12 w-72 rounded-2xl" />
</div>
{/* Assistant message */}
<div className="flex flex-col gap-2">
<Skeleton className="h-10 w-[30%]" />
<Skeleton className="h-4 w-[90%]" />
<Skeleton className="h-6 w-[60%]" />
</div>
{/* User message */}
<div className="flex gap-2 justify-end">
<Skeleton className="h-12 w-96 rounded-2xl" />
</div>
</div>
{/* 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" />
{/* Input bar */}
<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>
<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">
<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 && (
{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>
) : (
<CreateInviteDialog
roles={roles}
onCreateInvite={handleCreateInvite}
searchSpaceId={searchSpaceId}
/>
)
)}
{invitesLoading ? (
<Skeleton className="h-9 w-32 rounded-md" />
) : (
canInvite &&
activeInvites.length > 0 && (
<AllInvitesDialog invites={activeInvites} onRevokeInvite={handleRevokeInvite} />
)
)}
))}
{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>
) : (
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>
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.
<p>
Flip{" "}
<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,27 +240,36 @@ export function AgentPermissionsContent() {
patterns and are evaluated at the most specific scope first.
</p>
</div>
{!showForm && (
<Button
size="sm"
onClick={() => {
setShowForm(true);
setFormData(EMPTY_FORM);
}}
className="shrink-0 gap-1.5"
>
<Plus className="size-3.5" />
New rule
</Button>
)}
<Button
size="sm"
onClick={() => {
setShowForm(true);
setFormData(EMPTY_FORM);
}}
className="shrink-0 gap-1.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,43 +309,69 @@ 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 className="flex items-center justify-end gap-2 pt-2">
<Button
variant="ghost"
size="sm"
onClick={() => {
setShowForm(false);
setFormData(EMPTY_FORM);
}}
disabled={createMutation.isPending}
>
Cancel
</Button>
<Button
size="sm"
onClick={handleCreate}
disabled={createMutation.isPending || !formData.permission.trim()}
className="relative"
>
<span className={createMutation.isPending ? "opacity-0" : ""}>Create</span>
{createMutation.isPending && <Spinner className="absolute size-3.5" />}
</Button>
</div>
</div>
<DialogFooter>
<Button
type="button"
variant="secondary"
size="sm"
onClick={() => {
setShowForm(false);
setFormData(EMPTY_FORM);
}}
disabled={createMutation.isPending}
className="text-sm h-9"
>
Cancel
</Button>
<Button
size="sm"
onClick={handleCreate}
disabled={createMutation.isPending || !formData.permission.trim()}
className="relative text-sm h-9 min-w-[96px]"
>
<span className={createMutation.isPending ? "opacity-0" : ""}>Create</span>
{createMutation.isPending && <Spinner size="sm" className="absolute" />}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{isLoading && (
<div className="-m-1 space-y-2 p-1">
{["skeleton-a", "skeleton-b", "skeleton-c"].map((key) => (
<Card key={key} className="border-accent bg-accent/20">
<CardContent className="p-4 flex flex-col gap-3 min-h-24">
<Skeleton className="h-4 w-32 md:w-40 bg-accent" />
<Skeleton className="h-3 w-full bg-accent" />
<Skeleton className="h-3 w-24 md:w-28 bg-accent mt-auto" />
</CardContent>
</Card>
))}
</div>
)}
{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>
<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.
<p>
Showing that{" "}
<code className="rounded bg-muted px-1 text-[10px]">
SURFSENSE_DISABLE_NEW_AGENT_STACK=true
</code>
, 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,58 +75,58 @@ 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>
<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">
{prompt.mode}
</span>
{prompt.author_name && (
<span className="text-[11px] text-muted-foreground/60">
by {prompt.author_name}
<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-md border-0 bg-muted px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground">
{prompt.mode}
</span>
{prompt.author_name && (
<span className="text-[11px] text-muted-foreground/60">
by {prompt.author_name}
</span>
)}
</div>
<p
className={`mt-1 text-xs text-muted-foreground ${expandedId === prompt.id ? "whitespace-pre-wrap" : "line-clamp-2"}`}
>
{prompt.prompt}
</p>
{prompt.prompt.length > 100 && (
<Button
type="button"
variant="link"
onClick={() => setExpandedId(expandedId === prompt.id ? null : prompt.id)}
className="mt-1 h-auto cursor-pointer px-0 py-0 text-[11px] text-primary"
>
{expandedId === prompt.id ? "See less" : "See more"}
</Button>
)}
</div>
<p
className={`mt-1 text-xs text-muted-foreground ${expandedId === prompt.id ? "whitespace-pre-wrap" : "line-clamp-2"}`}
<Button
variant="ghost"
size="sm"
className="h-7 shrink-0 gap-1.5 rounded-lg px-2 text-muted-foreground hover:text-accent-foreground"
disabled={copyingIds.has(prompt.id)}
onClick={() => handleCopy(prompt.id)}
>
{prompt.prompt}
</p>
{prompt.prompt.length > 100 && (
<button
type="button"
onClick={() => setExpandedId(expandedId === prompt.id ? null : prompt.id)}
className="mt-1 text-[11px] text-primary hover:underline cursor-pointer"
>
{expandedId === prompt.id ? "See less" : "See more"}
</button>
)}
</div>
<Button
variant="outline"
size="sm"
className="shrink-0 gap-1.5"
disabled={copyingIds.has(prompt.id)}
onClick={() => handleCopy(prompt.id)}
>
{copyingIds.has(prompt.id) ? (
<Spinner className="size-3" />
) : (
<Copy className="size-3" />
)}
Add to mine
</Button>
</div>
{copyingIds.has(prompt.id) ? (
<Spinner className="size-3" />
) : (
<Copy className="size-3" />
)}
Add to mine
</Button>
</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,17 +180,19 @@ export function DesktopShortcutsContent() {
return shortcutsLoaded ? (
<div className="flex flex-col gap-3">
<div>
{HOTKEY_ROWS.map((row) => (
<HotkeyRow
key={row.key}
label={row.label}
value={shortcuts[row.key]}
defaultValue={DEFAULT_SHORTCUTS[row.key]}
icon={row.icon}
isMac={isMac}
onChange={(accel) => updateShortcut(row.key, accel)}
onReset={() => resetShortcut(row.key)}
/>
{HOTKEY_ROWS.map((row, index) => (
<div key={row.key}>
<HotkeyRow
label={row.label}
value={shortcuts[row.key]}
defaultValue={DEFAULT_SHORTCUTS[row.key]}
icon={row.icon}
isMac={isMac}
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,97 +154,150 @@ export function PromptsContent() {
Create prompt templates triggered with <ShortcutKbd keys={["/"]} className="ml-0" /> in
the chat composer.
</p>
{!showForm && (
<Button
size="sm"
onClick={() => {
setShowForm(true);
setEditingId(null);
setFormData(EMPTY_FORM);
}}
className="shrink-0 gap-1.5"
>
New
</Button>
)}
<Button
size="sm"
onClick={() => {
setShowForm(true);
setEditingId(null);
setFormData(EMPTY_FORM);
}}
className="shrink-0 gap-1.5"
>
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-2">
<Label htmlFor="prompt-name">Name</Label>
<Input
id="prompt-name"
value={formData.name}
onChange={(e) => setFormData((p) => ({ ...p, name: e.target.value }))}
placeholder="e.g. Fix grammar"
/>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="prompt-name">Name</Label>
<Input
id="prompt-name"
value={formData.name}
onChange={(e) => setFormData((p) => ({ ...p, name: e.target.value }))}
placeholder="e.g. Fix grammar"
/>
</div>
<div className="space-y-2">
<Label htmlFor="prompt-template">Prompt template</Label>
<textarea
id="prompt-template"
value={formData.prompt}
onChange={(e) => setFormData((p) => ({ ...p, prompt: e.target.value }))}
placeholder="e.g. Fix the grammar in the following text:\n\n{selection}"
rows={4}
className="w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm outline-none resize-none focus:ring-1 focus:ring-ring"
/>
<p className="text-xs text-muted-foreground">
Use{" "}
<code className="rounded bg-muted px-1 py-0.5 font-mono text-[11px]">
{"{selection}"}
</code>{" "}
to insert the input text. If omitted, the text is appended automatically.
</p>
</div>
<div className="space-y-2">
<Label htmlFor="prompt-mode">Mode</Label>
<Select
value={formData.mode}
onValueChange={(value) =>
setFormData((p) => ({ ...p, mode: value as "transform" | "explore" }))
}
>
<SelectTrigger id="prompt-mode" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="transform">
Transform rewrites or modifies your text
</SelectItem>
<SelectItem value="explore">
Explore answers a question about your text
</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-2">
<Switch
id="prompt-public"
checked={formData.is_public}
onCheckedChange={(checked) => setFormData((p) => ({ ...p, is_public: checked }))}
/>
<Label htmlFor="prompt-public" className="text-sm font-normal">
Share with community
</Label>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="prompt-template">Prompt template</Label>
<textarea
id="prompt-template"
value={formData.prompt}
onChange={(e) => setFormData((p) => ({ ...p, prompt: e.target.value }))}
placeholder="e.g. Fix the grammar in the following text:\n\n{selection}"
rows={4}
className="w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm outline-none resize-none focus:ring-1 focus:ring-ring"
/>
<p className="text-xs text-muted-foreground">
Use{" "}
<code className="rounded bg-muted px-1 py-0.5 font-mono text-[11px]">
{"{selection}"}
</code>{" "}
to insert the input text. If omitted, the text is appended automatically.
</p>
</div>
<div className="space-y-2">
<Label htmlFor="prompt-mode">Mode</Label>
<select
id="prompt-mode"
value={formData.mode}
onChange={(e) =>
setFormData((p) => ({ ...p, mode: e.target.value as "transform" | "explore" }))
}
className="w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-ring"
<DialogFooter>
<Button
type="button"
variant="secondary"
size="sm"
onClick={handleCancel}
disabled={isSaving}
className="text-sm h-9"
>
<option value="transform">Transform rewrites or modifies your text</option>
<option value="explore">Explore answers a question about your text</option>
</select>
</div>
<div className="flex items-center gap-2">
<Switch
id="prompt-public"
checked={formData.is_public}
onCheckedChange={(checked) => setFormData((p) => ({ ...p, is_public: checked }))}
/>
<Label htmlFor="prompt-public" className="text-sm font-normal">
Share with community
</Label>
</div>
<div className="flex items-center justify-end gap-2 pt-2">
<Button variant="ghost" size="sm" onClick={handleCancel}>
Cancel
</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
type="button"
title={prompt.is_public ? "Make private" : "Share with community"}
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" />
) : prompt.is_public ? (
<Lock className="size-3.5" />
) : (
<Globe className="size-3.5" />
)}
</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"
onClick={() => setDeleteTarget(prompt.id)}
>
<Trash2 className="size-3.5" />
</Button>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
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)}
>
{togglingPublicIds.has(prompt.id) ? (
<Spinner className="size-4" />
) : prompt.is_public ? (
<Lock className="size-4" />
) : (
<Globe className="size-4" />
)}
{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-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">
<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 && (
<Badge variant="secondary" className="text-[10px]">
{data.total}
</Badge>
)}
</div>
<Button
size="sm"
variant="ghost"
onClick={() => refetch()}
disabled={isFetching || !actionLogEnabled}
className="size-8 p-0"
aria-label="Refresh action log"
>
<RefreshCcw className={isFetching ? "size-3.5 animate-spin" : "size-3.5"} />
</Button>
<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">
<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>
<SheetDescription className="text-xs text-muted-foreground">
<DialogDescription className="sr-only">
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>
</DialogDescription>
<Separator className="mt-4" />
</div>
<Separator />
<Button
size="sm"
variant="ghost"
onClick={() => refetch()}
disabled={isFetching || !actionLogEnabled}
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 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">
Arguments
</p>
<pre className="max-h-48 overflow-auto rounded-md bg-background p-2 text-[11px] text-foreground/80">
{truncatedArgs}
</pre>
<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>
<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>
);
};

View file

@ -9,8 +9,7 @@ const ChatScrollToBottom: FC = () => (
<ThreadPrimitive.ScrollToBottom asChild>
<TooltipIconButton
tooltip="Scroll to bottom"
variant="outline"
className="aui-thread-scroll-to-bottom -top-12 absolute z-10 self-center rounded-full p-4 disabled:invisible dark:bg-main-panel dark:hover:bg-accent"
className="aui-thread-scroll-to-bottom -top-12 absolute z-10 self-center rounded-full border-0 bg-muted p-4 text-foreground hover:bg-accent hover:text-accent-foreground disabled:invisible"
>
<ArrowDownIcon />
</TooltipIconButton>

View file

@ -1,7 +1,8 @@
"use client";
import { useAtomValue, useSetAtom } from "jotai";
import { useAtomValue } from "jotai";
import { AlertTriangle, Settings } from "lucide-react";
import { useRouter } from "next/navigation";
import { forwardRef, useEffect, useImperativeHandle, useMemo, useState } from "react";
import { createPortal } from "react-dom";
import { statusInboxItemsAtom } from "@/atoms/inbox/status-inbox.atom";
@ -10,7 +11,6 @@ import {
llmPreferencesAtom,
} from "@/atoms/new-llm-config/new-llm-config-query.atoms";
import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms";
import { searchSpaceSettingsDialogAtom } from "@/atoms/settings/settings-dialog.atoms";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
@ -44,8 +44,8 @@ interface ConnectorIndicatorProps {
export const ConnectorIndicator = forwardRef<ConnectorIndicatorHandle, ConnectorIndicatorProps>(
(_props, ref) => {
const router = useRouter();
const searchSpaceId = useAtomValue(activeSearchSpaceIdAtom);
const setSearchSpaceSettingsDialog = useSetAtom(searchSpaceSettingsDialogAtom);
const { data: preferences = {}, isFetching: preferencesLoading } =
useAtomValue(llmPreferencesAtom);
const { data: globalConfigs = [], isFetching: globalConfigsLoading } =
@ -218,7 +218,7 @@ export const ConnectorIndicator = forwardRef<ConnectorIndicatorHandle, Connector
onPointerDownOutside={(e) => {
if (pickerOpen) e.preventDefault();
}}
className="max-w-3xl w-[95vw] sm:w-full h-[75vh] sm:h-[85vh] flex flex-col p-0 gap-0 overflow-hidden border border-border ring-0 dark:ring-0 bg-muted dark:bg-muted text-foreground [&>button]:right-4 sm:[&>button]:right-12 [&>button]:top-6 sm:[&>button]:top-10 [&>button]:opacity-80 [&>button]:hover:opacity-100 [&>button]:hover:bg-foreground/10 [&>button>svg]:size-5 select-none"
className="max-w-3xl w-[95vw] sm:w-full h-[75vh] sm:h-[85vh] flex flex-col p-0 gap-0 overflow-hidden ring-0 dark:ring-0 [&>button]:right-4 sm:[&>button]:right-12 [&>button]:top-6 sm:[&>button]:top-10 [&>button]:opacity-80 [&>button]:hover:opacity-100 [&>button]:hover:bg-accent [&>button]:hover:text-accent-foreground [&>button>svg]:size-5 select-none"
>
<DialogTitle className="sr-only">Manage Connectors</DialogTitle>
{/* YouTube Crawler View - shown when adding YouTube videos */}
@ -380,34 +380,32 @@ export const ConnectorIndicator = forwardRef<ConnectorIndicatorHandle, Connector
<div className="px-4 sm:px-12 py-4 sm:py-8 pb-12 sm:pb-16">
{/* LLM Configuration Warning */}
{!llmConfigLoading && !hasDocumentSummaryLLM && (
<Alert
variant="destructive"
className="mb-6 bg-muted/50 rounded-xl border-destructive/30"
>
<AlertTriangle className="h-4 w-4" />
<AlertTitle>LLM Configuration Required</AlertTitle>
<AlertDescription className="mt-2">
<p className="mb-3">
{isAutoMode && !hasGlobalConfigs
? "Auto mode requires a global LLM configuration. Please add one in Settings"
: "A Document Summary LLM is required to process uploads, configure one in Settings"}
</p>
<Button
size="sm"
variant="outline"
onClick={() => {
handleOpenChange(false);
setSearchSpaceSettingsDialog({
open: true,
initialTab: "models",
});
}}
>
<Settings className="mr-2 h-4 w-4" />
Go to Settings
</Button>
</AlertDescription>
</Alert>
<div className="mb-6">
<Alert variant="destructive">
<AlertTriangle />
<AlertTitle>LLM Configuration Required</AlertTitle>
<AlertDescription>
<p>
{isAutoMode && !hasGlobalConfigs
? "Auto mode requires a global LLM configuration. Please add one in Settings"
: "A Document Summary LLM is required to process uploads, configure one in Settings"}
</p>
<Button
size="sm"
variant="outline"
onClick={() => {
handleOpenChange(false);
router.push(
`/dashboard/${searchSpaceId}/search-space-settings/models`
);
}}
>
<Settings className="mr-2 h-4 w-4" />
Go to Settings
</Button>
</AlertDescription>
</Alert>
</div>
)}
<TabsContent value="all" className="m-0">
@ -446,7 +444,7 @@ export const ConnectorIndicator = forwardRef<ConnectorIndicatorHandle, Connector
</div>
</div>
{/* Bottom fade shadow */}
<div className="absolute bottom-0 left-0 right-0 h-7 bg-linear-to-t from-muted via-muted/80 to-transparent pointer-events-none z-10" />
<div className="absolute bottom-0 left-0 right-0 h-7 bg-linear-to-t from-popover via-popover/80 to-transparent pointer-events-none z-10" />
</div>
</Tabs>
)}

View file

@ -81,8 +81,8 @@ export const ConnectorCard: FC<ConnectorCardProps> = ({
className={cn(
"group relative flex items-center gap-4 p-4 rounded-xl text-left transition-all duration-200 w-full border",
status.status === "warning"
? "border-yellow-500/30 bg-slate-400/5 dark:bg-white/5 hover:bg-slate-400/10 dark:hover:bg-white/10"
: "border-border bg-slate-400/5 dark:bg-white/5 hover:bg-slate-400/10 dark:hover:bg-white/10"
? "border-yellow-500/30 bg-slate-400/5 dark:bg-white/5 hover:bg-accent hover:text-accent-foreground"
: "border-border bg-slate-400/5 dark:bg-white/5 hover:bg-accent hover:text-accent-foreground"
)}
>
<div
@ -145,9 +145,9 @@ export const ConnectorCard: FC<ConnectorCardProps> = ({
size="sm"
variant={isConnected ? "secondary" : "default"}
className={cn(
"relative h-8 text-[11px] px-3 rounded-lg shrink-0 font-medium items-center justify-center",
"relative h-8 text-[11px] px-3 shrink-0 font-medium items-center justify-center",
isConnected &&
"bg-white text-slate-700 hover:bg-slate-50 border-0 shadow-xs dark:bg-secondary dark:text-secondary-foreground dark:hover:bg-secondary/80",
"bg-white text-slate-700 hover:bg-accent hover:text-accent-foreground border-0 shadow-xs dark:bg-secondary dark:text-secondary-foreground",
!isConnected && "shadow-xs"
)}
onClick={isConnected ? onManage : onConnect}

View file

@ -2,6 +2,7 @@
import { Search, X } from "lucide-react";
import type { FC } from "react";
import { Button } from "@/components/ui/button";
import { DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { TabsList, TabsTrigger } from "@/components/ui/tabs";
import { cn } from "@/lib/utils";
@ -25,7 +26,7 @@ export const ConnectorDialogHeader: FC<ConnectorDialogHeaderProps> = ({
<div
className={cn(
"flex-shrink-0 px-4 sm:px-12 pt-5 sm:pt-10 transition-shadow duration-200 relative z-10",
isScrolled && "shadow-xl bg-muted/50 backdrop-blur-md"
isScrolled && "bg-popover shadow-xl"
)}
>
<DialogHeader>
@ -37,7 +38,7 @@ export const ConnectorDialogHeader: FC<ConnectorDialogHeaderProps> = ({
</DialogDescription>
</DialogHeader>
<div className="flex flex-col-reverse sm:flex-row sm:items-end justify-between gap-4 sm:gap-8 mt-4 sm:mt-8 border-b border-border/80 dark:border-white/5">
<div className="flex flex-col-reverse sm:flex-row sm:items-end justify-between gap-4 sm:gap-8 mt-4 sm:mt-8 border-b border-popover-border">
<TabsList className="bg-transparent p-0 gap-4 sm:gap-8 h-auto w-full sm:w-auto justify-center sm:justify-start">
<TabsTrigger
value="all"
@ -63,27 +64,29 @@ export const ConnectorDialogHeader: FC<ConnectorDialogHeaderProps> = ({
<div className="w-full sm:w-72 sm:pb-1">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-gray-500 dark:text-gray-500" />
<Search className="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
<input
type="text"
autoComplete="off"
placeholder="Search"
className={cn(
"w-full bg-slate-400/5 dark:bg-white/5 hover:bg-slate-400/10 dark:hover:bg-white/10 focus:bg-slate-400/10 dark:focus:bg-white/10 border border-border rounded-xl pl-9 py-2 text-sm transition-all outline-none placeholder:text-muted-foreground/50",
"w-full bg-slate-400/5 dark:bg-white/5 hover:bg-accent hover:text-accent-foreground focus:bg-slate-400/10 dark:focus:bg-white/10 border border-border rounded-xl pl-9 py-2 text-sm transition-all outline-none placeholder:text-muted-foreground/50",
searchQuery ? "pr-9" : "pr-4"
)}
value={searchQuery}
onChange={(e) => onSearchChange(e.target.value)}
/>
{searchQuery && (
<button
<Button
variant="ghost"
size="icon"
type="button"
onClick={() => onSearchChange("")}
className="absolute right-3 top-1/2 -translate-y-1/2 size-4 text-gray-500 dark:text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 transition-colors"
className="absolute right-1.5 top-1/2 size-7 -translate-y-1/2 text-muted-foreground transition-colors hover:bg-transparent hover:text-accent-foreground"
aria-label="Clear search"
>
<X className="size-4" />
</button>
<X data-icon="inline-start" />
</Button>
)}
</div>
</div>

View file

@ -3,6 +3,7 @@
import { AlertTriangle, X } from "lucide-react";
import type { FC } from "react";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
interface ConnectorWarningBannerProps {
@ -42,14 +43,16 @@ export const ConnectorWarningBanner: FC<ConnectorWarningBannerProps> = ({
)}
</div>
{onDismiss && (
<button
<Button
variant="ghost"
size="icon"
type="button"
onClick={handleDismiss}
className="shrink-0 p-0.5 rounded hover:bg-yellow-500/20 transition-colors"
className="size-6 shrink-0 rounded p-0 transition-colors hover:bg-yellow-500/20"
aria-label="Dismiss warning"
>
<X className="size-3.5 text-yellow-700 dark:text-yellow-300" />
</button>
<X data-icon="inline-start" className="text-yellow-700 dark:text-yellow-300" />
</Button>
)}
</div>
);

View file

@ -136,7 +136,7 @@ export const DateRangeSelector: FC<DateRangeSelectorProps> = ({
variant="outline"
size="sm"
onClick={handleClearDates}
className="text-xs sm:text-sm bg-slate-400/5 dark:bg-slate-400/5 border-slate-400/20 hover:bg-slate-400/10 dark:hover:bg-slate-400/10"
className="text-xs sm:text-sm bg-slate-400/5 dark:bg-slate-400/5 border-slate-400/20 hover:bg-accent hover:text-accent-foreground"
>
Clear Dates
</Button>
@ -145,7 +145,7 @@ export const DateRangeSelector: FC<DateRangeSelectorProps> = ({
variant="outline"
size="sm"
onClick={handleLast30Days}
className="text-xs sm:text-sm bg-slate-400/5 dark:bg-slate-400/5 border-slate-400/20 hover:bg-slate-400/10 dark:hover:bg-slate-400/10"
className="text-xs sm:text-sm bg-slate-400/5 dark:bg-slate-400/5 border-slate-400/20 hover:bg-accent hover:text-accent-foreground"
>
Last 30 Days
</Button>
@ -155,7 +155,7 @@ export const DateRangeSelector: FC<DateRangeSelectorProps> = ({
variant="outline"
size="sm"
onClick={handleNext30Days}
className="text-xs sm:text-sm bg-slate-400/5 dark:bg-slate-400/5 border-slate-400/20 hover:bg-slate-400/10 dark:hover:bg-slate-400/10"
className="text-xs sm:text-sm bg-slate-400/5 dark:bg-slate-400/5 border-slate-400/20 hover:bg-accent hover:text-accent-foreground"
>
Next 30 Days
</Button>
@ -165,7 +165,7 @@ export const DateRangeSelector: FC<DateRangeSelectorProps> = ({
variant="outline"
size="sm"
onClick={handleLastYear}
className="text-xs sm:text-sm bg-slate-400/5 dark:bg-slate-400/5 border-slate-400/20 hover:bg-slate-400/10 dark:hover:bg-slate-400/10"
className="text-xs sm:text-sm bg-slate-400/5 dark:bg-slate-400/5 border-slate-400/20 hover:bg-accent hover:text-accent-foreground"
>
Last Year
</Button>

View file

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

View file

@ -96,10 +96,10 @@ export const BookStackConnectForm: FC<ConnectFormProps> = ({ onSubmit, isSubmitt
return (
<div className="space-y-6 pb-6">
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20 p-2 sm:p-3">
<Info className="size-4 shrink-0" />
<AlertTitle className="text-xs sm:text-sm">API Token Required</AlertTitle>
<AlertDescription className="text-[10px] sm:text-xs">
<Alert>
<Info />
<AlertTitle>API Token Required</AlertTitle>
<AlertDescription>
You'll need a BookStack API Token to use this connector. You can create one from your
BookStack instance settings.
</AlertDescription>

View file

@ -172,10 +172,10 @@ export const ElasticsearchConnectForm: FC<ConnectFormProps> = ({ onSubmit, isSub
return (
<div className="space-y-6 pb-6">
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20 p-2 sm:p-3">
<Info className="size-4 shrink-0" />
<AlertTitle className="text-xs sm:text-sm">API Key Required</AlertTitle>
<AlertDescription className="text-[10px] sm:text-xs">
<Alert>
<Info />
<AlertTitle>API Key Required</AlertTitle>
<AlertDescription>
Enter your Elasticsearch cluster endpoint URL and authentication credentials to connect.
</AlertDescription>
</Alert>
@ -428,10 +428,10 @@ export const ElasticsearchConnectForm: FC<ConnectFormProps> = ({ onSubmit, isSub
</div>
)}
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20">
<Info className="h-3 w-3 sm:h-4 sm:w-4" />
<AlertTitle className="text-[10px] sm:text-xs">Index Selection Tips</AlertTitle>
<AlertDescription className="text-[9px] sm:text-[10px] mt-2">
<Alert>
<Info />
<AlertTitle>Index Selection Tips</AlertTitle>
<AlertDescription>
<ul className="list-disc pl-4 space-y-1">
<li>Use wildcards like "logs-*" to match multiple indices</li>
<li>Separate multiple indices with commas</li>
@ -643,231 +643,6 @@ export const ElasticsearchConnectForm: FC<ConnectFormProps> = ({ onSubmit, isSub
</ul>
</div>
)}
{/* Documentation Section */}
<Accordion
type="single"
collapsible
className="w-full border border-border rounded-xl bg-slate-400/5 dark:bg-white/5"
>
<AccordionItem value="documentation" className="border-0">
<AccordionTrigger className="text-sm sm:text-base font-medium px-3 sm:px-6 no-underline hover:no-underline">
Documentation
</AccordionTrigger>
<AccordionContent className="px-3 sm:px-6 pb-3 sm:pb-6 space-y-6">
<div>
<h3 className="text-sm sm:text-base font-semibold mb-2">How it works</h3>
<p className="text-[10px] sm:text-xs text-muted-foreground">
The Elasticsearch connector allows you to search and retrieve documents from your
Elasticsearch cluster. Configure connection details, select specific indices, and
set search parameters to make your existing data searchable within SurfSense.
</p>
</div>
<div className="space-y-4">
<div>
<h3 className="text-sm sm:text-base font-semibold mb-2">Connection Setup</h3>
<div className="space-y-4 sm:space-y-6">
<div>
<h4 className="text-[10px] sm:text-xs font-medium mb-2">
Step 1: Get your Elasticsearch endpoint
</h4>
<p className="text-[10px] sm:text-xs text-muted-foreground mb-3">
You'll need the endpoint URL for your Elasticsearch cluster. This typically
looks like:
</p>
<ul className="list-disc pl-5 space-y-1 text-[10px] sm:text-xs text-muted-foreground mb-4">
<li>
Cloud:{" "}
<code className="bg-muted px-1 py-0.5 rounded">
https://your-cluster.es.region.aws.com:443
</code>
</li>
<li>
Self-hosted:{" "}
<code className="bg-muted px-1 py-0.5 rounded">
https://elasticsearch.example.com:9200
</code>
</li>
</ul>
</div>
<div>
<h4 className="text-[10px] sm:text-xs font-medium mb-2">
Step 2: Configure authentication
</h4>
<p className="text-[10px] sm:text-xs text-muted-foreground mb-3">
Elasticsearch requires authentication. You can use either:
</p>
<ul className="list-disc pl-5 space-y-2 text-[10px] sm:text-xs text-muted-foreground mb-4">
<li>
<strong>API Key:</strong> A base64-encoded API key. You can create one in
Elasticsearch by running:
<pre className="bg-muted p-2 rounded mt-1 text-[9px] overflow-x-auto">
<code>POST /_security/api_key</code>
</pre>
</li>
<li>
<strong>Username & Password:</strong> Basic authentication using your
Elasticsearch username and password.
</li>
</ul>
</div>
<div>
<h4 className="text-[10px] sm:text-xs font-medium mb-2">
Step 3: Select indices
</h4>
<p className="text-[10px] sm:text-xs text-muted-foreground mb-3">
Specify which indices to search. You can:
</p>
<ul className="list-disc pl-5 space-y-1 text-[10px] sm:text-xs text-muted-foreground">
<li>
Use wildcards: <code className="bg-muted px-1 py-0.5 rounded">logs-*</code>{" "}
to match multiple indices
</li>
<li>
List specific indices:{" "}
<code className="bg-muted px-1 py-0.5 rounded">
logs-2024, documents-2024
</code>
</li>
<li>
Leave empty to search all accessible indices (not recommended for
performance)
</li>
</ul>
</div>
</div>
</div>
</div>
<div className="space-y-4">
<div>
<h3 className="text-sm sm:text-base font-semibold mb-2">Advanced Configuration</h3>
<div className="space-y-4">
<div>
<h4 className="text-[10px] sm:text-xs font-medium mb-2">Search Query</h4>
<p className="text-[10px] sm:text-xs text-muted-foreground mb-2">
The default query used for searches. Use{" "}
<code className="bg-muted px-1 py-0.5 rounded">*</code> to match all
documents, or specify a more complex Elasticsearch query.
</p>
</div>
<div>
<h4 className="text-[10px] sm:text-xs font-medium mb-2">Search Fields</h4>
<p className="text-[10px] sm:text-xs text-muted-foreground mb-2">
Limit searches to specific fields for better performance. Common fields
include:
</p>
<ul className="list-disc pl-5 space-y-1 text-[10px] sm:text-xs text-muted-foreground">
<li>
<code className="bg-muted px-1 py-0.5 rounded">title</code> - Document
titles
</li>
<li>
<code className="bg-muted px-1 py-0.5 rounded">content</code> - Main content
</li>
<li>
<code className="bg-muted px-1 py-0.5 rounded">description</code> -
Descriptions
</li>
</ul>
<p className="text-[10px] sm:text-xs text-muted-foreground mt-2">
Leave empty to search all fields in your documents.
</p>
</div>
<div>
<h4 className="text-[10px] sm:text-xs font-medium mb-2">Maximum Documents</h4>
<p className="text-[10px] sm:text-xs text-muted-foreground">
Set a limit on the number of documents retrieved per search (1-10,000). This
helps control response times and resource usage. Leave empty to use
Elasticsearch's default limit.
</p>
</div>
</div>
</div>
</div>
<div className="space-y-4">
<div>
<h3 className="text-sm sm:text-base font-semibold mb-2">Troubleshooting</h3>
<div className="space-y-4">
<div>
<h4 className="text-[10px] sm:text-xs font-medium mb-2">Connection Issues</h4>
<ul className="list-disc pl-5 space-y-2 text-[10px] sm:text-xs text-muted-foreground">
<li>
<strong>Invalid URL:</strong> Ensure your endpoint URL includes the protocol
(https://) and port number if required.
</li>
<li>
<strong>SSL/TLS Errors:</strong> Verify that your cluster uses HTTPS and the
certificate is valid. Self-signed certificates may require additional
configuration.
</li>
<li>
<strong>Connection Timeout:</strong> Check your network connectivity and
firewall settings. Ensure the Elasticsearch cluster is accessible from
SurfSense servers.
</li>
</ul>
</div>
<div>
<h4 className="text-[10px] sm:text-xs font-medium mb-2">
Authentication Issues
</h4>
<ul className="list-disc pl-5 space-y-2 text-[10px] sm:text-xs text-muted-foreground">
<li>
<strong>Invalid Credentials:</strong> Double-check your username/password or
API key. API keys must be base64-encoded.
</li>
<li>
<strong>Permission Denied:</strong> Ensure your API key or user account has
read permissions for the indices you want to search.
</li>
<li>
<strong>API Key Format:</strong> Elasticsearch API keys are typically
base64-encoded strings. Make sure you're using the full key value.
</li>
</ul>
</div>
<div>
<h4 className="text-[10px] sm:text-xs font-medium mb-2">Search Issues</h4>
<ul className="list-disc pl-5 space-y-2 text-[10px] sm:text-xs text-muted-foreground">
<li>
<strong>No Results:</strong> Verify that your index selection matches
existing indices. Use wildcards carefully.
</li>
<li>
<strong>Slow Searches:</strong> Limit the number of indices or use specific
index names instead of wildcards. Reduce the maximum documents limit.
</li>
<li>
<strong>Field Not Found:</strong> Ensure the search fields you specify
actually exist in your Elasticsearch documents.
</li>
</ul>
</div>
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20 mt-4">
<Info className="h-3 w-3 sm:h-4 sm:w-4" />
<AlertTitle className="text-[10px] sm:text-xs">Need More Help?</AlertTitle>
<AlertDescription className="text-[9px] sm:text-[10px]">
If you continue to experience issues, check your Elasticsearch cluster logs
and ensure your cluster version is compatible. For Elasticsearch Cloud
deployments, verify your access policies and IP allowlists.
</AlertDescription>
</Alert>
</div>
</div>
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
</div>
);
};

View file

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

View file

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

View file

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

View file

@ -155,7 +155,7 @@ export const MCPConnectForm: FC<ConnectFormProps> = ({ onSubmit, isSubmitting })
type="button"
variant="ghost"
size="sm"
className="h-6 px-2 text-xs text-muted-foreground hover:text-foreground"
className="h-6 px-2 text-xs text-muted-foreground hover:text-accent-foreground"
onClick={() => handleConfigChange(DEFAULT_STDIO_CONFIG)}
>
Local Example
@ -164,7 +164,7 @@ export const MCPConnectForm: FC<ConnectFormProps> = ({ onSubmit, isSubmitting })
type="button"
variant="ghost"
size="sm"
className="h-6 px-2 text-xs text-muted-foreground hover:text-foreground"
className="h-6 px-2 text-xs text-muted-foreground hover:text-accent-foreground"
onClick={() => handleConfigChange(DEFAULT_HTTP_CONFIG)}
>
Remote Example
@ -210,7 +210,7 @@ export const MCPConnectForm: FC<ConnectFormProps> = ({ onSubmit, isSubmitting })
onClick={handleTestConnection}
disabled={isTesting}
variant="secondary"
className="w-full h-8 text-[13px] px-3 rounded-lg font-medium bg-white text-slate-700 hover:bg-slate-50 border-0 shadow-xs dark:bg-secondary dark:text-secondary-foreground dark:hover:bg-secondary/80"
className="w-full h-8 text-[13px] px-3 font-medium bg-white text-slate-700 hover:bg-accent hover:text-accent-foreground border-0 shadow-xs dark:bg-secondary dark:text-secondary-foreground"
>
{isTesting ? (
<>

View file

@ -1,20 +1,17 @@
"use client";
import { Check, Copy, Info } from "lucide-react";
import { type FC, useCallback, useRef, useState } from "react";
import type { FC } from "react";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { EnumConnectorName } from "@/contracts/enums/connector";
import { useApiKey } from "@/hooks/use-api-key";
import { copyToClipboard as copyToClipboardUtil } from "@/lib/utils";
import { getConnectorBenefits } from "../connector-benefits";
import type { ConnectFormProps } from "../index";
const PLUGIN_RELEASES_URL =
"https://github.com/MODSetter/SurfSense/releases?q=obsidian&expanded=true";
const BACKEND_URL = process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL ?? "https://surfsense.com";
/**
* Obsidian connect form for the plugin-only architecture.
*
@ -30,16 +27,6 @@ const BACKEND_URL = process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL ?? "https://surf
*/
export const ObsidianConnectForm: FC<ConnectFormProps> = ({ onBack }) => {
const { apiKey, isLoading, copied, copyToClipboard } = useApiKey();
const [copiedUrl, setCopiedUrl] = useState(false);
const urlCopyTimerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
const copyServerUrl = useCallback(async () => {
const ok = await copyToClipboardUtil(BACKEND_URL);
if (!ok) return;
setCopiedUrl(true);
if (urlCopyTimerRef.current) clearTimeout(urlCopyTimerRef.current);
urlCopyTimerRef.current = setTimeout(() => setCopiedUrl(false), 2000);
}, []);
const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
@ -52,10 +39,10 @@ export const ObsidianConnectForm: FC<ConnectFormProps> = ({ onBack }) => {
that just closes the dialog (see component-level docstring). */}
<form id="obsidian-connect-form" onSubmit={handleSubmit} />
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20 p-2 sm:p-3">
<Info className="size-4 shrink-0 text-purple-500" />
<AlertTitle className="text-xs sm:text-sm">Plugin-based sync</AlertTitle>
<AlertDescription className="text-[10px] sm:text-xs">
<Alert>
<Info />
<AlertTitle>Plugin-based sync</AlertTitle>
<AlertDescription>
SurfSense now syncs Obsidian via an official plugin that runs inside Obsidian itself.
Works on desktop and mobile, in cloud and self-hosted deployments.
</AlertDescription>
@ -123,7 +110,7 @@ export const ObsidianConnectForm: FC<ConnectFormProps> = ({ onBack }) => {
variant="ghost"
size="icon"
onClick={copyToClipboard}
className="size-7 shrink-0 text-muted-foreground hover:text-foreground"
className="size-7 shrink-0 text-muted-foreground hover:text-accent-foreground"
aria-label={copied ? "Copied" : "Copy API key"}
>
{copied ? (

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