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> <html>
<body> <body>
<h1>Mocked Widget</h1> <h1>Mocked Widget</h1>
<button>Mocked Button</button> <p>Mocked widget content</p>
</body> </body>
</html> </html>
`, `,

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -81,6 +81,7 @@ from app.tasks.chat.streaming.helpers.interrupt_inspector import (
) )
from app.utils.content_utils import bootstrap_history_from_db from app.utils.content_utils import bootstrap_history_from_db
from app.utils.perf import get_perf_logger, log_system_snapshot, trim_native_heap from app.utils.perf import get_perf_logger, log_system_snapshot, trim_native_heap
from app.utils.surfsense_docs import surfsense_docs_public_url
from app.utils.user_message_multimodal import build_human_message_content from app.utils.user_message_multimodal import build_human_message_content
_background_tasks: set[asyncio.Task] = set() _background_tasks: set[asyncio.Task] = set()
@ -216,14 +217,17 @@ def format_mentioned_surfsense_docs_as_context(
) )
for doc in documents: for doc in documents:
metadata_json = json.dumps({"source": doc.source}, ensure_ascii=False) public_url = surfsense_docs_public_url(doc.source)
metadata_json = json.dumps(
{"source": doc.source, "public_url": public_url}, ensure_ascii=False
)
context_parts.append("<document>") context_parts.append("<document>")
context_parts.append("<document_metadata>") context_parts.append("<document_metadata>")
context_parts.append(f" <document_id>doc-{doc.id}</document_id>") context_parts.append(f" <document_id>doc-{doc.id}</document_id>")
context_parts.append(" <document_type>SURFSENSE_DOCS</document_type>") context_parts.append(" <document_type>SURFSENSE_DOCS</document_type>")
context_parts.append(f" <title><![CDATA[{doc.title}]]></title>") context_parts.append(f" <title><![CDATA[{doc.title}]]></title>")
context_parts.append(f" <url><![CDATA[{doc.source}]]></url>") context_parts.append(f" <url><![CDATA[{public_url}]]></url>")
context_parts.append( context_parts.append(
f" <metadata_json><![CDATA[{metadata_json}]]></metadata_json>" f" <metadata_json><![CDATA[{metadata_json}]]></metadata_json>"
) )

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -137,7 +137,7 @@ export default function RootLayout({
<WebSiteJsonLd /> <WebSiteJsonLd />
<SoftwareApplicationJsonLd /> <SoftwareApplicationJsonLd />
</head> </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> <PostHogProvider>
<LocaleProvider> <LocaleProvider>
<I18nProvider> <I18nProvider>

View file

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

View file

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

View file

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

View file

@ -1,6 +1,6 @@
"use client"; "use client";
import { ChevronRight, RotateCcw, ShieldOff, Undo2 } from "lucide-react"; import { Check, ChevronRight, Copy, RotateCcw, Undo2 } from "lucide-react";
import { useState } from "react"; import { useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { import {
@ -16,7 +16,6 @@ import {
} from "@/components/ui/alert-dialog"; } from "@/components/ui/alert-dialog";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import { getToolDisplayName, getToolIcon } from "@/contracts/enums/toolIcons"; import { getToolDisplayName, getToolIcon } from "@/contracts/enums/toolIcons";
import { type AgentAction, agentActionsApiService } from "@/lib/apis/agent-actions-api.service"; import { type AgentAction, agentActionsApiService } from "@/lib/apis/agent-actions-api.service";
import { AppError } from "@/lib/error"; import { AppError } from "@/lib/error";
@ -29,10 +28,55 @@ interface ActionLogItemProps {
onRevertSuccess: () => void; 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) { export function ActionLogItem({ action, threadId, onRevertSuccess }: ActionLogItemProps) {
const [isExpanded, setIsExpanded] = useState(false); const [isExpanded, setIsExpanded] = useState(false);
const [isReverting, setIsReverting] = useState(false); const [isReverting, setIsReverting] = useState(false);
const [confirmOpen, setConfirmOpen] = useState(false); const [confirmOpen, setConfirmOpen] = useState(false);
const [copiedSection, setCopiedSection] = useState<"arguments" | null>(null);
const isAlreadyReverted = action.reverted_by_action_id !== null; const isAlreadyReverted = action.reverted_by_action_id !== null;
const isRevertAction = action.is_revert_action; const isRevertAction = action.is_revert_action;
@ -42,11 +86,22 @@ export function ActionLogItem({ action, threadId, onRevertSuccess }: ActionLogIt
const displayName = getToolDisplayName(action.tool_name); const displayName = getToolDisplayName(action.tool_name);
const argsPreview = action.args ? JSON.stringify(action.args, null, 2) : null; 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 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 () => { const handleRevert = async () => {
setIsReverting(true); setIsReverting(true);
try { try {
@ -70,17 +125,18 @@ export function ActionLogItem({ action, threadId, onRevertSuccess }: ActionLogIt
return ( return (
<div <div
className={cn( 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" isAlreadyReverted && "opacity-70"
)} )}
> >
<button <Button
type="button" type="button"
variant="ghost"
onClick={() => setIsExpanded((v) => !v)} 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} 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 ? ( {isRevertAction ? (
<Undo2 className="size-4 text-muted-foreground" /> <Undo2 className="size-4 text-muted-foreground" />
) : ( ) : (
@ -101,7 +157,10 @@ export function ActionLogItem({ action, threadId, onRevertSuccess }: ActionLogIt
</Badge> </Badge>
)} )}
{!isRevertAction && action.reversible && !isAlreadyReverted && ( {!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 Reversible
</Badge> </Badge>
)} )}
@ -115,55 +174,67 @@ export function ActionLogItem({ action, threadId, onRevertSuccess }: ActionLogIt
</div> </div>
<ChevronRight <ChevronRight
className={cn( 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" isExpanded && "rotate-90"
)} )}
/> />
</button> </Button>
{isExpanded && ( {isExpanded && (
<div className="flex flex-col gap-3 border-t bg-muted/20 p-3"> <div className="flex flex-col border-t border-popover-border bg-accent/80">
{truncatedArgs && ( {action.args && argsPreview && (
<div> <div className="border-b border-popover-border">
<p className="mb-1 text-[10px] font-medium uppercase tracking-wide text-muted-foreground"> <div className="flex items-center justify-between px-4 py-2">
Arguments <p className="text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
</p> Arguments
<pre className="max-h-48 overflow-auto rounded-md bg-background p-2 text-[11px] text-foreground/80"> </p>
{truncatedArgs} <Button
</pre> 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> </div>
)} )}
{action.error && ( {action.error && (
<div> <div className="border-b border-popover-border">
<p className="mb-1 text-[10px] font-medium uppercase tracking-wide text-muted-foreground"> <p className="px-4 py-2 text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
Error Error
</p> </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)} {JSON.stringify(action.error, null, 2)}
</pre> </pre>
</div> </div>
)} )}
{action.reverse_descriptor && ( {action.reverse_descriptor && (
<div> <div className="border-b border-popover-border">
<p className="mb-1 text-[10px] font-medium uppercase tracking-wide text-muted-foreground"> <p className="px-4 py-2 text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
Reverse plan Reverse plan
</p> </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)} {JSON.stringify(action.reverse_descriptor, null, 2)}
</pre> </pre>
</div> </div>
)} )}
<Separator /> <div className="flex items-center justify-between px-4 py-3">
<div className="flex items-center justify-between">
<p className="text-[10px] text-muted-foreground"> <p className="text-[10px] text-muted-foreground">
Action ID: <span className="font-mono">{action.id}</span> Action ID: <span className="font-mono">{action.id}</span>
</p> </p>
{canRevert ? ( {canRevert ? (
<AlertDialog open={confirmOpen} onOpenChange={setConfirmOpen}> <AlertDialog open={confirmOpen} onOpenChange={setConfirmOpen}>
<AlertDialogTrigger asChild> <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" /> <RotateCcw className="size-3.5" />
Revert Revert
</Button> </Button>
@ -185,6 +256,7 @@ export function ActionLogItem({ action, threadId, onRevertSuccess }: ActionLogIt
handleRevert(); handleRevert();
}} }}
disabled={isReverting} disabled={isReverting}
className="bg-secondary text-secondary-foreground hover:bg-secondary/80 focus-visible:ring-0"
> >
{isReverting ? "Reverting…" : "Revert"} {isReverting ? "Reverting…" : "Revert"}
</AlertDialogAction> </AlertDialogAction>
@ -193,7 +265,6 @@ export function ActionLogItem({ action, threadId, onRevertSuccess }: ActionLogIt
</AlertDialog> </AlertDialog>
) : ( ) : (
<div className="flex items-center gap-1.5 text-[11px] text-muted-foreground"> <div className="flex items-center gap-1.5 text-[11px] text-muted-foreground">
<ShieldOff className="size-3.5" />
{isAlreadyReverted {isAlreadyReverted
? "Already reverted" ? "Already reverted"
: isRevertAction : 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() { export function AnnouncementsEmptyState() {
return ( return (
<div className="flex flex-col items-center justify-center py-16 text-center"> <div className="flex flex-col items-center justify-center py-12 text-center">
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-muted mb-4"> <div className="mb-3 flex h-12 w-12 items-center justify-center rounded-full bg-muted">
<BellOff className="h-7 w-7 text-muted-foreground" /> <BellOff className="h-5 w-5 text-muted-foreground" />
</div> </div>
<h3 className="text-lg font-semibold">No announcements</h3> <h3 className="text-sm font-semibold">Nothing new yet</h3>
<p className="mt-1 text-sm text-muted-foreground max-w-sm"> <p className="mt-1 max-w-xs text-xs text-muted-foreground">
You're all caught up! New announcements will appear here. You're all caught up! New updates will appear here.
</p> </p>
</div> </div>
); );

View file

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

View file

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

View file

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

View file

@ -81,8 +81,8 @@ export const ConnectorCard: FC<ConnectorCardProps> = ({
className={cn( className={cn(
"group relative flex items-center gap-4 p-4 rounded-xl text-left transition-all duration-200 w-full border", "group relative flex items-center gap-4 p-4 rounded-xl text-left transition-all duration-200 w-full border",
status.status === "warning" 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-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-slate-400/10 dark:hover:bg-white/10" : "border-border bg-slate-400/5 dark:bg-white/5 hover:bg-accent hover:text-accent-foreground"
)} )}
> >
<div <div
@ -145,9 +145,9 @@ export const ConnectorCard: FC<ConnectorCardProps> = ({
size="sm" size="sm"
variant={isConnected ? "secondary" : "default"} variant={isConnected ? "secondary" : "default"}
className={cn( 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 && 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" !isConnected && "shadow-xs"
)} )}
onClick={isConnected ? onManage : onConnect} onClick={isConnected ? onManage : onConnect}

View file

@ -2,6 +2,7 @@
import { Search, X } from "lucide-react"; import { Search, X } from "lucide-react";
import type { FC } from "react"; import type { FC } from "react";
import { Button } from "@/components/ui/button";
import { DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"; import { DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { TabsList, TabsTrigger } from "@/components/ui/tabs"; import { TabsList, TabsTrigger } from "@/components/ui/tabs";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@ -25,7 +26,7 @@ export const ConnectorDialogHeader: FC<ConnectorDialogHeaderProps> = ({
<div <div
className={cn( className={cn(
"flex-shrink-0 px-4 sm:px-12 pt-5 sm:pt-10 transition-shadow duration-200 relative z-10", "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> <DialogHeader>
@ -37,7 +38,7 @@ export const ConnectorDialogHeader: FC<ConnectorDialogHeaderProps> = ({
</DialogDescription> </DialogDescription>
</DialogHeader> </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"> <TabsList className="bg-transparent p-0 gap-4 sm:gap-8 h-auto w-full sm:w-auto justify-center sm:justify-start">
<TabsTrigger <TabsTrigger
value="all" value="all"
@ -63,27 +64,29 @@ export const ConnectorDialogHeader: FC<ConnectorDialogHeaderProps> = ({
<div className="w-full sm:w-72 sm:pb-1"> <div className="w-full sm:w-72 sm:pb-1">
<div className="relative"> <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 <input
type="text" type="text"
autoComplete="off" autoComplete="off"
placeholder="Search" placeholder="Search"
className={cn( 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" searchQuery ? "pr-9" : "pr-4"
)} )}
value={searchQuery} value={searchQuery}
onChange={(e) => onSearchChange(e.target.value)} onChange={(e) => onSearchChange(e.target.value)}
/> />
{searchQuery && ( {searchQuery && (
<button <Button
variant="ghost"
size="icon"
type="button" type="button"
onClick={() => onSearchChange("")} 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" aria-label="Clear search"
> >
<X className="size-4" /> <X data-icon="inline-start" />
</button> </Button>
)} )}
</div> </div>
</div> </div>

View file

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

View file

@ -136,7 +136,7 @@ export const DateRangeSelector: FC<DateRangeSelectorProps> = ({
variant="outline" variant="outline"
size="sm" size="sm"
onClick={handleClearDates} 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 Clear Dates
</Button> </Button>
@ -145,7 +145,7 @@ export const DateRangeSelector: FC<DateRangeSelectorProps> = ({
variant="outline" variant="outline"
size="sm" size="sm"
onClick={handleLast30Days} 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 Last 30 Days
</Button> </Button>
@ -155,7 +155,7 @@ export const DateRangeSelector: FC<DateRangeSelectorProps> = ({
variant="outline" variant="outline"
size="sm" size="sm"
onClick={handleNext30Days} 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 Next 30 Days
</Button> </Button>
@ -165,7 +165,7 @@ export const DateRangeSelector: FC<DateRangeSelectorProps> = ({
variant="outline" variant="outline"
size="sm" size="sm"
onClick={handleLastYear} 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 Last Year
</Button> </Button>

View file

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

View file

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

View file

@ -172,10 +172,10 @@ export const ElasticsearchConnectForm: FC<ConnectFormProps> = ({ onSubmit, isSub
return ( return (
<div className="space-y-6 pb-6"> <div className="space-y-6 pb-6">
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20 p-2 sm:p-3"> <Alert>
<Info className="size-4 shrink-0" /> <Info />
<AlertTitle className="text-xs sm:text-sm">API Key Required</AlertTitle> <AlertTitle>API Key Required</AlertTitle>
<AlertDescription className="text-[10px] sm:text-xs"> <AlertDescription>
Enter your Elasticsearch cluster endpoint URL and authentication credentials to connect. Enter your Elasticsearch cluster endpoint URL and authentication credentials to connect.
</AlertDescription> </AlertDescription>
</Alert> </Alert>
@ -428,10 +428,10 @@ export const ElasticsearchConnectForm: FC<ConnectFormProps> = ({ onSubmit, isSub
</div> </div>
)} )}
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20"> <Alert>
<Info className="h-3 w-3 sm:h-4 sm:w-4" /> <Info />
<AlertTitle className="text-[10px] sm:text-xs">Index Selection Tips</AlertTitle> <AlertTitle>Index Selection Tips</AlertTitle>
<AlertDescription className="text-[9px] sm:text-[10px] mt-2"> <AlertDescription>
<ul className="list-disc pl-4 space-y-1"> <ul className="list-disc pl-4 space-y-1">
<li>Use wildcards like "logs-*" to match multiple indices</li> <li>Use wildcards like "logs-*" to match multiple indices</li>
<li>Separate multiple indices with commas</li> <li>Separate multiple indices with commas</li>
@ -643,231 +643,6 @@ export const ElasticsearchConnectForm: FC<ConnectFormProps> = ({ onSubmit, isSub
</ul> </ul>
</div> </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> </div>
); );
}; };

View file

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

View file

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

View file

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

View file

@ -155,7 +155,7 @@ export const MCPConnectForm: FC<ConnectFormProps> = ({ onSubmit, isSubmitting })
type="button" type="button"
variant="ghost" variant="ghost"
size="sm" 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)} onClick={() => handleConfigChange(DEFAULT_STDIO_CONFIG)}
> >
Local Example Local Example
@ -164,7 +164,7 @@ export const MCPConnectForm: FC<ConnectFormProps> = ({ onSubmit, isSubmitting })
type="button" type="button"
variant="ghost" variant="ghost"
size="sm" 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)} onClick={() => handleConfigChange(DEFAULT_HTTP_CONFIG)}
> >
Remote Example Remote Example
@ -210,7 +210,7 @@ export const MCPConnectForm: FC<ConnectFormProps> = ({ onSubmit, isSubmitting })
onClick={handleTestConnection} onClick={handleTestConnection}
disabled={isTesting} disabled={isTesting}
variant="secondary" 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 ? ( {isTesting ? (
<> <>

View file

@ -1,20 +1,17 @@
"use client"; "use client";
import { Check, Copy, Info } from "lucide-react"; 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 { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { EnumConnectorName } from "@/contracts/enums/connector"; import { EnumConnectorName } from "@/contracts/enums/connector";
import { useApiKey } from "@/hooks/use-api-key"; import { useApiKey } from "@/hooks/use-api-key";
import { copyToClipboard as copyToClipboardUtil } from "@/lib/utils";
import { getConnectorBenefits } from "../connector-benefits"; import { getConnectorBenefits } from "../connector-benefits";
import type { ConnectFormProps } from "../index"; import type { ConnectFormProps } from "../index";
const PLUGIN_RELEASES_URL = const PLUGIN_RELEASES_URL =
"https://github.com/MODSetter/SurfSense/releases?q=obsidian&expanded=true"; "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. * 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 }) => { export const ObsidianConnectForm: FC<ConnectFormProps> = ({ onBack }) => {
const { apiKey, isLoading, copied, copyToClipboard } = useApiKey(); 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>) => { const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault(); event.preventDefault();
@ -52,10 +39,10 @@ export const ObsidianConnectForm: FC<ConnectFormProps> = ({ onBack }) => {
that just closes the dialog (see component-level docstring). */} that just closes the dialog (see component-level docstring). */}
<form id="obsidian-connect-form" onSubmit={handleSubmit} /> <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"> <Alert>
<Info className="size-4 shrink-0 text-purple-500" /> <Info />
<AlertTitle className="text-xs sm:text-sm">Plugin-based sync</AlertTitle> <AlertTitle>Plugin-based sync</AlertTitle>
<AlertDescription className="text-[10px] sm:text-xs"> <AlertDescription>
SurfSense now syncs Obsidian via an official plugin that runs inside Obsidian itself. SurfSense now syncs Obsidian via an official plugin that runs inside Obsidian itself.
Works on desktop and mobile, in cloud and self-hosted deployments. Works on desktop and mobile, in cloud and self-hosted deployments.
</AlertDescription> </AlertDescription>
@ -123,7 +110,7 @@ export const ObsidianConnectForm: FC<ConnectFormProps> = ({ onBack }) => {
variant="ghost" variant="ghost"
size="icon" size="icon"
onClick={copyToClipboard} 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"} aria-label={copied ? "Copied" : "Copy API key"}
> >
{copied ? ( {copied ? (

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