mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-05-21 18:55:16 +02:00
Merge pull request #1403 from AnishSarkar22/feat/ui-revamp
feat: UI revamp
This commit is contained in:
commit
8174949b38
269 changed files with 6662 additions and 6150 deletions
|
|
@ -372,7 +372,7 @@ test("mock iframe response", async ({ page }) => {
|
|||
<html>
|
||||
<body>
|
||||
<h1>Mocked Widget</h1>
|
||||
<button>Mocked Button</button>
|
||||
<p>Mocked widget content</p>
|
||||
</body>
|
||||
</html>
|
||||
`,
|
||||
|
|
|
|||
|
|
@ -100,7 +100,7 @@ use: {
|
|||
Usage:
|
||||
|
||||
```typescript
|
||||
// HTML: <button data-testid="submit-btn">Submit</button>
|
||||
// React: <Button data-testid="submit-btn">Submit</Button>
|
||||
page.getByTestId("submit-btn");
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -549,6 +549,8 @@ Preload heavy bundles before they're needed to reduce perceived latency.
|
|||
**Example: preload on hover/focus**
|
||||
|
||||
```tsx
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
function EditorButton({ onClick }: { onClick: () => void }) {
|
||||
const preload = () => {
|
||||
if (typeof window !== 'undefined') {
|
||||
|
|
@ -557,13 +559,13 @@ function EditorButton({ onClick }: { onClick: () => void }) {
|
|||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
<Button
|
||||
onMouseEnter={preload}
|
||||
onFocus={preload}
|
||||
onClick={onClick}
|
||||
>
|
||||
Open Editor
|
||||
</button>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
|
@ -1239,11 +1241,12 @@ function StaticContent() {
|
|||
**For mutations:**
|
||||
|
||||
```tsx
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useSWRMutation } from 'swr/mutation'
|
||||
|
||||
function UpdateButton() {
|
||||
const { trigger } = useSWRMutation('/api/user', updateUser)
|
||||
return <button onClick={() => trigger()}>Update</button>
|
||||
return <Button onClick={() => trigger()}>Update</Button>
|
||||
}
|
||||
```
|
||||
|
||||
|
|
@ -1369,6 +1372,8 @@ Don't subscribe to dynamic state (searchParams, localStorage) if you only read i
|
|||
**Incorrect: subscribes to all searchParams changes**
|
||||
|
||||
```tsx
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
function ShareButton({ chatId }: { chatId: string }) {
|
||||
const searchParams = useSearchParams()
|
||||
|
||||
|
|
@ -1377,13 +1382,15 @@ function ShareButton({ chatId }: { chatId: string }) {
|
|||
shareChat(chatId, { ref })
|
||||
}
|
||||
|
||||
return <button onClick={handleShare}>Share</button>
|
||||
return <Button onClick={handleShare}>Share</Button>
|
||||
}
|
||||
```
|
||||
|
||||
**Correct: reads on demand, no subscription**
|
||||
|
||||
```tsx
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
function ShareButton({ chatId }: { chatId: string }) {
|
||||
const handleShare = () => {
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
|
|
@ -1391,7 +1398,7 @@ function ShareButton({ chatId }: { chatId: string }) {
|
|||
shareChat(chatId, { ref })
|
||||
}
|
||||
|
||||
return <button onClick={handleShare}>Share</button>
|
||||
return <Button onClick={handleShare}>Share</Button>
|
||||
}
|
||||
```
|
||||
|
||||
|
|
@ -1549,6 +1556,8 @@ If a side effect is triggered by a specific user action (submit, click, drag), r
|
|||
**Incorrect: event modeled as state + effect**
|
||||
|
||||
```tsx
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
function Form() {
|
||||
const [submitted, setSubmitted] = useState(false)
|
||||
const theme = useContext(ThemeContext)
|
||||
|
|
@ -1560,13 +1569,15 @@ function Form() {
|
|||
}
|
||||
}, [submitted, theme])
|
||||
|
||||
return <button onClick={() => setSubmitted(true)}>Submit</button>
|
||||
return <Button onClick={() => setSubmitted(true)}>Submit</Button>
|
||||
}
|
||||
```
|
||||
|
||||
**Correct: do it in the handler**
|
||||
|
||||
```tsx
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
function Form() {
|
||||
const theme = useContext(ThemeContext)
|
||||
|
||||
|
|
@ -1575,7 +1586,7 @@ function Form() {
|
|||
showToast('Registered', theme)
|
||||
}
|
||||
|
||||
return <button onClick={handleSubmit}>Submit</button>
|
||||
return <Button onClick={handleSubmit}>Submit</Button>
|
||||
}
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -12,6 +12,8 @@ Preload heavy bundles before they're needed to reduce perceived latency.
|
|||
**Example (preload on hover/focus):**
|
||||
|
||||
```tsx
|
||||
import { Button } from "@/components/ui/button"
|
||||
|
||||
function EditorButton({ onClick }: { onClick: () => void }) {
|
||||
const preload = () => {
|
||||
if (typeof window !== 'undefined') {
|
||||
|
|
@ -20,13 +22,13 @@ function EditorButton({ onClick }: { onClick: () => void }) {
|
|||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
<Button
|
||||
onMouseEnter={preload}
|
||||
onFocus={preload}
|
||||
onClick={onClick}
|
||||
>
|
||||
Open Editor
|
||||
</button>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
|
|
|||
|
|
@ -45,11 +45,12 @@ function StaticContent() {
|
|||
**For mutations:**
|
||||
|
||||
```tsx
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useSWRMutation } from 'swr/mutation'
|
||||
|
||||
function UpdateButton() {
|
||||
const { trigger } = useSWRMutation('/api/user', updateUser)
|
||||
return <button onClick={() => trigger()}>Update</button>
|
||||
return <Button onClick={() => trigger()}>Update</Button>
|
||||
}
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -12,6 +12,8 @@ Don't subscribe to dynamic state (searchParams, localStorage) if you only read i
|
|||
**Incorrect (subscribes to all searchParams changes):**
|
||||
|
||||
```tsx
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
function ShareButton({ chatId }: { chatId: string }) {
|
||||
const searchParams = useSearchParams()
|
||||
|
||||
|
|
@ -20,13 +22,15 @@ function ShareButton({ chatId }: { chatId: string }) {
|
|||
shareChat(chatId, { ref })
|
||||
}
|
||||
|
||||
return <button onClick={handleShare}>Share</button>
|
||||
return <Button onClick={handleShare}>Share</Button>
|
||||
}
|
||||
```
|
||||
|
||||
**Correct (reads on demand, no subscription):**
|
||||
|
||||
```tsx
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
function ShareButton({ chatId }: { chatId: string }) {
|
||||
const handleShare = () => {
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
|
|
@ -34,6 +38,6 @@ function ShareButton({ chatId }: { chatId: string }) {
|
|||
shareChat(chatId, { ref })
|
||||
}
|
||||
|
||||
return <button onClick={handleShare}>Share</button>
|
||||
return <Button onClick={handleShare}>Share</Button>
|
||||
}
|
||||
```
|
||||
|
|
|
|||
|
|
@ -12,6 +12,8 @@ If a side effect is triggered by a specific user action (submit, click, drag), r
|
|||
**Incorrect (event modeled as state + effect):**
|
||||
|
||||
```tsx
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
function Form() {
|
||||
const [submitted, setSubmitted] = useState(false)
|
||||
const theme = useContext(ThemeContext)
|
||||
|
|
@ -23,13 +25,15 @@ function Form() {
|
|||
}
|
||||
}, [submitted, theme])
|
||||
|
||||
return <button onClick={() => setSubmitted(true)}>Submit</button>
|
||||
return <Button onClick={() => setSubmitted(true)}>Submit</Button>
|
||||
}
|
||||
```
|
||||
|
||||
**Correct (do it in the handler):**
|
||||
|
||||
```tsx
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
function Form() {
|
||||
const theme = useContext(ThemeContext)
|
||||
|
||||
|
|
@ -38,7 +42,7 @@ function Form() {
|
|||
showToast('Registered', theme)
|
||||
}
|
||||
|
||||
return <button onClick={handleSubmit}>Submit</button>
|
||||
return <Button onClick={handleSubmit}>Submit</Button>
|
||||
}
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||
|
||||
from app.db import SurfsenseDocsChunk, SurfsenseDocsDocument
|
||||
from app.utils.document_converters import embed_text
|
||||
from app.utils.surfsense_docs import surfsense_docs_public_url
|
||||
|
||||
|
||||
def format_surfsense_docs_results(results: list[tuple]) -> str:
|
||||
|
|
@ -19,13 +20,14 @@ def format_surfsense_docs_results(results: list[tuple]) -> str:
|
|||
# Group chunks by document
|
||||
grouped: dict[int, dict] = {}
|
||||
for chunk, doc in results:
|
||||
public_url = surfsense_docs_public_url(doc.source)
|
||||
if doc.id not in grouped:
|
||||
grouped[doc.id] = {
|
||||
"document_id": f"doc-{doc.id}",
|
||||
"document_type": "SURFSENSE_DOCS",
|
||||
"title": doc.title,
|
||||
"url": doc.source,
|
||||
"metadata": {"source": doc.source},
|
||||
"url": public_url,
|
||||
"metadata": {"source": doc.source, "public_url": public_url},
|
||||
"chunks": [],
|
||||
}
|
||||
grouped[doc.id]["chunks"].append(
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||
|
||||
from app.db import SurfsenseDocsChunk, SurfsenseDocsDocument, async_session_maker
|
||||
from app.utils.document_converters import embed_text
|
||||
from app.utils.surfsense_docs import surfsense_docs_public_url
|
||||
|
||||
|
||||
def format_surfsense_docs_results(results: list[tuple]) -> str:
|
||||
|
|
@ -40,13 +41,14 @@ def format_surfsense_docs_results(results: list[tuple]) -> str:
|
|||
# Group chunks by document
|
||||
grouped: dict[int, dict] = {}
|
||||
for chunk, doc in results:
|
||||
public_url = surfsense_docs_public_url(doc.source)
|
||||
if doc.id not in grouped:
|
||||
grouped[doc.id] = {
|
||||
"document_id": f"doc-{doc.id}",
|
||||
"document_type": "SURFSENSE_DOCS",
|
||||
"title": doc.title,
|
||||
"url": doc.source,
|
||||
"metadata": {"source": doc.source},
|
||||
"url": public_url,
|
||||
"metadata": {"source": doc.source, "public_url": public_url},
|
||||
"chunks": [],
|
||||
}
|
||||
grouped[doc.id]["chunks"].append(
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ from app.schemas.surfsense_docs import (
|
|||
SurfsenseDocsDocumentWithChunksRead,
|
||||
)
|
||||
from app.users import current_active_user
|
||||
from app.utils.surfsense_docs import surfsense_docs_public_url
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
|
@ -76,6 +77,7 @@ async def get_surfsense_doc_by_chunk_id(
|
|||
id=document.id,
|
||||
title=document.title,
|
||||
source=document.source,
|
||||
public_url=surfsense_docs_public_url(document.source),
|
||||
content=document.content,
|
||||
chunks=[
|
||||
SurfsenseDocsChunkRead(id=c.id, content=c.content)
|
||||
|
|
@ -146,6 +148,7 @@ async def list_surfsense_docs(
|
|||
id=doc.id,
|
||||
title=doc.title,
|
||||
source=doc.source,
|
||||
public_url=surfsense_docs_public_url(doc.source),
|
||||
content=doc.content,
|
||||
created_at=doc.created_at,
|
||||
updated_at=doc.updated_at,
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ class SurfsenseDocsDocumentRead(BaseModel):
|
|||
id: int
|
||||
title: str
|
||||
source: str
|
||||
public_url: str
|
||||
content: str
|
||||
created_at: datetime | None = None
|
||||
updated_at: datetime | None = None
|
||||
|
|
@ -35,6 +36,7 @@ class SurfsenseDocsDocumentWithChunksRead(BaseModel):
|
|||
id: int
|
||||
title: str
|
||||
source: str
|
||||
public_url: str
|
||||
content: str
|
||||
chunks: list[SurfsenseDocsChunkRead]
|
||||
|
||||
|
|
|
|||
|
|
@ -81,6 +81,7 @@ from app.tasks.chat.streaming.helpers.interrupt_inspector import (
|
|||
)
|
||||
from app.utils.content_utils import bootstrap_history_from_db
|
||||
from app.utils.perf import get_perf_logger, log_system_snapshot, trim_native_heap
|
||||
from app.utils.surfsense_docs import surfsense_docs_public_url
|
||||
from app.utils.user_message_multimodal import build_human_message_content
|
||||
|
||||
_background_tasks: set[asyncio.Task] = set()
|
||||
|
|
@ -216,14 +217,17 @@ def format_mentioned_surfsense_docs_as_context(
|
|||
)
|
||||
|
||||
for doc in documents:
|
||||
metadata_json = json.dumps({"source": doc.source}, ensure_ascii=False)
|
||||
public_url = surfsense_docs_public_url(doc.source)
|
||||
metadata_json = json.dumps(
|
||||
{"source": doc.source, "public_url": public_url}, ensure_ascii=False
|
||||
)
|
||||
|
||||
context_parts.append("<document>")
|
||||
context_parts.append("<document_metadata>")
|
||||
context_parts.append(f" <document_id>doc-{doc.id}</document_id>")
|
||||
context_parts.append(" <document_type>SURFSENSE_DOCS</document_type>")
|
||||
context_parts.append(f" <title><![CDATA[{doc.title}]]></title>")
|
||||
context_parts.append(f" <url><![CDATA[{doc.source}]]></url>")
|
||||
context_parts.append(f" <url><![CDATA[{public_url}]]></url>")
|
||||
context_parts.append(
|
||||
f" <metadata_json><![CDATA[{metadata_json}]]></metadata_json>"
|
||||
)
|
||||
|
|
|
|||
13
surfsense_backend/app/utils/surfsense_docs.py
Normal file
13
surfsense_backend/app/utils/surfsense_docs.py
Normal 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()
|
||||
BIN
surfsense_desktop/assets/icon-128.png
Normal file
BIN
surfsense_desktop/assets/icon-128.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 287 KiB |
BIN
surfsense_desktop/assets/iconTemplate.png
Normal file
BIN
surfsense_desktop/assets/iconTemplate.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 684 B |
BIN
surfsense_desktop/assets/iconTemplate@2x.png
Normal file
BIN
surfsense_desktop/assets/iconTemplate@2x.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.4 KiB |
|
|
@ -11,11 +11,20 @@ let registeredGeneralAssist: string | null = null;
|
|||
let registeredScreenshotAssist: string | null = null;
|
||||
|
||||
function getTrayIcon(): NativeImage {
|
||||
const iconName = process.platform === 'win32' ? 'icon.ico' : 'icon.png';
|
||||
const iconName =
|
||||
process.platform === 'darwin'
|
||||
? 'iconTemplate.png'
|
||||
: process.platform === 'win32'
|
||||
? 'icon.ico'
|
||||
: 'icon.png';
|
||||
const iconPath = app.isPackaged
|
||||
? path.join(process.resourcesPath, 'assets', iconName)
|
||||
: path.join(__dirname, '..', 'assets', iconName);
|
||||
const img = nativeImage.createFromPath(iconPath);
|
||||
if (process.platform === 'darwin') {
|
||||
img.setTemplateImage(true);
|
||||
return img;
|
||||
}
|
||||
return img.resize({ width: 16, height: 16 });
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { setActiveSearchSpaceId } from './active-search-space';
|
|||
|
||||
const isDev = !app.isPackaged;
|
||||
const HOSTED_FRONTEND_URL = process.env.HOSTED_FRONTEND_URL as string;
|
||||
const isMac = process.platform === 'darwin';
|
||||
|
||||
let mainWindow: BrowserWindow | null = null;
|
||||
let isQuitting = false;
|
||||
|
|
@ -35,7 +36,12 @@ export function createMainWindow(initialPath = '/dashboard'): BrowserWindow {
|
|||
webviewTag: false,
|
||||
},
|
||||
show: false,
|
||||
titleBarStyle: 'hiddenInset',
|
||||
...(isMac
|
||||
? {
|
||||
titleBarStyle: 'hidden' as const,
|
||||
trafficLightPosition: { x: 12, y: 10 },
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
|
||||
mainWindow.once('ready-to-show', () => {
|
||||
|
|
|
|||
|
|
@ -2,20 +2,20 @@ import type { Metadata } from "next";
|
|||
import type { ReactNode } from "react";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Announcements | SurfSense",
|
||||
title: "What's New | SurfSense",
|
||||
description: "Latest product updates, feature releases, and news from SurfSense.",
|
||||
alternates: {
|
||||
canonical: "https://www.surfsense.com/announcements",
|
||||
},
|
||||
openGraph: {
|
||||
title: "Announcements | SurfSense",
|
||||
title: "What's New | SurfSense",
|
||||
description: "Latest product updates, feature releases, and news from SurfSense.",
|
||||
url: "https://www.surfsense.com/announcements",
|
||||
type: "website",
|
||||
},
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
title: "Announcements | SurfSense",
|
||||
title: "What's New | SurfSense",
|
||||
description: "Latest product updates, feature releases, and news from SurfSense.",
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ export default function AnnouncementsPage() {
|
|||
<div className="max-w-5xl mx-auto relative">
|
||||
<div className="p-6">
|
||||
<h1 className="text-4xl font-bold tracking-tight bg-linear-to-r from-gray-900 to-gray-600 dark:from-white dark:to-gray-400 bg-clip-text text-transparent">
|
||||
Announcements
|
||||
What's New
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,15 +1,47 @@
|
|||
"use client";
|
||||
import { IconBrandGoogleFilled } from "@tabler/icons-react";
|
||||
import { motion } from "motion/react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useState } from "react";
|
||||
import { Logo } from "@/components/Logo";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { trackLoginAttempt } from "@/lib/posthog/events";
|
||||
import { AmbientBackground } from "./AmbientBackground";
|
||||
|
||||
function GoogleGLogo({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
className={className}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 48 48"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
fill="#EA4335"
|
||||
d="M24 9.5c3.54 0 6.71 1.22 9.21 3.6l6.85-6.85C35.9 2.38 30.47 0 24 0 14.62 0 6.51 5.38 2.56 13.22l7.98 6.19C12.43 13.72 17.74 9.5 24 9.5z"
|
||||
/>
|
||||
<path
|
||||
fill="#4285F4"
|
||||
d="M46.98 24.55c0-1.57-.15-3.09-.38-4.55H24v9.02h12.94c-.58 2.96-2.26 5.48-4.78 7.18l7.73 6c4.51-4.18 7.09-10.36 7.09-17.65z"
|
||||
/>
|
||||
<path
|
||||
fill="#FBBC05"
|
||||
d="M10.53 28.59c-.48-1.45-.76-2.99-.76-4.59s.27-3.14.76-4.59l-7.98-6.19C.92 16.46 0 20.12 0 24c0 3.88.92 7.54 2.56 10.78l7.97-6.19z"
|
||||
/>
|
||||
<path
|
||||
fill="#34A853"
|
||||
d="M24 48c6.48 0 11.93-2.13 15.89-5.81l-7.73-6c-2.15 1.45-4.92 2.3-8.16 2.3-6.26 0-11.57-4.22-13.47-9.91l-7.98 6.19C6.51 42.62 14.62 48 24 48z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function GoogleLoginButton() {
|
||||
const t = useTranslations("auth");
|
||||
const [isRedirecting, setIsRedirecting] = useState(false);
|
||||
|
||||
const handleGoogleLogin = () => {
|
||||
if (isRedirecting) return;
|
||||
setIsRedirecting(true);
|
||||
|
||||
// Track Google login attempt
|
||||
trackLoginAttempt("google");
|
||||
|
||||
|
|
@ -73,21 +105,15 @@ export function GoogleLoginButton() {
|
|||
</motion.div>
|
||||
</motion.div> */}
|
||||
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
className="group/btn relative flex w-full items-center justify-center space-x-2 rounded-lg bg-white px-6 py-3 md:py-4 text-neutral-700 shadow-lg transition-all duration-200 hover:shadow-xl dark:bg-neutral-800 dark:text-neutral-200"
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full max-w-md gap-2 rounded-lg border-white bg-white px-6 py-5 font-medium text-[#1f1f1f] shadow-sm hover:bg-zinc-100 hover:text-[#1f1f1f] dark:border-white md:py-5"
|
||||
disabled={isRedirecting}
|
||||
onClick={handleGoogleLogin}
|
||||
>
|
||||
<div className="absolute inset-0 h-full w-full transform opacity-0 transition duration-200 group-hover/btn:opacity-100">
|
||||
<div className="absolute -left-px -top-px h-4 w-4 rounded-tl-lg border-l-2 border-t-2 border-blue-500 bg-transparent transition-all duration-200 group-hover/btn:-left-2 group-hover/btn:-top-2"></div>
|
||||
<div className="absolute -right-px -top-px h-4 w-4 rounded-tr-lg border-r-2 border-t-2 border-blue-500 bg-transparent transition-all duration-200 group-hover/btn:-right-2 group-hover/btn:-top-2"></div>
|
||||
<div className="absolute -bottom-px -left-px h-4 w-4 rounded-bl-lg border-b-2 border-l-2 border-blue-500 bg-transparent transition-all duration-200 group-hover/btn:-bottom-2 group-hover/btn:-left-2"></div>
|
||||
<div className="absolute -bottom-px -right-px h-4 w-4 rounded-br-lg border-b-2 border-r-2 border-blue-500 bg-transparent transition-all duration-200 group-hover/btn:-bottom-2 group-hover/btn:-right-2"></div>
|
||||
</div>
|
||||
<IconBrandGoogleFilled className="h-5 w-5 text-neutral-700 dark:text-neutral-200" />
|
||||
<GoogleGLogo className="h-5 w-5" />
|
||||
<span className="text-base font-medium">{t("continue_with_google")}</span>
|
||||
</motion.button>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { useRouter } from "next/navigation";
|
|||
import { useTranslations } from "next-intl";
|
||||
import { useState } from "react";
|
||||
import { loginMutationAtom } from "@/atoms/auth/auth-mutation.atoms";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { getAuthErrorDetails, isNetworkError } from "@/lib/auth-errors";
|
||||
import { AUTH_TYPE } from "@/lib/env-config";
|
||||
|
|
@ -120,11 +121,13 @@ export function LocalLoginForm() {
|
|||
<p className="text-sm font-semibold mb-1">{error.title}</p>
|
||||
<p className="text-sm text-destructive">{error.message}</p>
|
||||
</div>
|
||||
<button
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => {
|
||||
setError({ title: null, message: null });
|
||||
}}
|
||||
className="flex-shrink-0 text-destructive hover:text-destructive/90 transition-colors"
|
||||
className="size-6 flex-shrink-0 text-destructive hover:bg-transparent hover:text-destructive/90"
|
||||
aria-label="Dismiss error"
|
||||
type="button"
|
||||
>
|
||||
|
|
@ -143,7 +146,7 @@ export function LocalLoginForm() {
|
|||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
|
@ -191,21 +194,23 @@ export function LocalLoginForm() {
|
|||
}`}
|
||||
disabled={isLoggingIn}
|
||||
/>
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setShowPassword((prev) => !prev)}
|
||||
className="absolute inset-y-0 right-0 flex items-center pr-3 text-muted-foreground hover:text-foreground"
|
||||
className="absolute inset-y-0 right-0 h-full w-10 text-muted-foreground hover:bg-transparent hover:text-foreground"
|
||||
aria-label={showPassword ? t("hide_password") : t("show_password")}
|
||||
>
|
||||
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isLoggingIn}
|
||||
className="relative w-full rounded-md bg-primary px-4 py-1.5 md:py-2 text-primary-foreground shadow-sm hover:bg-primary/90 focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 transition-all text-sm md:text-base flex items-center justify-center gap-2"
|
||||
className="relative h-auto w-full px-4 py-1.5 text-sm md:py-2 md:text-base"
|
||||
>
|
||||
<span className={isLoggingIn ? "invisible" : ""}>{t("sign_in")}</span>
|
||||
{isLoggingIn && (
|
||||
|
|
@ -213,7 +218,7 @@ export function LocalLoginForm() {
|
|||
<Spinner size="sm" className="text-primary-foreground" />
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
{authType === "LOCAL" && (
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { useTranslations } from "next-intl";
|
|||
import { Suspense, useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Logo } from "@/components/Logo";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useGlobalLoadingEffect } from "@/hooks/use-global-loading";
|
||||
import { getAuthErrorDetails, shouldRetry } from "@/lib/auth-errors";
|
||||
import { setRedirectPath } from "@/lib/auth-utils";
|
||||
|
|
@ -154,10 +155,12 @@ function LoginContent() {
|
|||
<p className="text-sm font-semibold mb-1">{urlError.title}</p>
|
||||
<p className="text-sm text-red-700 dark:text-red-300">{urlError.message}</p>
|
||||
</div>
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setUrlError(null)}
|
||||
className="flex-shrink-0 text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-200 transition-colors"
|
||||
className="size-6 flex-shrink-0 text-red-500 hover:bg-transparent hover:text-red-700 dark:text-red-400 dark:hover:text-red-200"
|
||||
aria-label="Dismiss error"
|
||||
>
|
||||
<svg
|
||||
|
|
@ -175,7 +178,7 @@ function LoginContent() {
|
|||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import { useEffect, useState } from "react";
|
|||
import { type ExternalToast, toast } from "sonner";
|
||||
import { registerMutationAtom } from "@/atoms/auth/auth-mutation.atoms";
|
||||
import { Logo } from "@/components/Logo";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { getAuthErrorDetails, isNetworkError, shouldRetry } from "@/lib/auth-errors";
|
||||
import { getBearerToken } from "@/lib/auth-utils";
|
||||
|
|
@ -199,11 +200,13 @@ export default function RegisterPage() {
|
|||
<p className="text-sm font-semibold mb-1">{error.title}</p>
|
||||
<p className="text-sm text-red-700 dark:text-red-300">{error.message}</p>
|
||||
</div>
|
||||
<button
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => {
|
||||
setError({ title: null, message: null });
|
||||
}}
|
||||
className="flex-shrink-0 text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-200 transition-colors"
|
||||
className="size-6 flex-shrink-0 text-red-500 hover:bg-transparent hover:text-red-700 dark:text-red-400 dark:hover:text-red-200"
|
||||
aria-label="Dismiss error"
|
||||
type="button"
|
||||
>
|
||||
|
|
@ -222,7 +225,7 @@ export default function RegisterPage() {
|
|||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
|
@ -295,18 +298,18 @@ export default function RegisterPage() {
|
|||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isRegistering}
|
||||
className="relative w-full rounded-md bg-primary px-4 py-1.5 md:py-2 text-primary-foreground shadow-sm hover:bg-primary/90 focus:outline-none focus:ring-1 focus:ring-primary/40 disabled:cursor-not-allowed disabled:opacity-50 transition-all text-sm md:text-base flex items-center justify-center gap-2"
|
||||
className="relative h-auto w-full px-4 py-1.5 text-sm md:py-2 md:text-base"
|
||||
>
|
||||
<span className={isRegistering ? "invisible" : ""}>{t("register")}</span>
|
||||
{isRegistering && (
|
||||
<span className="absolute inset-0 flex items-center justify-center gap-2">
|
||||
<Spinner size="sm" className="text-white" />
|
||||
<Spinner size="sm" className="text-primary-foreground" />
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<div className="mt-4 text-center text-sm">
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { motion } from "motion/react";
|
|||
import { useState } from "react";
|
||||
import { BuyPagesContent } from "@/components/settings/buy-pages-content";
|
||||
import { BuyTokensContent } from "@/components/settings/buy-tokens-content";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
|
||||
const TABS = [
|
||||
{ id: "pages", label: "Pages" },
|
||||
|
|
@ -17,33 +17,38 @@ export default function BuyMorePage() {
|
|||
const [activeTab, setActiveTab] = useState<TabId>("pages");
|
||||
|
||||
return (
|
||||
<div className="flex min-h-[calc(100vh-64px)] select-none items-center justify-center px-4 py-8">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="w-full max-w-md space-y-6"
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="w-full select-none"
|
||||
>
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={(value) => {
|
||||
setActiveTab(value as TabId);
|
||||
}}
|
||||
className="relative min-h-[37rem] w-full"
|
||||
>
|
||||
<div className="flex items-center justify-center rounded-lg border bg-muted/30 p-1">
|
||||
<TabsList className="absolute top-20 left-1/2 -translate-x-1/2 rounded-xl bg-accent p-1">
|
||||
{TABS.map((tab) => (
|
||||
<button
|
||||
<TabsTrigger
|
||||
key={tab.id}
|
||||
type="button"
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={cn(
|
||||
"flex-1 rounded-md px-3 py-1.5 text-sm font-medium transition-colors",
|
||||
activeTab === tab.id
|
||||
? "bg-background text-foreground shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
value={tab.id}
|
||||
className="h-8 rounded-lg px-4 text-sm font-semibold text-accent-foreground transition-colors hover:bg-transparent hover:text-white data-[state=active]:bg-[#4a4a4a] data-[state=active]:text-white data-[state=active]:shadow-none"
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</div>
|
||||
</TabsList>
|
||||
|
||||
{activeTab === "pages" ? <BuyPagesContent /> : <BuyTokensContent />}
|
||||
</motion.div>
|
||||
</div>
|
||||
<TabsContent value="pages" className="mt-0 flex min-h-[37rem] items-center pt-14">
|
||||
<BuyPagesContent />
|
||||
</TabsContent>
|
||||
<TabsContent value="tokens" className="mt-0 flex min-h-[37rem] items-center pt-14">
|
||||
<BuyTokensContent />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -144,6 +144,19 @@ export function DashboardClientLayout({
|
|||
|
||||
const electronAPI = useElectronAPI();
|
||||
|
||||
useEffect(() => {
|
||||
const htmlBackground = document.documentElement.style.backgroundColor;
|
||||
const bodyBackground = document.body.style.backgroundColor;
|
||||
|
||||
document.documentElement.style.backgroundColor = "var(--panel)";
|
||||
document.body.style.backgroundColor = "var(--panel)";
|
||||
|
||||
return () => {
|
||||
document.documentElement.style.backgroundColor = htmlBackground;
|
||||
document.body.style.backgroundColor = bodyBackground;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!electronAPI?.onChatScreenCapture) return;
|
||||
return electronAPI.onChatScreenCapture((dataUrl: string) => {
|
||||
|
|
@ -163,12 +176,13 @@ export function DashboardClientLayout({
|
|||
setActiveSearchSpaceIdState(activeSeacrhSpaceId);
|
||||
|
||||
// Sync to Electron store if stored value is null (first navigation)
|
||||
if (electronAPI?.setActiveSearchSpace) {
|
||||
if (electronAPI?.getActiveSearchSpace && electronAPI.setActiveSearchSpace) {
|
||||
const setActiveSearchSpace = electronAPI.setActiveSearchSpace;
|
||||
electronAPI
|
||||
.getActiveSearchSpace?.()
|
||||
.then((stored) => {
|
||||
.getActiveSearchSpace()
|
||||
.then((stored: string | null) => {
|
||||
if (!stored) {
|
||||
electronAPI.setActiveSearchSpace!(activeSeacrhSpaceId);
|
||||
setActiveSearchSpace(activeSeacrhSpaceId);
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
|
|
|
|||
|
|
@ -17,7 +17,6 @@ import {
|
|||
} from "@tanstack/react-table";
|
||||
import { useAtomValue } from "jotai";
|
||||
import {
|
||||
Activity,
|
||||
AlertCircle,
|
||||
AlertTriangle,
|
||||
Bug,
|
||||
|
|
@ -38,6 +37,7 @@ import {
|
|||
RefreshCw,
|
||||
Terminal,
|
||||
Trash,
|
||||
Workflow,
|
||||
X,
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
|
|
@ -133,7 +133,6 @@ const logStatusConfig = {
|
|||
function MessageDetails({
|
||||
message,
|
||||
taskName,
|
||||
metadata,
|
||||
createdAt,
|
||||
children,
|
||||
}: {
|
||||
|
|
@ -623,7 +622,7 @@ function LogsSummaryDashboard({
|
|||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">{t("total_logs")}</CardTitle>
|
||||
<Activity className="h-4 w-4 text-muted-foreground" />
|
||||
<Workflow className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{summary.total_logs}</div>
|
||||
|
|
@ -739,7 +738,7 @@ function LogsFilters({
|
|||
</div>
|
||||
{Boolean(filterInput) && (
|
||||
<Button
|
||||
className="absolute inset-y-0 end-0 flex h-full w-9 items-center justify-center rounded-e-lg text-muted-foreground/80 hover:text-foreground"
|
||||
className="absolute inset-y-0 end-0 flex h-full w-9 items-center justify-center rounded-e-lg text-muted-foreground/80 hover:text-accent-foreground"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => {
|
||||
|
|
@ -1045,7 +1044,7 @@ function LogsTable({
|
|||
}}
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
className={cn(
|
||||
"border-b transition-colors hover:bg-muted/50",
|
||||
"border-b transition-colors hover:bg-accent hover:text-accent-foreground",
|
||||
row.getIsSelected() ? "bg-muted/50" : ""
|
||||
)}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -53,7 +53,10 @@ export default function Loading() {
|
|||
|
||||
{/* Table Rows */}
|
||||
{[...Array(6)].map((_, i) => (
|
||||
<div key={i} className="border-b px-4 py-3 flex items-center gap-4 hover:bg-muted/50">
|
||||
<div
|
||||
key={i}
|
||||
className="border-b px-4 py-3 flex items-center gap-4 hover:bg-accent hover:text-accent-foreground"
|
||||
>
|
||||
<Skeleton className="h-4 w-4" />
|
||||
<Skeleton className="h-6 w-12 rounded-full" />
|
||||
<Skeleton className="h-6 w-16 rounded-full" />
|
||||
|
|
|
|||
|
|
@ -1,19 +1,11 @@
|
|||
"use client";
|
||||
|
||||
import { motion } from "motion/react";
|
||||
import { MorePagesContent } from "@/components/settings/more-pages-content";
|
||||
|
||||
export default function MorePagesPage() {
|
||||
return (
|
||||
<div className="flex min-h-[calc(100vh-64px)] select-none items-center justify-center px-4 py-8">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="w-full max-w-md space-y-6"
|
||||
>
|
||||
<MorePagesContent />
|
||||
</motion.div>
|
||||
<div className="w-full select-none space-y-6">
|
||||
<MorePagesContent />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -49,6 +49,7 @@ import {
|
|||
type TokenUsageData,
|
||||
TokenUsageProvider,
|
||||
} from "@/components/assistant-ui/token-usage-context";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
type HitlDecision,
|
||||
PendingInterruptProvider,
|
||||
|
|
@ -78,12 +79,7 @@ import {
|
|||
setActivePodcastTaskId,
|
||||
} from "@/lib/chat/podcast-state";
|
||||
import { createStreamFlushHelpers } from "@/lib/chat/stream-flush";
|
||||
import {
|
||||
consumeSseEvents,
|
||||
hasPersistableContent,
|
||||
markInterruptsCompleted,
|
||||
processSharedStreamEvent,
|
||||
} from "@/lib/chat/stream-pipeline";
|
||||
import { consumeSseEvents, processSharedStreamEvent } from "@/lib/chat/stream-pipeline";
|
||||
import {
|
||||
applyTurnIdToAssistantMessageList,
|
||||
mergeChatTurnIdIntoMessage,
|
||||
|
|
@ -92,7 +88,6 @@ import {
|
|||
} from "@/lib/chat/stream-side-effects";
|
||||
import {
|
||||
addToolCall,
|
||||
buildContentForPersistence,
|
||||
buildContentForUI,
|
||||
type ContentPartsState,
|
||||
type FrameBatchedUpdater,
|
||||
|
|
@ -453,7 +448,7 @@ export default function NewChatPage() {
|
|||
}, [params.search_space_id]);
|
||||
|
||||
// Unified store for agent-action rows (the same react-query cache
|
||||
// the agent-actions sheet, the inline Revert button, and the
|
||||
// the agent-actions dialog, the inline Revert button, and the
|
||||
// per-turn Revert button all read). Hydrates from
|
||||
// ``GET /threads/{id}/actions`` and is updated incrementally by the
|
||||
// SSE handlers + revert-batch results below — no atom side-channel.
|
||||
|
|
@ -1762,8 +1757,19 @@ export default function NewChatPage() {
|
|||
}
|
||||
|
||||
const byTcId = new Map<string, (typeof incoming)[number]>();
|
||||
for (let i = 0; i < tcIds.length; i++) byTcId.set(tcIds[i], incoming[i]);
|
||||
const submittedDecisions = tcIds.map((id) => byTcId.get(id)!);
|
||||
const submittedDecisions: typeof incoming = [];
|
||||
for (let i = 0; i < tcIds.length; i++) {
|
||||
const tcId = tcIds[i];
|
||||
const decision = incoming[i];
|
||||
if (tcId === undefined || decision === undefined) {
|
||||
toast.error(
|
||||
`Cannot resume: ${incoming.length} decision(s) submitted for ${N} pending actions.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
byTcId.set(tcId, decision);
|
||||
submittedDecisions.push(decision);
|
||||
}
|
||||
|
||||
// All pending cards belong to the same assistant message, so a
|
||||
// single content-update pass suffices.
|
||||
|
|
@ -2407,16 +2413,15 @@ export default function NewChatPage() {
|
|||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-4">
|
||||
<div className="text-destructive">Failed to load chat</div>
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setIsInitializing(true);
|
||||
initializeThread();
|
||||
}}
|
||||
className="rounded-md bg-primary px-4 py-2 text-primary-foreground hover:bg-primary/90"
|
||||
>
|
||||
Try Again
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,42 +2,59 @@ import { Skeleton } from "@/components/ui/skeleton";
|
|||
|
||||
export default function Loading() {
|
||||
return (
|
||||
<div className="flex h-full flex-col bg-main-panel px-4">
|
||||
<div className="mx-auto w-full max-w-[44rem] flex flex-1 flex-col gap-6 py-8">
|
||||
{/* User message */}
|
||||
<div className="flex justify-end">
|
||||
<Skeleton className="h-12 w-56 rounded-2xl" />
|
||||
<div
|
||||
className="aui-root aui-thread-root @container flex h-full min-h-0 flex-col bg-main-panel"
|
||||
style={{
|
||||
["--thread-max-width" as string]: "42rem",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="aui-thread-viewport relative flex flex-1 min-h-0 flex-col overflow-y-auto px-4 scroll-smooth"
|
||||
style={{ scrollbarGutter: "stable" }}
|
||||
>
|
||||
<div
|
||||
aria-hidden
|
||||
className="aui-chat-viewport-top-fade pointer-events-none sticky top-0 z-10 -mx-4 h-2 shrink-0 bg-gradient-to-b from-main-panel from-20% to-transparent"
|
||||
/>
|
||||
<div className="mx-auto w-full max-w-(--thread-max-width) flex flex-1 flex-col gap-6 py-8">
|
||||
{/* User message */}
|
||||
<div className="flex justify-end">
|
||||
<Skeleton className="h-12 w-56 rounded-2xl" />
|
||||
</div>
|
||||
|
||||
{/* Assistant message */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-[85%]" />
|
||||
<Skeleton className="h-18 w-[40%]" />
|
||||
</div>
|
||||
|
||||
{/* User message */}
|
||||
<div className="flex gap-2 justify-end">
|
||||
<Skeleton className="h-12 w-72 rounded-2xl" />
|
||||
</div>
|
||||
|
||||
{/* Assistant message */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<Skeleton className="h-10 w-[30%]" />
|
||||
<Skeleton className="h-4 w-[90%]" />
|
||||
<Skeleton className="h-6 w-[60%]" />
|
||||
</div>
|
||||
|
||||
{/* User message */}
|
||||
<div className="flex gap-2 justify-end">
|
||||
<Skeleton className="h-12 w-96 rounded-2xl" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Assistant message */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-[85%]" />
|
||||
<Skeleton className="h-18 w-[40%]" />
|
||||
</div>
|
||||
|
||||
{/* User message */}
|
||||
<div className="flex gap-2 justify-end">
|
||||
<Skeleton className="h-12 w-72 rounded-2xl" />
|
||||
</div>
|
||||
|
||||
{/* Assistant message */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<Skeleton className="h-10 w-[30%]" />
|
||||
<Skeleton className="h-4 w-[90%]" />
|
||||
<Skeleton className="h-6 w-[60%]" />
|
||||
</div>
|
||||
|
||||
{/* User message */}
|
||||
<div className="flex gap-2 justify-end">
|
||||
<Skeleton className="h-12 w-96 rounded-2xl" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Input bar */}
|
||||
<div className="sticky bottom-0 pb-6 bg-main-panel">
|
||||
<div className="mx-auto w-full max-w-[44rem]">
|
||||
<Skeleton className="h-24 w-full rounded-2xl" />
|
||||
{/* Input bar */}
|
||||
<div
|
||||
className="aui-chat-composer-footer sticky bottom-0 z-20 -mx-4 mt-auto flex flex-col items-stretch bg-gradient-to-t from-main-panel from-60% to-transparent px-4 pt-6"
|
||||
style={{ paddingBottom: "max(0.5rem, env(safe-area-inset-bottom))" }}
|
||||
>
|
||||
<div className="aui-chat-composer-area relative mx-auto flex w-full max-w-(--thread-max-width) flex-col gap-3 overflow-visible">
|
||||
<Skeleton className="h-28 w-full rounded-3xl" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -151,7 +151,7 @@ export default function OnboardPage() {
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="h-screen flex flex-col items-center p-4 bg-background dark:bg-neutral-900 select-none overflow-hidden">
|
||||
<div className="h-screen flex flex-col items-center p-4 bg-main-panel select-none overflow-hidden">
|
||||
<div className="w-full max-w-lg flex flex-col min-h-0 h-full gap-6 py-8">
|
||||
{/* Header */}
|
||||
<div className="text-center space-y-3 shrink-0">
|
||||
|
|
@ -165,7 +165,7 @@ export default function OnboardPage() {
|
|||
</div>
|
||||
|
||||
{/* Form card */}
|
||||
<div className="rounded-xl border bg-background dark:bg-neutral-900 flex-1 min-h-0 overflow-y-auto px-6 py-6">
|
||||
<div className="rounded-xl border bg-main-panel flex-1 min-h-0 overflow-y-auto px-6 py-6">
|
||||
<LLMConfigForm
|
||||
searchSpaceId={searchSpaceId}
|
||||
onSubmit={handleSubmit}
|
||||
|
|
|
|||
|
|
@ -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)} />;
|
||||
}
|
||||
|
|
@ -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)} />;
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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)} />;
|
||||
}
|
||||
|
|
@ -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`);
|
||||
}
|
||||
|
|
@ -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)} />;
|
||||
}
|
||||
|
|
@ -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)} />;
|
||||
}
|
||||
|
|
@ -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)} />;
|
||||
}
|
||||
|
|
@ -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)} />;
|
||||
}
|
||||
|
|
@ -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)} />;
|
||||
}
|
||||
|
|
@ -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)} />;
|
||||
}
|
||||
15
surfsense_web/app/dashboard/[search_space_id]/team/page.tsx
Normal file
15
surfsense_web/app/dashboard/[search_space_id]/team/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useAtomValue, useSetAtom } from "jotai";
|
||||
import { useAtomValue } from "jotai";
|
||||
import {
|
||||
Calendar,
|
||||
Check,
|
||||
|
|
@ -20,6 +20,7 @@ import {
|
|||
UserPlus,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
|
|
@ -31,7 +32,6 @@ import {
|
|||
updateMemberMutationAtom,
|
||||
} from "@/atoms/members/members-mutation.atoms";
|
||||
import { membersAtom, myAccessAtom } from "@/atoms/members/members-query.atoms";
|
||||
import { searchSpaceSettingsDialogAtom } from "@/atoms/settings/settings-dialog.atoms";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
|
|
@ -240,46 +240,77 @@ export function TeamContent({ searchSpaceId }: TeamContentProps) {
|
|||
|
||||
if (accessLoading || membersLoading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<Skeleton className="h-9 w-36 rounded-md" />
|
||||
<Skeleton className="h-4 w-20" />
|
||||
<div className="space-y-4 md:space-y-6">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
aria-disabled="true"
|
||||
tabIndex={-1}
|
||||
className="pointer-events-none gap-1.5 md:gap-2 text-xs md:text-sm bg-black text-white dark:bg-white dark:text-black"
|
||||
>
|
||||
<UserPlus className="h-3.5 w-3.5 md:h-4 md:w-4" />
|
||||
Invite members
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
aria-disabled="true"
|
||||
tabIndex={-1}
|
||||
className="pointer-events-none gap-1.5 md:gap-2 text-xs md:text-sm"
|
||||
>
|
||||
<Link2 className="h-3.5 w-3.5 md:h-4 md:w-4 rotate-315" />
|
||||
Active invites
|
||||
<span className="inline-flex items-center justify-center h-4 md:h-5 min-w-4 md:min-w-5 px-1 rounded-full bg-neutral-700 text-neutral-200">
|
||||
<Skeleton className="h-2.5 w-2.5 rounded-sm bg-neutral-500/60" />
|
||||
</span>
|
||||
</Button>
|
||||
<div className="flex items-center gap-1 text-xs md:text-sm text-muted-foreground whitespace-nowrap">
|
||||
<Skeleton className="h-3 w-2 rounded-sm" />
|
||||
members
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg border border-border/40 bg-background overflow-hidden">
|
||||
<div className="rounded-lg border border-border/60 bg-accent overflow-hidden">
|
||||
<Table className="table-fixed w-full">
|
||||
<TableHeader>
|
||||
<TableRow className="hover:bg-transparent border-b border-border/40">
|
||||
<TableHead className="w-[45%] px-4 md:px-6 border-r border-border/40">
|
||||
<Skeleton className="h-3 w-16" />
|
||||
<TableRow className="hover:bg-transparent border-b border-border/60">
|
||||
<TableHead className="w-[45%] px-4 md:px-6 border-r border-border/60">
|
||||
<span className="flex items-center gap-1.5 text-sm font-medium text-muted-foreground/70">
|
||||
<User size={14} className="opacity-60 text-muted-foreground" />
|
||||
Name
|
||||
</span>
|
||||
</TableHead>
|
||||
<TableHead className="hidden md:table-cell w-[25%] border-r border-border/40">
|
||||
<Skeleton className="h-3 w-24" />
|
||||
<TableHead className="hidden md:table-cell w-[25%] border-r border-border/60">
|
||||
<span className="flex items-center gap-1.5 text-sm font-medium text-muted-foreground/70">
|
||||
<Clock size={14} className="opacity-60 text-muted-foreground" />
|
||||
Last logged in
|
||||
</span>
|
||||
</TableHead>
|
||||
<TableHead className="w-[30%] px-4 md:px-6">
|
||||
<div className="flex justify-end">
|
||||
<Skeleton className="h-3 w-12" />
|
||||
</div>
|
||||
<span className="flex items-center gap-1.5 text-sm font-medium text-muted-foreground/70 justify-end">
|
||||
<ShieldUser size={14} className="opacity-60 text-muted-foreground" />
|
||||
Role
|
||||
</span>
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{SKELETON_KEYS.map((id) => (
|
||||
<TableRow key={id} className="border-b border-border/40 hover:bg-transparent">
|
||||
<TableCell className="w-[45%] py-2.5 px-4 md:px-6 border-r border-border/40">
|
||||
{SKELETON_KEYS.slice(0, 2).map((id) => (
|
||||
<TableRow key={id} className="border-b border-border/60 hover:bg-transparent">
|
||||
<TableCell className="w-[45%] py-2.5 px-4 md:px-6 border-r border-border/60">
|
||||
<div className="flex items-center gap-3">
|
||||
<Skeleton className="h-10 w-10 rounded-full shrink-0" />
|
||||
<div className="flex-1 min-w-0 space-y-1.5">
|
||||
<Skeleton className="h-4 w-[60%]" />
|
||||
<Skeleton className="h-3 w-[40%]" />
|
||||
</div>
|
||||
<Skeleton className="h-4 w-28 md:w-32" />
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="hidden md:table-cell w-[25%] py-2.5 border-r border-border/40">
|
||||
<TableCell className="hidden md:table-cell w-[25%] py-2.5 border-r border-border/60">
|
||||
<Skeleton className="h-4 w-24" />
|
||||
</TableCell>
|
||||
<TableCell className="w-[30%] py-2.5 px-4 md:px-6">
|
||||
<div className="flex justify-end">
|
||||
<Skeleton className="h-4 w-16" />
|
||||
<Skeleton className="h-4 w-12" />
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
|
|
@ -294,41 +325,63 @@ export function TeamContent({ searchSpaceId }: TeamContentProps) {
|
|||
return (
|
||||
<div className="space-y-4 md:space-y-6">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{rolesLoading ? (
|
||||
<Skeleton className="h-9 w-32 rounded-md" />
|
||||
) : (
|
||||
canInvite && (
|
||||
{canInvite &&
|
||||
(rolesLoading ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
aria-disabled="true"
|
||||
tabIndex={-1}
|
||||
className="pointer-events-none gap-1.5 md:gap-2 text-xs md:text-sm bg-black text-white dark:bg-white dark:text-black"
|
||||
>
|
||||
<UserPlus className="h-3.5 w-3.5 md:h-4 md:w-4" />
|
||||
Invite members
|
||||
</Button>
|
||||
) : (
|
||||
<CreateInviteDialog
|
||||
roles={roles}
|
||||
onCreateInvite={handleCreateInvite}
|
||||
searchSpaceId={searchSpaceId}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
{invitesLoading ? (
|
||||
<Skeleton className="h-9 w-32 rounded-md" />
|
||||
) : (
|
||||
canInvite &&
|
||||
activeInvites.length > 0 && (
|
||||
<AllInvitesDialog invites={activeInvites} onRevokeInvite={handleRevokeInvite} />
|
||||
)
|
||||
)}
|
||||
))}
|
||||
{canInvite &&
|
||||
(invitesLoading ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
aria-disabled="true"
|
||||
tabIndex={-1}
|
||||
className="pointer-events-none gap-1.5 md:gap-2 text-xs md:text-sm"
|
||||
>
|
||||
<Link2 className="h-3.5 w-3.5 md:h-4 md:w-4 rotate-315" />
|
||||
Active invites
|
||||
<span className="inline-flex items-center justify-center h-4 md:h-5 min-w-4 md:min-w-5 px-1 rounded-full bg-neutral-700 text-neutral-200">
|
||||
<Skeleton className="h-2.5 w-2.5 rounded-sm bg-neutral-500/60" />
|
||||
</span>
|
||||
</Button>
|
||||
) : (
|
||||
activeInvites.length > 0 && (
|
||||
<AllInvitesDialog invites={activeInvites} onRevokeInvite={handleRevokeInvite} />
|
||||
)
|
||||
))}
|
||||
<p className="text-xs md:text-sm text-muted-foreground whitespace-nowrap">
|
||||
{members.length} {members.length === 1 ? "member" : "members"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-border/40 bg-background overflow-hidden">
|
||||
<div className="rounded-lg border border-border/60 bg-accent overflow-hidden">
|
||||
<Table className="table-fixed w-full">
|
||||
<TableHeader>
|
||||
<TableRow className="hover:bg-transparent border-b border-border/40">
|
||||
<TableHead className="w-[45%] px-4 md:px-6 border-r border-border/40">
|
||||
<TableRow className="hover:bg-transparent border-b border-border/60">
|
||||
<TableHead className="w-[45%] px-4 md:px-6 border-r border-border/60">
|
||||
<span className="flex items-center gap-1.5 text-sm font-medium text-muted-foreground/70">
|
||||
<User size={14} className="opacity-60 text-muted-foreground" />
|
||||
Name
|
||||
</span>
|
||||
</TableHead>
|
||||
<TableHead className="hidden md:table-cell w-[25%] border-r border-border/40">
|
||||
<TableHead className="hidden md:table-cell w-[25%] border-r border-border/60">
|
||||
<span className="flex items-center gap-1.5 text-sm font-medium text-muted-foreground/70">
|
||||
<Clock size={14} className="opacity-60 text-muted-foreground" />
|
||||
Last logged in
|
||||
|
|
@ -346,6 +399,7 @@ export function TeamContent({ searchSpaceId }: TeamContentProps) {
|
|||
{owners.map((member) => (
|
||||
<MemberRow
|
||||
key={`member-${member.id}`}
|
||||
searchSpaceId={searchSpaceId}
|
||||
member={member}
|
||||
roles={roles}
|
||||
canManageRoles={canManageRoles}
|
||||
|
|
@ -357,6 +411,7 @@ export function TeamContent({ searchSpaceId }: TeamContentProps) {
|
|||
{paginatedMembers.map((member) => (
|
||||
<MemberRow
|
||||
key={`member-${member.id}`}
|
||||
searchSpaceId={searchSpaceId}
|
||||
member={member}
|
||||
roles={roles}
|
||||
canManageRoles={canManageRoles}
|
||||
|
|
@ -433,6 +488,7 @@ export function TeamContent({ searchSpaceId }: TeamContentProps) {
|
|||
}
|
||||
|
||||
function MemberRow({
|
||||
searchSpaceId,
|
||||
member,
|
||||
roles,
|
||||
canManageRoles,
|
||||
|
|
@ -440,6 +496,7 @@ function MemberRow({
|
|||
onUpdateRole,
|
||||
onRemoveMember,
|
||||
}: {
|
||||
searchSpaceId: number;
|
||||
member: Membership;
|
||||
roles: Role[];
|
||||
canManageRoles: boolean;
|
||||
|
|
@ -447,21 +504,23 @@ function MemberRow({
|
|||
onUpdateRole: (membershipId: number, roleId: number | null) => Promise<Membership>;
|
||||
onRemoveMember: (membershipId: number) => Promise<boolean>;
|
||||
}) {
|
||||
const setSearchSpaceSettingsDialog = useSetAtom(searchSpaceSettingsDialogAtom);
|
||||
const router = useRouter();
|
||||
const initials = getAvatarInitials(member);
|
||||
const displayName = member.user_display_name || member.user_email || "Unknown";
|
||||
const roleName = member.is_owner ? "Owner" : member.role?.name || "No role";
|
||||
const showActions = !member.is_owner && (canManageRoles || canRemove);
|
||||
|
||||
return (
|
||||
<TableRow className="border-b border-border/40 transition-colors hover:bg-muted/30">
|
||||
<TableCell className="w-[45%] py-2.5 px-4 md:px-6 max-w-0 border-r border-border/40">
|
||||
<TableRow className="border-b border-border/60 transition-colors hover:bg-accent hover:text-accent-foreground">
|
||||
<TableCell className="w-[45%] py-2.5 px-4 md:px-6 max-w-0 border-r border-border/60">
|
||||
<div className="flex items-center gap-3">
|
||||
<Avatar className="size-10 shrink-0">
|
||||
{member.user_avatar_url && (
|
||||
<AvatarImage src={member.user_avatar_url} alt={displayName} />
|
||||
)}
|
||||
<AvatarFallback className="text-sm">{initials}</AvatarFallback>
|
||||
<AvatarFallback className="bg-popover text-sm text-popover-foreground">
|
||||
{initials}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium text-sm truncate select-text">{displayName}</p>
|
||||
|
|
@ -474,7 +533,7 @@ function MemberRow({
|
|||
</div>
|
||||
</TableCell>
|
||||
|
||||
<TableCell className="hidden md:table-cell w-[25%] py-2.5 text-sm text-foreground border-r border-border/40">
|
||||
<TableCell className="hidden md:table-cell w-[25%] py-2.5 text-sm text-foreground border-r border-border/60">
|
||||
{member.user_last_login ? formatRelativeDate(member.user_last_login) : "Never"}
|
||||
</TableCell>
|
||||
|
||||
|
|
@ -482,18 +541,20 @@ function MemberRow({
|
|||
{showActions ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
className="inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-auto w-[74px] justify-end gap-1.5 px-0 py-0 text-sm text-muted-foreground hover:bg-transparent hover:text-accent-foreground has-[>svg]:px-0"
|
||||
>
|
||||
{roleName}
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</button>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
onCloseAutoFocus={(e) => e.preventDefault()}
|
||||
className="min-w-[120px] dark:bg-neutral-900 dark:border dark:border-white/5"
|
||||
className="min-w-[120px]"
|
||||
>
|
||||
{canManageRoles &&
|
||||
roles
|
||||
|
|
@ -536,13 +597,10 @@ function MemberRow({
|
|||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)}
|
||||
<DropdownMenuSeparator className="dark:bg-white/5" />
|
||||
<DropdownMenuSeparator className="bg-popover-border" />
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
setSearchSpaceSettingsDialog({
|
||||
open: true,
|
||||
initialTab: "team-roles",
|
||||
})
|
||||
router.push(`/dashboard/${searchSpaceId}/search-space-settings/team-roles`)
|
||||
}
|
||||
>
|
||||
Manage Roles
|
||||
|
|
@ -707,7 +765,7 @@ function CreateInviteDialog({
|
|||
<div className="space-y-2">
|
||||
<Label htmlFor="invite-role">Role</Label>
|
||||
<Select value={roleId} onValueChange={setRoleId}>
|
||||
<SelectTrigger>
|
||||
<SelectTrigger className="border-popover-border">
|
||||
<SelectValue placeholder="Assign a role" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
|
|
@ -743,7 +801,7 @@ function CreateInviteDialog({
|
|||
<Button
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"w-full justify-start text-left font-normal bg-transparent",
|
||||
"w-full justify-start text-left font-normal bg-transparent border-popover-border",
|
||||
!expiresAt && "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,5 @@
|
|||
import { AgentPermissionsContent } from "../components/AgentPermissionsContent";
|
||||
|
||||
export default function Page() {
|
||||
return <AgentPermissionsContent />;
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
import { AgentStatusContent } from "../components/AgentStatusContent";
|
||||
|
||||
export default function Page() {
|
||||
return <AgentStatusContent />;
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
import { ApiKeyContent } from "../components/ApiKeyContent";
|
||||
|
||||
export default function Page() {
|
||||
return <ApiKeyContent />;
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
import { CommunityPromptsContent } from "../components/CommunityPromptsContent";
|
||||
|
||||
export default function Page() {
|
||||
return <CommunityPromptsContent />;
|
||||
}
|
||||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { AlertTriangle, Check, Plus, ShieldCheck, Trash2, X } from "lucide-react";
|
||||
import { AlertTriangle, Info, ShieldCheck, Trash2 } from "lucide-react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { agentFlagsAtom } from "@/atoms/agent/agent-flags-query.atom";
|
||||
|
|
@ -20,6 +20,15 @@ import {
|
|||
} from "@/components/ui/alert-dialog";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
|
|
@ -29,6 +38,7 @@ import {
|
|||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import {
|
||||
type AgentPermissionAction,
|
||||
|
|
@ -67,20 +77,29 @@ function permissionRulesQueryKey(searchSpaceId: number) {
|
|||
function ScopeBadge({ rule }: { rule: AgentPermissionRule }) {
|
||||
if (rule.thread_id !== null) {
|
||||
return (
|
||||
<Badge variant="outline" className="text-[10px]">
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="text-[10px] px-1.5 py-0.5 border-0 text-muted-foreground bg-muted"
|
||||
>
|
||||
Thread #{rule.thread_id}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
if (rule.user_id !== null) {
|
||||
return (
|
||||
<Badge variant="outline" className="text-[10px]">
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="text-[10px] px-1.5 py-0.5 border-0 text-muted-foreground bg-muted"
|
||||
>
|
||||
User-specific
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Badge variant="outline" className="text-[10px]">
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="text-[10px] px-1.5 py-0.5 border-0 text-muted-foreground bg-muted"
|
||||
>
|
||||
Search space
|
||||
</Badge>
|
||||
);
|
||||
|
|
@ -170,8 +189,8 @@ export function AgentPermissionsContent() {
|
|||
permission: formData.permission.trim(),
|
||||
pattern: formData.pattern.trim() || "*",
|
||||
});
|
||||
setShowForm(false);
|
||||
setFormData(EMPTY_FORM);
|
||||
setShowForm(false);
|
||||
} catch (err) {
|
||||
if (err instanceof AppError && err.message) {
|
||||
// already toasted by onError
|
||||
|
|
@ -190,13 +209,17 @@ export function AgentPermissionsContent() {
|
|||
|
||||
if (!featureEnabled) {
|
||||
return (
|
||||
<Alert className="border-dashed">
|
||||
<ShieldCheck className="size-4" />
|
||||
<Alert>
|
||||
<Info />
|
||||
<AlertTitle>Permission middleware is disabled</AlertTitle>
|
||||
<AlertDescription>
|
||||
Flip{" "}
|
||||
<code className="rounded bg-muted px-1 text-[10px]">SURFSENSE_ENABLE_PERMISSION</code> on
|
||||
the backend to manage allow/deny/ask rules from this panel.
|
||||
<p>
|
||||
Flip{" "}
|
||||
<code className="rounded bg-popover px-1 py-0.5 text-[10px] text-popover-foreground">
|
||||
SURFSENSE_ENABLE_PERMISSION
|
||||
</code>{" "}
|
||||
on the backend to manage allow/deny/ask rules from this panel.
|
||||
</p>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
|
|
@ -208,28 +231,8 @@ export function AgentPermissionsContent() {
|
|||
);
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Spinner className="size-6" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<div className="rounded-lg border border-dashed border-destructive/40 p-8 text-center">
|
||||
<AlertTriangle className="mx-auto size-8 text-destructive/60" />
|
||||
<p className="mt-2 text-sm text-destructive">Failed to load rules</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{error instanceof Error ? error.message : "Unknown error."}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-w-0 space-y-6 overflow-hidden">
|
||||
<div className="min-w-0 space-y-6 overflow-visible">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
|
|
@ -237,27 +240,36 @@ export function AgentPermissionsContent() {
|
|||
patterns and are evaluated at the most specific scope first.
|
||||
</p>
|
||||
</div>
|
||||
{!showForm && (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setShowForm(true);
|
||||
setFormData(EMPTY_FORM);
|
||||
}}
|
||||
className="shrink-0 gap-1.5"
|
||||
>
|
||||
<Plus className="size-3.5" />
|
||||
New rule
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setShowForm(true);
|
||||
setFormData(EMPTY_FORM);
|
||||
}}
|
||||
className="shrink-0 gap-1.5"
|
||||
>
|
||||
New rule
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{showForm && (
|
||||
<div className="rounded-lg border border-border/60 bg-card p-6">
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-semibold tracking-tight">New permission rule</h3>
|
||||
<Dialog
|
||||
open={showForm}
|
||||
onOpenChange={(open) => {
|
||||
setShowForm(open);
|
||||
if (!open) setFormData(EMPTY_FORM);
|
||||
}}
|
||||
>
|
||||
<DialogContent className="max-w-lg bg-popover text-popover-foreground">
|
||||
<DialogHeader>
|
||||
<DialogTitle>New permission rule</DialogTitle>
|
||||
<DialogDescription>
|
||||
Tell the agent whether matching tool calls should be allowed, denied, or paused for
|
||||
approval.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-4">
|
||||
<div className="grid gap-3">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="permission-name">Permission</Label>
|
||||
<Input
|
||||
|
|
@ -297,43 +309,69 @@ export function AgentPermissionsContent() {
|
|||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="allow">Allow — run without asking</SelectItem>
|
||||
<SelectItem value="ask">Ask — pause for approval</SelectItem>
|
||||
<SelectItem value="deny">Deny — block silently</SelectItem>
|
||||
<SelectItem value="allow">Allow (run without asking)</SelectItem>
|
||||
<SelectItem value="ask">Ask (pause for approval)</SelectItem>
|
||||
<SelectItem value="deny">Deny (block silently)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
{ACTION_DESCRIPTIONS[formData.action]}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-2 pt-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setShowForm(false);
|
||||
setFormData(EMPTY_FORM);
|
||||
}}
|
||||
disabled={createMutation.isPending}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleCreate}
|
||||
disabled={createMutation.isPending || !formData.permission.trim()}
|
||||
className="relative"
|
||||
>
|
||||
<span className={createMutation.isPending ? "opacity-0" : ""}>Create</span>
|
||||
{createMutation.isPending && <Spinner className="absolute size-3.5" />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setShowForm(false);
|
||||
setFormData(EMPTY_FORM);
|
||||
}}
|
||||
disabled={createMutation.isPending}
|
||||
className="text-sm h-9"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleCreate}
|
||||
disabled={createMutation.isPending || !formData.permission.trim()}
|
||||
className="relative text-sm h-9 min-w-[96px]"
|
||||
>
|
||||
<span className={createMutation.isPending ? "opacity-0" : ""}>Create</span>
|
||||
{createMutation.isPending && <Spinner size="sm" className="absolute" />}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{isLoading && (
|
||||
<div className="-m-1 space-y-2 p-1">
|
||||
{["skeleton-a", "skeleton-b", "skeleton-c"].map((key) => (
|
||||
<Card key={key} className="border-accent bg-accent/20">
|
||||
<CardContent className="p-4 flex flex-col gap-3 min-h-24">
|
||||
<Skeleton className="h-4 w-32 md:w-40 bg-accent" />
|
||||
<Skeleton className="h-3 w-full bg-accent" />
|
||||
<Skeleton className="h-3 w-24 md:w-28 bg-accent mt-auto" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sortedRules.length === 0 && !showForm && (
|
||||
{isError && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTriangle />
|
||||
<AlertTitle>Failed to load rules</AlertTitle>
|
||||
<AlertDescription>
|
||||
{error instanceof Error ? error.message : "Unknown error."}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{!isLoading && !isError && sortedRules.length === 0 && !showForm && (
|
||||
<div className="rounded-lg border border-dashed border-border/60 p-8 text-center">
|
||||
<ShieldCheck className="mx-auto size-8 text-muted-foreground/40" />
|
||||
<p className="mt-2 text-sm text-muted-foreground">No rules yet</p>
|
||||
|
|
@ -343,8 +381,8 @@ export function AgentPermissionsContent() {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{sortedRules.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
{!isLoading && !isError && sortedRules.length > 0 && (
|
||||
<div className="-m-1 space-y-2 p-1">
|
||||
{sortedRules.map((rule) => {
|
||||
const badge = ACTION_BADGE[rule.action];
|
||||
const isUpdating =
|
||||
|
|
@ -352,14 +390,14 @@ export function AgentPermissionsContent() {
|
|||
const isDeleting = deleteMutation.isPending && deleteMutation.variables === rule.id;
|
||||
|
||||
return (
|
||||
<div
|
||||
<Card
|
||||
key={rule.id}
|
||||
className="group flex flex-col gap-3 rounded-lg border border-border/60 bg-card p-4"
|
||||
className="group relative overflow-hidden transition-all duration-200 border-accent bg-accent/20 hover:shadow-md h-full"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<CardContent className="p-4 flex items-center justify-between gap-3 h-full">
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-1.5">
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<code className="truncate rounded bg-muted px-1.5 py-0.5 font-mono text-xs">
|
||||
<code className="truncate font-mono text-sm font-medium text-foreground">
|
||||
{rule.permission}
|
||||
</code>
|
||||
{rule.pattern !== "*" && (
|
||||
|
|
@ -374,7 +412,7 @@ export function AgentPermissionsContent() {
|
|||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<div className="flex shrink-0 items-center self-center gap-1">
|
||||
<Select
|
||||
value={rule.action}
|
||||
onValueChange={(value) =>
|
||||
|
|
@ -389,11 +427,7 @@ export function AgentPermissionsContent() {
|
|||
className={cn("h-8 gap-1 border px-2 text-[11px]", badge.className)}
|
||||
>
|
||||
<SelectValue>
|
||||
<span className="flex items-center gap-1">
|
||||
{rule.action === "allow" && <Check className="size-3" />}
|
||||
{rule.action === "deny" && <X className="size-3" />}
|
||||
{badge.label}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">{badge.label}</span>
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
|
|
@ -406,7 +440,7 @@ export function AgentPermissionsContent() {
|
|||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="size-8 p-0 text-muted-foreground hover:text-destructive"
|
||||
className="h-7 w-7 rounded-lg p-0 text-muted-foreground hover:text-destructive"
|
||||
onClick={() => setDeleteTarget(rule.id)}
|
||||
disabled={isUpdating || isDeleting}
|
||||
aria-label="Delete rule"
|
||||
|
|
@ -414,8 +448,8 @@ export function AgentPermissionsContent() {
|
|||
<Trash2 className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
"use client";
|
||||
|
||||
import { useAtomValue } from "jotai";
|
||||
import { CircleCheck, CircleSlash, Cog, RotateCcw } from "lucide-react";
|
||||
import { useMemo } from "react";
|
||||
import { AlertTriangle, CircleCheck, CircleSlash, Info } from "lucide-react";
|
||||
import { Fragment, useMemo } from "react";
|
||||
import { agentFlagsAtom } from "@/atoms/agent/agent-flags-query.atom";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
|
@ -136,7 +136,7 @@ const FLAG_GROUPS: FlagGroup[] = [
|
|||
{
|
||||
id: "tier5",
|
||||
title: "Tier 5 — Audit + revert",
|
||||
subtitle: "Action log + revert route used by the Agent Actions sheet.",
|
||||
subtitle: "Action log + revert route used by the Agent Actions dialog.",
|
||||
flags: [
|
||||
{
|
||||
key: "enable_action_log",
|
||||
|
|
@ -222,7 +222,7 @@ function FlagRow({ def, value }: { def: FlagDef; value: boolean }) {
|
|||
}
|
||||
|
||||
export function AgentStatusContent() {
|
||||
const { data: flags, isLoading, isError, error, refetch } = useAtomValue(agentFlagsAtom);
|
||||
const { data: flags, isLoading, isError, error } = useAtomValue(agentFlagsAtom);
|
||||
|
||||
const enabledCount = useMemo(() => {
|
||||
if (!flags) return 0;
|
||||
|
|
@ -243,17 +243,10 @@ export function AgentStatusContent() {
|
|||
if (isError || !flags) {
|
||||
return (
|
||||
<Alert variant="destructive">
|
||||
<AlertTriangle />
|
||||
<AlertTitle>Failed to load agent status</AlertTitle>
|
||||
<AlertDescription className="flex items-center gap-2">
|
||||
<AlertDescription>
|
||||
{error instanceof Error ? error.message : "Unknown error."}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => refetch()}
|
||||
className="ml-auto inline-flex items-center gap-1 rounded-md border px-2 py-0.5 text-xs hover:bg-background"
|
||||
>
|
||||
<RotateCcw className="size-3" />
|
||||
Retry
|
||||
</button>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
|
|
@ -265,28 +258,36 @@ export function AgentStatusContent() {
|
|||
<div className="space-y-6">
|
||||
{masterOff ? (
|
||||
<Alert variant="destructive">
|
||||
<Cog className="size-4" />
|
||||
<AlertTriangle />
|
||||
<AlertTitle>Master kill-switch is on</AlertTitle>
|
||||
<AlertDescription>
|
||||
<code className="rounded bg-muted px-1 text-[10px]">
|
||||
SURFSENSE_DISABLE_NEW_AGENT_STACK=true
|
||||
</code>
|
||||
forces every new middleware off, regardless of the individual flags below. Restart the
|
||||
backend after changing it.
|
||||
<p>
|
||||
Showing that{" "}
|
||||
<code className="rounded bg-muted px-1 text-[10px]">
|
||||
SURFSENSE_DISABLE_NEW_AGENT_STACK=true
|
||||
</code>
|
||||
, which forces every new middleware off, regardless of the individual flags below.
|
||||
Restart the backend after changing it.
|
||||
</p>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : (
|
||||
<Alert>
|
||||
<Cog className="size-4" />
|
||||
<Info />
|
||||
<AlertTitle className="flex items-center gap-2">
|
||||
Agent stack
|
||||
<Badge variant="secondary" className="text-[10px]">
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="rounded bg-popover px-1 py-0.5 text-[9px] text-popover-foreground"
|
||||
>
|
||||
{enabledCount} on
|
||||
</Badge>
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
Read-only mirror of the backend's <code>AgentFeatureFlags</code>. Flip an env var and
|
||||
restart the backend to change a value.
|
||||
<p>
|
||||
Showing a read-only mirror of the backend's <code>AgentFeatureFlags</code>. Flip an
|
||||
env var and restart the backend to change a value.
|
||||
</p>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
|
@ -295,9 +296,9 @@ export function AgentStatusContent() {
|
|||
const allOff = group.flags.every((f) => !flags[f.key]);
|
||||
return (
|
||||
<div key={group.id}>
|
||||
{groupIdx > 0 && <Separator className="my-4" />}
|
||||
{groupIdx > 0 && <Separator className="my-4 bg-border" />}
|
||||
<div className="rounded-lg border border-border/60 bg-card">
|
||||
<div className="flex items-start justify-between gap-3 border-b px-4 py-3">
|
||||
<div className="flex items-start justify-between gap-3 px-4 py-3">
|
||||
<div>
|
||||
<p className="text-sm font-semibold">{group.title}</p>
|
||||
<p className="text-xs text-muted-foreground">{group.subtitle}</p>
|
||||
|
|
@ -308,9 +309,13 @@ export function AgentStatusContent() {
|
|||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="divide-y divide-border/50 px-4">
|
||||
{group.flags.map((def) => (
|
||||
<FlagRow key={def.key} def={def} value={flags[def.key]} />
|
||||
<Separator className="bg-border" />
|
||||
<div className="px-4">
|
||||
{group.flags.map((def, flagIdx) => (
|
||||
<Fragment key={def.key}>
|
||||
{flagIdx > 0 && <Separator className="bg-border" />}
|
||||
<FlagRow def={def} value={flags[def.key]} />
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { useTranslations } from "next-intl";
|
|||
import { useCallback, useRef, useState } from "react";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { useApiKey } from "@/hooks/use-api-key";
|
||||
import { copyToClipboard as copyToClipboardUtil } from "@/lib/utils";
|
||||
|
|
@ -27,17 +28,20 @@ export function ApiKeyContent() {
|
|||
|
||||
return (
|
||||
<div className="space-y-6 min-w-0 overflow-hidden">
|
||||
<Alert className="bg-muted/50 py-3 md:py-4">
|
||||
<Info className="h-3 w-3 md:h-4 md:w-4 shrink-0" />
|
||||
<AlertDescription className="text-xs md:text-sm">
|
||||
{t("api_key_warning_description")}
|
||||
</AlertDescription>
|
||||
<Alert>
|
||||
<Info />
|
||||
<AlertDescription>{t("api_key_warning_description")}</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<div className="rounded-lg border border-border/60 bg-card p-6 min-w-0 overflow-hidden">
|
||||
<div className="min-w-0 overflow-hidden">
|
||||
<h3 className="mb-4 text-sm font-semibold tracking-tight">{t("your_api_key")}</h3>
|
||||
{isLoading ? (
|
||||
<div className="h-12 w-full animate-pulse rounded-md border border-border/60 bg-muted/30" />
|
||||
<div className="flex items-center gap-2 rounded-md border border-border/60 bg-muted/30 px-2.5 py-1.5">
|
||||
<div className="min-w-0 flex-1 overflow-hidden">
|
||||
<Skeleton className="h-3 w-full bg-accent" />
|
||||
</div>
|
||||
<div className="h-6 w-6 shrink-0" />
|
||||
</div>
|
||||
) : apiKey ? (
|
||||
<div className="flex items-center gap-2 rounded-md border border-border/60 bg-muted/30 px-2.5 py-1.5">
|
||||
<div className="min-w-0 flex-1 overflow-x-auto scrollbar-hide">
|
||||
|
|
@ -52,7 +56,7 @@ export function ApiKeyContent() {
|
|||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={copyToClipboard}
|
||||
className="h-6 w-6 shrink-0 text-muted-foreground hover:text-foreground"
|
||||
className="h-6 w-6 shrink-0 text-muted-foreground hover:text-accent-foreground"
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="h-3 w-3 text-green-500" />
|
||||
|
|
@ -70,7 +74,7 @@ export function ApiKeyContent() {
|
|||
)}
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-border/60 bg-card p-6 min-w-0 overflow-hidden">
|
||||
<div className="min-w-0 overflow-hidden">
|
||||
<h3 className="mb-2 text-sm font-semibold tracking-tight">{t("usage_title")}</h3>
|
||||
<p className="mb-4 text-[11px] text-muted-foreground/60">{t("usage_description")}</p>
|
||||
<div className="flex items-center gap-2 rounded-md border border-border/60 bg-muted/30 px-2.5 py-1.5">
|
||||
|
|
@ -86,7 +90,7 @@ export function ApiKeyContent() {
|
|||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={copyUsageToClipboard}
|
||||
className="h-6 w-6 shrink-0 text-muted-foreground hover:text-foreground"
|
||||
className="h-6 w-6 shrink-0 text-muted-foreground hover:text-accent-foreground"
|
||||
>
|
||||
{copiedUsage ? (
|
||||
<Check className="h-3 w-3 text-green-500" />
|
||||
|
|
|
|||
|
|
@ -1,11 +1,14 @@
|
|||
"use client";
|
||||
|
||||
import { useAtomValue } from "jotai";
|
||||
import { AlertTriangle, Copy, Globe, Sparkles } from "lucide-react";
|
||||
import { AlertTriangle, Copy, Library } from "lucide-react";
|
||||
import { useCallback, useState } from "react";
|
||||
import { copyPromptMutationAtom } from "@/atoms/prompts/prompts-mutation.atoms";
|
||||
import { publicPromptsAtom } from "@/atoms/prompts/prompts-query.atoms";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
|
||||
export function CommunityPromptsContent() {
|
||||
|
|
@ -34,33 +37,37 @@ export function CommunityPromptsContent() {
|
|||
|
||||
const list = prompts ?? [];
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Spinner className="size-6" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<div className="rounded-lg border border-dashed border-destructive/40 p-8 text-center">
|
||||
<AlertTriangle className="mx-auto size-8 text-destructive/60" />
|
||||
<p className="mt-2 text-sm text-destructive">Failed to load community prompts</p>
|
||||
<p className="text-xs text-muted-foreground">Please try refreshing the page.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6 min-w-0 overflow-hidden">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Prompts shared by other users. Add any to your collection with one click.
|
||||
</p>
|
||||
|
||||
{list.length === 0 && (
|
||||
{isLoading && (
|
||||
<div className="space-y-2">
|
||||
{["skeleton-a", "skeleton-b", "skeleton-c"].map((key) => (
|
||||
<Card key={key} className="border-accent bg-accent/20">
|
||||
<CardContent className="p-4 flex flex-col gap-3 min-h-24">
|
||||
<Skeleton className="h-4 w-32 md:w-40 bg-accent" />
|
||||
<Skeleton className="h-3 w-full bg-accent" />
|
||||
<Skeleton className="h-3 w-24 md:w-28 bg-accent mt-auto" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isError && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTriangle />
|
||||
<AlertTitle>Failed to load community prompts</AlertTitle>
|
||||
<AlertDescription>Please try refreshing the page.</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{!isLoading && !isError && list.length === 0 && (
|
||||
<div className="rounded-lg border border-dashed border-border/60 p-8 text-center">
|
||||
<Globe className="mx-auto size-8 text-muted-foreground" />
|
||||
<Library className="mx-auto size-8 text-muted-foreground" />
|
||||
<p className="mt-2 text-sm text-muted-foreground">No community prompts yet</p>
|
||||
<p className="text-xs text-muted-foreground/60">
|
||||
Share your own prompts from the My Prompts tab
|
||||
|
|
@ -68,58 +75,58 @@ export function CommunityPromptsContent() {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{list.length > 0 && (
|
||||
{!isLoading && !isError && list.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
{list.map((prompt) => (
|
||||
<div
|
||||
<Card
|
||||
key={prompt.id}
|
||||
className="group flex items-start gap-3 rounded-lg border border-border/60 bg-card p-4"
|
||||
className="group relative overflow-hidden transition-all duration-200 border-accent bg-accent/20 hover:shadow-md h-full"
|
||||
>
|
||||
<div className="mt-0.5 shrink-0 text-muted-foreground">
|
||||
<Sparkles className="size-4" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium">{prompt.name}</span>
|
||||
<span className="rounded-full border px-2 py-0.5 text-[10px] text-muted-foreground">
|
||||
{prompt.mode}
|
||||
</span>
|
||||
{prompt.author_name && (
|
||||
<span className="text-[11px] text-muted-foreground/60">
|
||||
by {prompt.author_name}
|
||||
<CardContent className="p-4 flex items-start gap-3 h-full">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium">{prompt.name}</span>
|
||||
<span className="rounded-md border-0 bg-muted px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground">
|
||||
{prompt.mode}
|
||||
</span>
|
||||
{prompt.author_name && (
|
||||
<span className="text-[11px] text-muted-foreground/60">
|
||||
by {prompt.author_name}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p
|
||||
className={`mt-1 text-xs text-muted-foreground ${expandedId === prompt.id ? "whitespace-pre-wrap" : "line-clamp-2"}`}
|
||||
>
|
||||
{prompt.prompt}
|
||||
</p>
|
||||
{prompt.prompt.length > 100 && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="link"
|
||||
onClick={() => setExpandedId(expandedId === prompt.id ? null : prompt.id)}
|
||||
className="mt-1 h-auto cursor-pointer px-0 py-0 text-[11px] text-primary"
|
||||
>
|
||||
{expandedId === prompt.id ? "See less" : "See more"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<p
|
||||
className={`mt-1 text-xs text-muted-foreground ${expandedId === prompt.id ? "whitespace-pre-wrap" : "line-clamp-2"}`}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 shrink-0 gap-1.5 rounded-lg px-2 text-muted-foreground hover:text-accent-foreground"
|
||||
disabled={copyingIds.has(prompt.id)}
|
||||
onClick={() => handleCopy(prompt.id)}
|
||||
>
|
||||
{prompt.prompt}
|
||||
</p>
|
||||
{prompt.prompt.length > 100 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpandedId(expandedId === prompt.id ? null : prompt.id)}
|
||||
className="mt-1 text-[11px] text-primary hover:underline cursor-pointer"
|
||||
>
|
||||
{expandedId === prompt.id ? "See less" : "See more"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="shrink-0 gap-1.5"
|
||||
disabled={copyingIds.has(prompt.id)}
|
||||
onClick={() => handleCopy(prompt.id)}
|
||||
>
|
||||
{copyingIds.has(prompt.id) ? (
|
||||
<Spinner className="size-3" />
|
||||
) : (
|
||||
<Copy className="size-3" />
|
||||
)}
|
||||
Add to mine
|
||||
</Button>
|
||||
</div>
|
||||
{copyingIds.has(prompt.id) ? (
|
||||
<Spinner className="size-3" />
|
||||
) : (
|
||||
<Copy className="size-3" />
|
||||
)}
|
||||
Add to mine
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@
|
|||
|
||||
import { useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
|
|
@ -11,7 +10,8 @@ import {
|
|||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import type { SearchSpace } from "@/contracts/types/search-space.types";
|
||||
import { useElectronAPI } from "@/hooks/use-platform";
|
||||
|
|
@ -77,8 +77,27 @@ export function DesktopContent() {
|
|||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Spinner size="md" className="text-muted-foreground" />
|
||||
<div className="flex flex-col gap-4 md:gap-6">
|
||||
<section>
|
||||
<div className="flex flex-col gap-2 pb-2 md:pb-3">
|
||||
<Skeleton className="h-6 w-48 bg-accent" />
|
||||
<Skeleton className="h-4 w-full max-w-2xl bg-accent" />
|
||||
</div>
|
||||
<Skeleton className="h-10 w-full bg-accent" />
|
||||
</section>
|
||||
|
||||
<Separator className="bg-border" />
|
||||
|
||||
<section>
|
||||
<div className="flex flex-col gap-2 pb-2 md:pb-3">
|
||||
<Skeleton className="h-6 w-44 bg-accent" />
|
||||
<Skeleton className="h-4 w-full max-w-3xl bg-accent" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-3">
|
||||
<Skeleton className="h-20 w-full bg-accent" />
|
||||
<Skeleton className="h-20 w-full bg-accent" />
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -124,16 +143,16 @@ export function DesktopContent() {
|
|||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4 md:space-y-6">
|
||||
<Card>
|
||||
<CardHeader className="px-3 md:px-6 pt-3 md:pt-6 pb-2 md:pb-3">
|
||||
<CardTitle className="text-base md:text-lg">Default Search Space</CardTitle>
|
||||
<CardDescription className="text-xs md:text-sm">
|
||||
<div className="flex flex-col gap-4 md:gap-6">
|
||||
<section>
|
||||
<div className="pb-2 md:pb-3">
|
||||
<h2 className="text-base md:text-lg font-semibold">Default Search Space</h2>
|
||||
<p className="text-xs md:text-sm text-muted-foreground">
|
||||
Choose which search space General Assist, Screenshot Assist, and Quick Assist use by
|
||||
default.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="px-3 md:px-6 pb-3 md:pb-6">
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
{searchSpaces.length > 0 ? (
|
||||
<Select value={activeSpaceId ?? undefined} onValueChange={handleSearchSpaceChange}>
|
||||
<SelectTrigger className="w-full">
|
||||
|
|
@ -152,21 +171,23 @@ export function DesktopContent() {
|
|||
No search spaces found. Create one first.
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="px-3 md:px-6 pt-3 md:pt-6 pb-2 md:pb-3">
|
||||
<CardTitle className="text-base md:text-lg flex items-center gap-2">
|
||||
<Separator className="bg-border" />
|
||||
|
||||
<section>
|
||||
<div className="pb-2 md:pb-3">
|
||||
<h2 className="text-base md:text-lg font-semibold flex items-center gap-2">
|
||||
Launch on Startup
|
||||
</CardTitle>
|
||||
<CardDescription className="text-xs md:text-sm">
|
||||
</h2>
|
||||
<p className="text-xs md:text-sm text-muted-foreground">
|
||||
Automatically start SurfSense when you sign in to your computer so global shortcuts and
|
||||
folder sync are always available.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="px-3 md:px-6 pb-3 md:pb-6 space-y-3">
|
||||
<div className="flex items-center justify-between rounded-lg border p-4">
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center justify-between rounded-lg bg-accent p-4">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="auto-launch-toggle" className="text-sm font-medium cursor-pointer">
|
||||
Open SurfSense at login
|
||||
|
|
@ -184,7 +205,7 @@ export function DesktopContent() {
|
|||
disabled={!autoLaunchSupported}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between rounded-lg border p-4">
|
||||
<div className="flex items-center justify-between rounded-lg bg-accent p-4">
|
||||
<div className="space-y-0.5">
|
||||
<Label
|
||||
htmlFor="auto-launch-hidden-toggle"
|
||||
|
|
@ -193,7 +214,7 @@ export function DesktopContent() {
|
|||
Start minimized to tray
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Skip the main window on boot — SurfSense lives in the system tray until you need it.
|
||||
Skip the main window on boot. SurfSense lives in the system tray until you need it.
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
|
|
@ -203,8 +224,8 @@ export function DesktopContent() {
|
|||
disabled={!autoLaunchSupported || !autoLaunchEnabled}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|||
import { toast } from "sonner";
|
||||
import { DEFAULT_SHORTCUTS, keyEventToAccelerator } from "@/components/desktop/shortcut-recorder";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { ShortcutKbd } from "@/components/ui/shortcut-kbd";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { useElectronAPI } from "@/hooks/use-platform";
|
||||
|
|
@ -78,7 +79,7 @@ function HotkeyRow({
|
|||
);
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-2.5 border-border/60 border-b py-3 last:border-b-0">
|
||||
<div className="flex items-center justify-between gap-2.5 py-3">
|
||||
<div className="flex items-center gap-2.5 min-w-0">
|
||||
<div className="flex size-7 shrink-0 items-center justify-center rounded-md bg-primary/10 text-primary">
|
||||
<Icon className="size-3.5" />
|
||||
|
|
@ -90,38 +91,39 @@ function HotkeyRow({
|
|||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7 text-muted-foreground hover:text-foreground"
|
||||
className="size-7 text-muted-foreground hover:text-accent-foreground"
|
||||
onClick={onReset}
|
||||
title="Reset to default"
|
||||
>
|
||||
<RotateCcw className="size-3" />
|
||||
</Button>
|
||||
)}
|
||||
<button
|
||||
<Button
|
||||
ref={inputRef}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
title={recording ? "Press shortcut keys" : "Click to edit shortcut"}
|
||||
onClick={() => setRecording(true)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onBlur={() => setRecording(false)}
|
||||
className={
|
||||
recording
|
||||
? "flex h-7 items-center rounded-md border border-transparent bg-primary/5 outline-none ring-0 focus:outline-none focus-visible:outline-none focus-visible:ring-0"
|
||||
: "flex h-7 cursor-pointer items-center rounded-md border border-transparent bg-transparent outline-none ring-0 transition-colors hover:bg-accent hover:text-accent-foreground focus:outline-none focus-visible:outline-none focus-visible:ring-0"
|
||||
? "h-7 border border-transparent bg-primary/5 px-0 outline-none ring-0 focus:outline-none focus-visible:outline-none focus-visible:ring-0"
|
||||
: "h-7 cursor-pointer border border-transparent bg-transparent px-0 outline-none ring-0 transition-colors hover:bg-accent hover:text-accent-foreground focus:outline-none focus-visible:outline-none focus-visible:ring-0"
|
||||
}
|
||||
>
|
||||
{recording ? (
|
||||
<span className="px-2 text-[9px] text-primary whitespace-nowrap">Press hotkeys...</span>
|
||||
<span className="px-2 text-[9px] text-primary whitespace-nowrap">Press hotkeys</span>
|
||||
) : (
|
||||
<ShortcutKbd keys={displayKeys} className="ml-0 px-1.5 text-foreground/85" />
|
||||
)}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function DesktopShortcutsContent() {
|
||||
export function HotkeysContent() {
|
||||
const api = useElectronAPI();
|
||||
const [shortcuts, setShortcuts] = useState(DEFAULT_SHORTCUTS);
|
||||
const [shortcutsLoaded, setShortcutsLoaded] = useState(false);
|
||||
|
|
@ -178,17 +180,19 @@ export function DesktopShortcutsContent() {
|
|||
return shortcutsLoaded ? (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div>
|
||||
{HOTKEY_ROWS.map((row) => (
|
||||
<HotkeyRow
|
||||
key={row.key}
|
||||
label={row.label}
|
||||
value={shortcuts[row.key]}
|
||||
defaultValue={DEFAULT_SHORTCUTS[row.key]}
|
||||
icon={row.icon}
|
||||
isMac={isMac}
|
||||
onChange={(accel) => updateShortcut(row.key, accel)}
|
||||
onReset={() => resetShortcut(row.key)}
|
||||
/>
|
||||
{HOTKEY_ROWS.map((row, index) => (
|
||||
<div key={row.key}>
|
||||
<HotkeyRow
|
||||
label={row.label}
|
||||
value={shortcuts[row.key]}
|
||||
defaultValue={DEFAULT_SHORTCUTS[row.key]}
|
||||
icon={row.icon}
|
||||
isMac={isMac}
|
||||
onChange={(accel) => updateShortcut(row.key, accel)}
|
||||
onReset={() => resetShortcut(row.key)}
|
||||
/>
|
||||
{index < HOTKEY_ROWS.length - 1 ? <Separator className="bg-border" /> : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -177,9 +177,9 @@ export function MemoryContent() {
|
|||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Alert className="bg-muted/50 py-3 md:py-4">
|
||||
<Info className="h-3 w-3 md:h-4 md:w-4 shrink-0" />
|
||||
<AlertDescription className="text-xs md:text-sm">
|
||||
<Alert>
|
||||
<Info />
|
||||
<AlertDescription>
|
||||
<p>
|
||||
SurfSense uses this personal memory to personalize your responses across all
|
||||
conversations.
|
||||
|
|
@ -222,7 +222,9 @@ export function MemoryContent() {
|
|||
onClick={handleEdit}
|
||||
disabled={editing || !editQuery.trim()}
|
||||
className={`h-11 w-11 shrink-0 rounded-full ${
|
||||
editing ? "" : "bg-muted-foreground/15 hover:bg-muted-foreground/20"
|
||||
editing
|
||||
? ""
|
||||
: "bg-muted-foreground/15 hover:bg-accent hover:text-accent-foreground"
|
||||
}`}
|
||||
>
|
||||
{editing ? (
|
||||
|
|
|
|||
|
|
@ -11,8 +11,17 @@ import { Button } from "@/components/ui/button";
|
|||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { getUserAvatarColor, getUserInitials } from "@/lib/user-avatar";
|
||||
|
||||
function AvatarDisplay({ url, fallback }: { url?: string; fallback: string }) {
|
||||
function AvatarDisplay({
|
||||
url,
|
||||
fallback,
|
||||
bgColor,
|
||||
}: {
|
||||
url?: string;
|
||||
fallback: string;
|
||||
bgColor: string;
|
||||
}) {
|
||||
const [errorUrl, setErrorUrl] = useState<string>();
|
||||
const hasError = errorUrl === url;
|
||||
|
||||
|
|
@ -23,15 +32,19 @@ function AvatarDisplay({ url, fallback }: { url?: string; fallback: string }) {
|
|||
alt="Avatar"
|
||||
width={64}
|
||||
height={64}
|
||||
className="h-16 w-16 rounded-xl object-cover"
|
||||
className="h-16 w-16 rounded-full object-cover select-none"
|
||||
onError={() => setErrorUrl(url)}
|
||||
referrerPolicy="no-referrer"
|
||||
unoptimized
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-16 w-16 items-center justify-center rounded-xl bg-muted text-xl font-semibold text-muted-foreground">
|
||||
<div
|
||||
className="flex h-16 w-16 shrink-0 items-center justify-center rounded-full text-xl font-semibold text-white select-none"
|
||||
style={{ backgroundColor: bgColor }}
|
||||
>
|
||||
{fallback}
|
||||
</div>
|
||||
);
|
||||
|
|
@ -50,11 +63,6 @@ export function ProfileContent() {
|
|||
}
|
||||
}, [user]);
|
||||
|
||||
const getInitials = (email: string) => {
|
||||
const name = email.split("@")[0];
|
||||
return name.slice(0, 2).toUpperCase();
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
|
|
@ -69,6 +77,7 @@ export function ProfileContent() {
|
|||
};
|
||||
|
||||
const hasChanges = displayName !== (user?.display_name || "");
|
||||
const avatarBgColor = getUserAvatarColor(user?.email || "");
|
||||
|
||||
return (
|
||||
<div>
|
||||
|
|
@ -78,13 +87,13 @@ export function ProfileContent() {
|
|||
</div>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div className="rounded-lg bg-card">
|
||||
<div className="rounded-lg bg-main-panel">
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="space-y-2">
|
||||
<Label>{t("profile_avatar")}</Label>
|
||||
<AvatarDisplay
|
||||
url={user?.avatar_url || undefined}
|
||||
fallback={getInitials(user?.email || "")}
|
||||
fallback={getUserInitials(user?.email || "")}
|
||||
bgColor={avatarBgColor}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
@ -114,7 +123,7 @@ export function ProfileContent() {
|
|||
type="submit"
|
||||
variant="outline"
|
||||
disabled={isPending || !hasChanges}
|
||||
className="relative gap-2 bg-white text-black hover:bg-neutral-100 dark:bg-white dark:text-black dark:hover:bg-neutral-200"
|
||||
className="relative gap-2 bg-white text-black hover:bg-accent hover:text-accent-foreground dark:bg-white dark:text-black"
|
||||
>
|
||||
<span className={isPending ? "opacity-0" : ""}>{t("profile_save")}</span>
|
||||
{isPending && <Spinner size="sm" className="absolute" />}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { useAtomValue } from "jotai";
|
||||
import { AlertTriangle, Globe, Lock, Pencil, Sparkles, Trash2 } from "lucide-react";
|
||||
import { AlertTriangle, Globe, Lock, MoreHorizontal, Pencil, Sparkles, Trash2 } from "lucide-react";
|
||||
import { useCallback, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
|
|
@ -10,6 +10,7 @@ import {
|
|||
updatePromptMutationAtom,
|
||||
} from "@/atoms/prompts/prompts-mutation.atoms";
|
||||
import { promptsAtom } from "@/atoms/prompts/prompts-query.atoms";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
|
|
@ -21,9 +22,32 @@ import {
|
|||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { ShortcutKbd } from "@/components/ui/shortcut-kbd";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import type { PromptRead } from "@/contracts/types/prompts.types";
|
||||
|
|
@ -123,24 +147,6 @@ export function PromptsContent() {
|
|||
|
||||
const list = prompts ?? [];
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Spinner className="size-6" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<div className="rounded-lg border border-dashed border-destructive/40 p-8 text-center">
|
||||
<AlertTriangle className="mx-auto size-8 text-destructive/60" />
|
||||
<p className="mt-2 text-sm text-destructive">Failed to load prompts</p>
|
||||
<p className="text-xs text-muted-foreground">Please try refreshing the page.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6 min-w-0 overflow-hidden">
|
||||
<div className="flex items-center justify-between">
|
||||
|
|
@ -148,97 +154,150 @@ export function PromptsContent() {
|
|||
Create prompt templates triggered with <ShortcutKbd keys={["/"]} className="ml-0" /> in
|
||||
the chat composer.
|
||||
</p>
|
||||
{!showForm && (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setShowForm(true);
|
||||
setEditingId(null);
|
||||
setFormData(EMPTY_FORM);
|
||||
}}
|
||||
className="shrink-0 gap-1.5"
|
||||
>
|
||||
New
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setShowForm(true);
|
||||
setEditingId(null);
|
||||
setFormData(EMPTY_FORM);
|
||||
}}
|
||||
className="shrink-0 gap-1.5"
|
||||
>
|
||||
New
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{showForm && (
|
||||
<div className="rounded-lg border border-border/60 bg-card p-6 space-y-4">
|
||||
<h3 className="text-sm font-semibold tracking-tight">
|
||||
{editingId !== null ? "Edit prompt" : "New prompt"}
|
||||
</h3>
|
||||
<Dialog
|
||||
open={showForm}
|
||||
onOpenChange={(open) => {
|
||||
setShowForm(open);
|
||||
if (!open) {
|
||||
setFormData(EMPTY_FORM);
|
||||
setEditingId(null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent className="max-w-lg bg-popover text-popover-foreground">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editingId !== null ? "Edit prompt" : "New prompt"}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Create prompt templates triggered with / in the chat composer.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="prompt-name">Name</Label>
|
||||
<Input
|
||||
id="prompt-name"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData((p) => ({ ...p, name: e.target.value }))}
|
||||
placeholder="e.g. Fix grammar"
|
||||
/>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="prompt-name">Name</Label>
|
||||
<Input
|
||||
id="prompt-name"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData((p) => ({ ...p, name: e.target.value }))}
|
||||
placeholder="e.g. Fix grammar"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="prompt-template">Prompt template</Label>
|
||||
<textarea
|
||||
id="prompt-template"
|
||||
value={formData.prompt}
|
||||
onChange={(e) => setFormData((p) => ({ ...p, prompt: e.target.value }))}
|
||||
placeholder="e.g. Fix the grammar in the following text:\n\n{selection}"
|
||||
rows={4}
|
||||
className="w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm outline-none resize-none focus:ring-1 focus:ring-ring"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Use{" "}
|
||||
<code className="rounded bg-muted px-1 py-0.5 font-mono text-[11px]">
|
||||
{"{selection}"}
|
||||
</code>{" "}
|
||||
to insert the input text. If omitted, the text is appended automatically.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="prompt-mode">Mode</Label>
|
||||
<Select
|
||||
value={formData.mode}
|
||||
onValueChange={(value) =>
|
||||
setFormData((p) => ({ ...p, mode: value as "transform" | "explore" }))
|
||||
}
|
||||
>
|
||||
<SelectTrigger id="prompt-mode" className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="transform">
|
||||
Transform — rewrites or modifies your text
|
||||
</SelectItem>
|
||||
<SelectItem value="explore">
|
||||
Explore — answers a question about your text
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
id="prompt-public"
|
||||
checked={formData.is_public}
|
||||
onCheckedChange={(checked) => setFormData((p) => ({ ...p, is_public: checked }))}
|
||||
/>
|
||||
<Label htmlFor="prompt-public" className="text-sm font-normal">
|
||||
Share with community
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="prompt-template">Prompt template</Label>
|
||||
<textarea
|
||||
id="prompt-template"
|
||||
value={formData.prompt}
|
||||
onChange={(e) => setFormData((p) => ({ ...p, prompt: e.target.value }))}
|
||||
placeholder="e.g. Fix the grammar in the following text:\n\n{selection}"
|
||||
rows={4}
|
||||
className="w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm outline-none resize-none focus:ring-1 focus:ring-ring"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Use{" "}
|
||||
<code className="rounded bg-muted px-1 py-0.5 font-mono text-[11px]">
|
||||
{"{selection}"}
|
||||
</code>{" "}
|
||||
to insert the input text. If omitted, the text is appended automatically.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="prompt-mode">Mode</Label>
|
||||
<select
|
||||
id="prompt-mode"
|
||||
value={formData.mode}
|
||||
onChange={(e) =>
|
||||
setFormData((p) => ({ ...p, mode: e.target.value as "transform" | "explore" }))
|
||||
}
|
||||
className="w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-ring"
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={handleCancel}
|
||||
disabled={isSaving}
|
||||
className="text-sm h-9"
|
||||
>
|
||||
<option value="transform">Transform — rewrites or modifies your text</option>
|
||||
<option value="explore">Explore — answers a question about your text</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
id="prompt-public"
|
||||
checked={formData.is_public}
|
||||
onCheckedChange={(checked) => setFormData((p) => ({ ...p, is_public: checked }))}
|
||||
/>
|
||||
<Label htmlFor="prompt-public" className="text-sm font-normal">
|
||||
Share with community
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-2 pt-2">
|
||||
<Button variant="ghost" size="sm" onClick={handleCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button size="sm" onClick={handleSave} disabled={isSaving} className="relative">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleSave}
|
||||
disabled={isSaving}
|
||||
className="relative text-sm h-9 min-w-[96px]"
|
||||
>
|
||||
<span className={isSaving ? "opacity-0" : ""}>
|
||||
{editingId !== null ? "Update" : "Create"}
|
||||
</span>
|
||||
{isSaving && <Spinner className="size-3.5 absolute" />}
|
||||
{isSaving && <Spinner size="sm" className="absolute" />}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{isLoading && (
|
||||
<div className="space-y-2">
|
||||
{["skeleton-a", "skeleton-b", "skeleton-c"].map((key) => (
|
||||
<Card key={key} className="border-accent bg-accent/20">
|
||||
<CardContent className="p-4 flex flex-col gap-3 min-h-24">
|
||||
<Skeleton className="h-4 w-32 md:w-40 bg-accent" />
|
||||
<Skeleton className="h-3 w-full bg-accent" />
|
||||
<Skeleton className="h-3 w-24 md:w-28 bg-accent mt-auto" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{list.length === 0 && !showForm && (
|
||||
{isError && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTriangle />
|
||||
<AlertTitle>Failed to load prompts</AlertTitle>
|
||||
<AlertDescription>Please try refreshing the page.</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{!isLoading && !isError && list.length === 0 && !showForm && (
|
||||
<div className="rounded-lg border border-dashed border-border/60 p-8 text-center">
|
||||
<Sparkles className="mx-auto size-8 text-muted-foreground/40" />
|
||||
<p className="mt-2 text-sm text-muted-foreground">No prompts yet</p>
|
||||
|
|
@ -248,24 +307,21 @@ export function PromptsContent() {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{list.length > 0 && (
|
||||
{!isLoading && !isError && list.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
{list.map((prompt) => (
|
||||
<div
|
||||
key={prompt.id}
|
||||
className="group flex items-start gap-3 rounded-lg border border-border/60 bg-card p-4"
|
||||
className="group relative flex items-start gap-3 overflow-hidden rounded-lg border border-accent bg-accent/20 p-4 transition-all duration-200 hover:shadow-md"
|
||||
>
|
||||
<div className="mt-0.5 shrink-0 text-muted-foreground">
|
||||
<Sparkles className="size-4" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium">{prompt.name}</span>
|
||||
<span className="rounded-full border px-2 py-0.5 text-[10px] text-muted-foreground">
|
||||
<span className="rounded-md border-0 bg-muted px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground">
|
||||
{prompt.mode}
|
||||
</span>
|
||||
{prompt.is_public && (
|
||||
<span className="flex items-center gap-1 rounded-full border border-primary/20 bg-primary/5 px-2 py-0.5 text-[10px] text-primary">
|
||||
<span className="flex items-center gap-1 rounded-md border-0 bg-muted px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground">
|
||||
<Globe className="size-2.5" />
|
||||
Public
|
||||
</span>
|
||||
|
|
@ -277,48 +333,55 @@ export function PromptsContent() {
|
|||
{prompt.prompt}
|
||||
</p>
|
||||
{prompt.prompt.length > 100 && (
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
variant="link"
|
||||
onClick={() => setExpandedId(expandedId === prompt.id ? null : prompt.id)}
|
||||
className="mt-1 text-[11px] text-primary hover:underline cursor-pointer"
|
||||
className="mt-1 h-auto cursor-pointer px-0 py-0 text-[11px] text-primary"
|
||||
>
|
||||
{expandedId === prompt.id ? "See less" : "See more"}
|
||||
</button>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="hidden group-hover:flex items-center gap-1 shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
title={prompt.is_public ? "Make private" : "Share with community"}
|
||||
onClick={() => handleTogglePublic(prompt)}
|
||||
disabled={togglingPublicIds.has(prompt.id)}
|
||||
className="flex items-center justify-center size-7 rounded-md text-muted-foreground hover:text-foreground hover:bg-accent transition-colors disabled:opacity-50 disabled:pointer-events-none"
|
||||
>
|
||||
{togglingPublicIds.has(prompt.id) ? (
|
||||
<Spinner className="size-3.5" />
|
||||
) : prompt.is_public ? (
|
||||
<Lock className="size-3.5" />
|
||||
) : (
|
||||
<Globe className="size-3.5" />
|
||||
)}
|
||||
</button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7"
|
||||
onClick={() => handleEdit(prompt)}
|
||||
>
|
||||
<Pencil className="size-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7 text-destructive hover:text-destructive"
|
||||
onClick={() => setDeleteTarget(prompt.id)}
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 shrink-0 self-center rounded-lg text-muted-foreground opacity-100 pointer-events-auto transition-opacity duration-150 hover:text-accent-foreground sm:opacity-0 sm:pointer-events-none sm:group-hover:opacity-100 sm:group-hover:pointer-events-auto"
|
||||
>
|
||||
<MoreHorizontal className="size-3.5" />
|
||||
<span className="sr-only">Prompt actions</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleTogglePublic(prompt)}
|
||||
disabled={togglingPublicIds.has(prompt.id)}
|
||||
>
|
||||
{togglingPublicIds.has(prompt.id) ? (
|
||||
<Spinner className="size-4" />
|
||||
) : prompt.is_public ? (
|
||||
<Lock className="size-4" />
|
||||
) : (
|
||||
<Globe className="size-4" />
|
||||
)}
|
||||
{prompt.is_public ? "Make private" : "Share with community"}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => handleEdit(prompt)}>
|
||||
<Pencil className="size-4" />
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => setDeleteTarget(prompt.id)}
|
||||
className="text-destructive focus:text-destructive"
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,5 @@
|
|||
import { DesktopContent } from "../components/DesktopContent";
|
||||
|
||||
export default function Page() {
|
||||
return <DesktopContent />;
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
import { HotkeysContent } from "../components/HotkeysContent";
|
||||
|
||||
export default function Page() {
|
||||
return <HotkeysContent />;
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
import { MemoryContent } from "../components/MemoryContent";
|
||||
|
||||
export default function Page() {
|
||||
return <MemoryContent />;
|
||||
}
|
||||
|
|
@ -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`);
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
import { ProfileContent } from "../components/ProfileContent";
|
||||
|
||||
export default function Page() {
|
||||
return <ProfileContent />;
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
import { PromptsContent } from "../components/PromptsContent";
|
||||
|
||||
export default function Page() {
|
||||
return <PromptsContent />;
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
import { PurchaseHistoryContent } from "../components/PurchaseHistoryContent";
|
||||
|
||||
export default function Page() {
|
||||
return <PurchaseHistoryContent />;
|
||||
}
|
||||
|
|
@ -3,6 +3,7 @@
|
|||
import { ExternalLink } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { buildIssueUrl } from "@/lib/error-toast";
|
||||
|
||||
export default function DashboardError({
|
||||
|
|
@ -39,13 +40,9 @@ export default function DashboardError({
|
|||
)}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={reset}
|
||||
className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 transition-colors"
|
||||
>
|
||||
<Button type="button" onClick={reset}>
|
||||
Try again
|
||||
</button>
|
||||
</Button>
|
||||
<Link
|
||||
href="/dashboard"
|
||||
className="rounded-md border border-input bg-background px-4 py-2 text-sm font-medium hover:bg-accent hover:text-accent-foreground transition-colors"
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
"use client";
|
||||
|
||||
import { IconBrandGoogleFilled } from "@tabler/icons-react";
|
||||
import { useAtom } from "jotai";
|
||||
import { Crop, Eye, EyeOff, Rocket, RotateCcw, Zap } from "lucide-react";
|
||||
import Image from "next/image";
|
||||
|
|
@ -24,6 +23,34 @@ const isGoogleAuth = AUTH_TYPE === "GOOGLE";
|
|||
type ShortcutKey = "generalAssist" | "quickAsk" | "screenshotAssist";
|
||||
type ShortcutMap = typeof DEFAULT_SHORTCUTS;
|
||||
|
||||
function GoogleGLogo({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
className={className}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 48 48"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
fill="#EA4335"
|
||||
d="M24 9.5c3.54 0 6.71 1.22 9.21 3.6l6.85-6.85C35.9 2.38 30.47 0 24 0 14.62 0 6.51 5.38 2.56 13.22l7.98 6.19C12.43 13.72 17.74 9.5 24 9.5z"
|
||||
/>
|
||||
<path
|
||||
fill="#4285F4"
|
||||
d="M46.98 24.55c0-1.57-.15-3.09-.38-4.55H24v9.02h12.94c-.58 2.96-2.26 5.48-4.78 7.18l7.73 6c4.51-4.18 7.09-10.36 7.09-17.65z"
|
||||
/>
|
||||
<path
|
||||
fill="#FBBC05"
|
||||
d="M10.53 28.59c-.48-1.45-.76-2.99-.76-4.59s.27-3.14.76-4.59l-7.98-6.19C.92 16.46 0 20.12 0 24c0 3.88.92 7.54 2.56 10.78l7.97-6.19z"
|
||||
/>
|
||||
<path
|
||||
fill="#34A853"
|
||||
d="M24 48c6.48 0 11.93-2.13 15.89-5.81l-7.73-6c-2.15 1.45-4.92 2.3-8.16 2.3-6.26 0-11.57-4.22-13.47-9.91l-7.98 6.19C6.51 42.62 14.62 48 24 48z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
const HOTKEY_ROWS: Array<{
|
||||
key: ShortcutKey;
|
||||
label: string;
|
||||
|
|
@ -134,25 +161,26 @@ function HotkeyRow({
|
|||
<RotateCcw className="size-3" />
|
||||
</Button>
|
||||
)}
|
||||
<button
|
||||
<Button
|
||||
ref={inputRef}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
title={recording ? "Press shortcut keys" : "Click to edit shortcut"}
|
||||
onClick={() => setRecording(true)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onBlur={() => setRecording(false)}
|
||||
className={
|
||||
recording
|
||||
? "flex h-7 items-center rounded-md border border-transparent bg-primary/5 outline-none ring-0 focus:outline-none focus-visible:outline-none focus-visible:ring-0"
|
||||
: "flex h-7 cursor-pointer items-center rounded-md border border-transparent bg-transparent outline-none ring-0 transition-colors hover:bg-accent hover:text-accent-foreground focus:outline-none focus-visible:outline-none focus-visible:ring-0"
|
||||
? "h-7 border border-transparent bg-primary/5 px-0 outline-none ring-0 focus:outline-none focus-visible:outline-none focus-visible:ring-0"
|
||||
: "h-7 cursor-pointer border border-transparent bg-transparent px-0 outline-none ring-0 transition-colors hover:bg-accent hover:text-accent-foreground focus:outline-none focus-visible:outline-none focus-visible:ring-0"
|
||||
}
|
||||
>
|
||||
{recording ? (
|
||||
<span className="px-2 text-[9px] text-primary whitespace-nowrap">Press hotkeys...</span>
|
||||
<span className="px-2 text-[9px] text-primary whitespace-nowrap">Press hotkeys</span>
|
||||
) : (
|
||||
<ShortcutKbd keys={displayKeys} className="ml-0 px-1.5 text-foreground/85" />
|
||||
)}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -167,6 +195,7 @@ export default function DesktopLoginPage() {
|
|||
const [password, setPassword] = useState("");
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [loginError, setLoginError] = useState<string | null>(null);
|
||||
const [isGoogleRedirecting, setIsGoogleRedirecting] = useState(false);
|
||||
|
||||
const [shortcuts, setShortcuts] = useState(DEFAULT_SHORTCUTS);
|
||||
const [shortcutsLoaded, setShortcutsLoaded] = useState(false);
|
||||
|
|
@ -208,6 +237,8 @@ export default function DesktopLoginPage() {
|
|||
);
|
||||
|
||||
const handleGoogleLogin = () => {
|
||||
if (isGoogleRedirecting) return;
|
||||
setIsGoogleRedirecting(true);
|
||||
window.location.href = `${BACKEND_URL}/auth/google/authorize-redirect`;
|
||||
};
|
||||
|
||||
|
|
@ -255,8 +286,8 @@ export default function DesktopLoginPage() {
|
|||
};
|
||||
|
||||
return (
|
||||
<div className="relative flex min-h-svh items-center justify-center bg-background p-4 sm:p-6 select-none">
|
||||
<div className="relative flex w-full max-w-md flex-col overflow-hidden bg-card shadow-lg">
|
||||
<div className="relative flex min-h-svh items-center justify-center bg-main-panel p-4 sm:p-6 select-none">
|
||||
<div className="relative flex w-full max-w-md flex-col overflow-hidden bg-main-panel">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col items-center px-6 pt-6 pb-2 text-center">
|
||||
<Image
|
||||
|
|
@ -313,8 +344,13 @@ export default function DesktopLoginPage() {
|
|||
</p> */}
|
||||
|
||||
{isGoogleAuth ? (
|
||||
<Button variant="outline" className="w-full gap-2 h-10" onClick={handleGoogleLogin}>
|
||||
<IconBrandGoogleFilled className="size-4" />
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full gap-2 h-10 bg-white text-[#1f1f1f] border-white hover:bg-zinc-100 hover:text-[#1f1f1f] dark:border-white shadow-sm font-medium"
|
||||
disabled={isGoogleRedirecting}
|
||||
onClick={handleGoogleLogin}
|
||||
>
|
||||
<GoogleGLogo className="size-4" />
|
||||
Continue with Google
|
||||
</Button>
|
||||
) : (
|
||||
|
|
@ -357,10 +393,11 @@ export default function DesktopLoginPage() {
|
|||
disabled={isLoggingIn}
|
||||
className="h-9 pr-9"
|
||||
/>
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => setShowPassword((v) => !v)}
|
||||
className="absolute inset-y-0 right-0 flex items-center pr-2.5 text-muted-foreground hover:text-foreground"
|
||||
className="absolute inset-y-0 right-0 h-auto bg-transparent px-2.5 py-0 text-muted-foreground hover:bg-transparent hover:text-foreground"
|
||||
tabIndex={-1}
|
||||
>
|
||||
{showPassword ? (
|
||||
|
|
@ -368,7 +405,7 @@ export default function DesktopLoginPage() {
|
|||
) : (
|
||||
<Eye className="size-3.5" />
|
||||
)}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -207,13 +207,14 @@ export default function DesktopPermissionsPage() {
|
|||
<Button disabled className="text-sm h-9 min-w-[180px]">
|
||||
Grant permissions to continue
|
||||
</Button>
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
variant="link"
|
||||
onClick={handleSkip}
|
||||
className="block mx-auto text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
className="mx-auto h-auto px-0 py-0 text-xs text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
Skip for now
|
||||
</button>
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
import { ExternalLink } from "lucide-react";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { buildIssueUrl } from "@/lib/error-toast";
|
||||
|
||||
export default function ErrorPage({
|
||||
|
|
@ -37,13 +38,9 @@ export default function ErrorPage({
|
|||
)}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={reset}
|
||||
className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 transition-colors"
|
||||
>
|
||||
<Button type="button" onClick={reset}>
|
||||
Try again
|
||||
</button>
|
||||
</Button>
|
||||
<a
|
||||
href={issueUrl}
|
||||
target="_blank"
|
||||
|
|
|
|||
|
|
@ -18,8 +18,9 @@
|
|||
--foreground: oklch(0.145 0 0);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.145 0 0);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover: oklch(0.99 0 0);
|
||||
--popover-foreground: oklch(0.145 0 0);
|
||||
--popover-border: oklch(0.92 0 0);
|
||||
--primary: oklch(0.205 0 0);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.97 0 0);
|
||||
|
|
@ -39,7 +40,11 @@
|
|||
--chart-4: oklch(0.828 0.189 84.429);
|
||||
--chart-5: oklch(0.769 0.188 70.08);
|
||||
--radius: 0.625rem;
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
/* Unified surface used by the left sidebar, main panel, and right panel. */
|
||||
--panel: oklch(0.96 0 0);
|
||||
/* Distinct (lighter) surface used by the leftmost icon rail. */
|
||||
--rail: oklch(0.985 0 0);
|
||||
--sidebar: var(--panel);
|
||||
--sidebar-foreground: oklch(0.145 0 0);
|
||||
--sidebar-primary: oklch(0.205 0 0);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
|
|
@ -47,7 +52,7 @@
|
|||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||
--sidebar-border: oklch(0.922 0 0);
|
||||
--sidebar-ring: oklch(0.708 0 0);
|
||||
--main-panel: oklch(1 0 0);
|
||||
--main-panel: var(--panel);
|
||||
--syntax-bg: #f5f5f5;
|
||||
--brand: oklch(0.623 0.214 259.815);
|
||||
--highlight: oklch(0.852 0.199 91.936);
|
||||
|
|
@ -58,8 +63,9 @@
|
|||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.145 0 0);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.145 0 0);
|
||||
--popover: oklch(0.32 0 0);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--popover-border: oklch(0.4 0 0);
|
||||
--primary: oklch(0.985 0 0);
|
||||
--primary-foreground: oklch(0.205 0 0);
|
||||
--secondary: oklch(0.269 0 0);
|
||||
|
|
@ -70,23 +76,27 @@
|
|||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--destructive-foreground: oklch(0.985 0 0);
|
||||
--border: oklch(0.269 0 0);
|
||||
--input: oklch(0.269 0 0);
|
||||
--border: oklch(0.32 0 0);
|
||||
--input: oklch(0.32 0 0);
|
||||
--ring: oklch(0.439 0 0);
|
||||
--chart-1: oklch(0.488 0.243 264.376);
|
||||
--chart-2: oklch(0.696 0.17 162.48);
|
||||
--chart-3: oklch(0.769 0.188 70.08);
|
||||
--chart-4: oklch(0.627 0.265 303.9);
|
||||
--chart-5: oklch(0.645 0.246 16.439);
|
||||
--sidebar: oklch(0.205 0 0);
|
||||
/* Unified surface used by the left sidebar, main panel, and right panel. */
|
||||
--panel: oklch(0.24 0 0);
|
||||
/* Distinct (slightly darker) surface used by the leftmost icon rail. */
|
||||
--rail: oklch(0.205 0 0);
|
||||
--sidebar: var(--panel);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.269 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(0.269 0 0);
|
||||
--sidebar-border: oklch(0.32 0 0);
|
||||
--sidebar-ring: oklch(0.439 0 0);
|
||||
--main-panel: oklch(0.18 0 0);
|
||||
--main-panel: var(--panel);
|
||||
--syntax-bg: #1e1e1e;
|
||||
--brand: oklch(0.707 0.165 254.624);
|
||||
--highlight: oklch(0.852 0.199 91.936);
|
||||
|
|
@ -99,6 +109,7 @@
|
|||
--color-card-foreground: var(--card-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-popover-border: var(--popover-border);
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
|
|
@ -118,6 +129,8 @@
|
|||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--color-main-panel: var(--main-panel);
|
||||
--color-panel: var(--panel);
|
||||
--color-rail: var(--rail);
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
|
|
|
|||
|
|
@ -137,7 +137,7 @@ export default function RootLayout({
|
|||
<WebSiteJsonLd />
|
||||
<SoftwareApplicationJsonLd />
|
||||
</head>
|
||||
<body className={cn(roboto.className, "bg-white dark:bg-black antialiased h-full w-full ")}>
|
||||
<body className={cn(roboto.className, "bg-main-panel antialiased h-full w-full ")}>
|
||||
<PostHogProvider>
|
||||
<LocaleProvider>
|
||||
<I18nProvider>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { atom } from "jotai";
|
||||
import { atomWithQuery } from "jotai-tanstack-query";
|
||||
import { type AgentToolInfo, agentToolsApiService } from "@/lib/apis/agent-tools-api.service";
|
||||
import { agentToolsApiService } from "@/lib/apis/agent-tools-api.service";
|
||||
import { cacheKeys } from "@/lib/query-client/cache-keys";
|
||||
import { activeSearchSpaceIdAtom } from "../search-spaces/search-space-query.atoms";
|
||||
|
||||
|
|
|
|||
19
surfsense_web/atoms/agent/action-log-dialog.atom.ts
Normal file
19
surfsense_web/atoms/agent/action-log-dialog.atom.ts
Normal 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 });
|
||||
});
|
||||
|
|
@ -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 });
|
||||
});
|
||||
3
surfsense_web/atoms/layout/dialogs.atom.ts
Normal file
3
surfsense_web/atoms/layout/dialogs.atom.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
import { atom } from "jotai";
|
||||
|
||||
export const announcementsDialogAtom = atom<boolean>(false);
|
||||
|
|
@ -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);
|
||||
|
|
@ -36,14 +36,16 @@ const initialState: TabsState = {
|
|||
// Prevent race conditions where route-sync recreates a just-deleted chat tab.
|
||||
const deletedChatIdsAtom = atom<Set<number>>(new Set<number>());
|
||||
|
||||
const sessionStorageAdapter = createJSONStorage<TabsState>(
|
||||
() => (typeof window !== "undefined" ? sessionStorage : undefined) as Storage
|
||||
// Persist tabs in localStorage so they survive a hard refresh and let the user
|
||||
// keep tabs open across multiple search spaces (browser-like behavior).
|
||||
const localStorageAdapter = createJSONStorage<TabsState>(
|
||||
() => (typeof window !== "undefined" ? localStorage : undefined) as Storage
|
||||
);
|
||||
|
||||
export const tabsStateAtom = atomWithStorage<TabsState>(
|
||||
"surfsense:tabs",
|
||||
initialState,
|
||||
sessionStorageAdapter,
|
||||
localStorageAdapter,
|
||||
{ getOnInit: true }
|
||||
);
|
||||
|
||||
|
|
@ -72,7 +74,17 @@ export const syncChatTabAtom = atom(
|
|||
(
|
||||
get,
|
||||
set,
|
||||
{ chatId, title, chatUrl }: { chatId: number | null; title?: string; chatUrl?: string }
|
||||
{
|
||||
chatId,
|
||||
title,
|
||||
chatUrl,
|
||||
searchSpaceId,
|
||||
}: {
|
||||
chatId: number | null;
|
||||
title?: string;
|
||||
chatUrl?: string;
|
||||
searchSpaceId: number;
|
||||
}
|
||||
) => {
|
||||
if (chatId && get(deletedChatIdsAtom).has(chatId)) {
|
||||
return;
|
||||
|
|
@ -87,20 +99,32 @@ export const syncChatTabAtom = atom(
|
|||
...state,
|
||||
activeTabId: tabId,
|
||||
tabs: state.tabs.map((t) =>
|
||||
t.id === tabId ? { ...t, title: title || t.title, chatUrl: chatUrl || t.chatUrl } : t
|
||||
t.id === tabId
|
||||
? {
|
||||
...t,
|
||||
title: title || t.title,
|
||||
chatUrl: chatUrl || t.chatUrl,
|
||||
searchSpaceId: searchSpaceId ?? t.searchSpaceId,
|
||||
}
|
||||
: t
|
||||
),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// If navigating to a new chat (no chatId), ensure there's a "new chat" tab
|
||||
// scoped to the current search space.
|
||||
if (!chatId) {
|
||||
const hasNewChatTab = state.tabs.some((t) => t.id === "chat-new");
|
||||
if (hasNewChatTab) {
|
||||
set(tabsStateAtom, { ...state, activeTabId: "chat-new" });
|
||||
set(tabsStateAtom, {
|
||||
...state,
|
||||
activeTabId: "chat-new",
|
||||
tabs: state.tabs.map((t) => (t.id === "chat-new" ? { ...t, searchSpaceId, chatUrl } : t)),
|
||||
});
|
||||
} else {
|
||||
set(tabsStateAtom, {
|
||||
tabs: [...state.tabs, INITIAL_CHAT_TAB],
|
||||
tabs: [...state.tabs, { ...INITIAL_CHAT_TAB, searchSpaceId, chatUrl }],
|
||||
activeTabId: "chat-new",
|
||||
});
|
||||
}
|
||||
|
|
@ -112,9 +136,10 @@ export const syncChatTabAtom = atom(
|
|||
const newTab: Tab = {
|
||||
id: tabId,
|
||||
type: "chat",
|
||||
title: title || `Chat ${chatId}`,
|
||||
title: title || "New Chat",
|
||||
chatId,
|
||||
chatUrl,
|
||||
searchSpaceId,
|
||||
};
|
||||
|
||||
let updatedTabs: Tab[];
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
"use client";
|
||||
|
||||
import { useAtomValue, useSetAtom } from "jotai";
|
||||
import { Activity } from "lucide-react";
|
||||
import { Workflow } from "lucide-react";
|
||||
import { useCallback } from "react";
|
||||
import { openActionLogSheetAtom } from "@/atoms/agent/action-log-sheet.atom";
|
||||
import { openActionLogDialogAtom } from "@/atoms/agent/action-log-dialog.atom";
|
||||
import { agentFlagsAtom } from "@/atoms/agent/agent-flags-query.atom";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
|
|
@ -13,7 +13,7 @@ interface ActionLogButtonProps {
|
|||
}
|
||||
|
||||
/**
|
||||
* Header button that opens the agent action log sheet for the current
|
||||
* Header button that opens the agent action log dialog for the current
|
||||
* thread. Renders nothing when:
|
||||
* - the action log feature flag is off (graceful no-op for older
|
||||
* deployments), OR
|
||||
|
|
@ -21,7 +21,7 @@ interface ActionLogButtonProps {
|
|||
*/
|
||||
export function ActionLogButton({ threadId }: ActionLogButtonProps) {
|
||||
const { data: flags } = useAtomValue(agentFlagsAtom);
|
||||
const open = useSetAtom(openActionLogSheetAtom);
|
||||
const open = useSetAtom(openActionLogDialogAtom);
|
||||
|
||||
const enabled = !!flags?.enable_action_log && !flags?.disable_new_agent_stack;
|
||||
|
||||
|
|
@ -41,7 +41,7 @@ export function ActionLogButton({ threadId }: ActionLogButtonProps) {
|
|||
aria-label="Open agent action log"
|
||||
onClick={handleClick}
|
||||
>
|
||||
<Activity className="size-4" />
|
||||
<Workflow className="size-4 text-muted-foreground" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Agent actions</TooltipContent>
|
||||
|
|
|
|||
|
|
@ -2,35 +2,25 @@
|
|||
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useAtom, useAtomValue } from "jotai";
|
||||
import { Activity, RefreshCcw } from "lucide-react";
|
||||
import { RefreshCcw, Workflow } from "lucide-react";
|
||||
import { useCallback } from "react";
|
||||
import { actionLogSheetAtom } from "@/atoms/agent/action-log-sheet.atom";
|
||||
import { actionLogDialogAtom } from "@/atoms/agent/action-log-dialog.atom";
|
||||
import { agentFlagsAtom } from "@/atoms/agent/agent-flags-query.atom";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from "@/components/ui/sheet";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { agentActionsQueryKey, useAgentActionsQuery } from "@/hooks/use-agent-actions-query";
|
||||
import { ActionLogItem } from "./action-log-item";
|
||||
|
||||
function EmptyState() {
|
||||
return (
|
||||
<div className="flex flex-1 flex-col items-center justify-center gap-3 px-6 text-center">
|
||||
<div className="flex size-12 items-center justify-center rounded-full bg-muted">
|
||||
<Activity className="size-5 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-sm font-medium">No actions logged yet</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Once the agent calls a tool in this thread, it will show up here. From the log you can
|
||||
inspect arguments and revert reversible actions.
|
||||
<div className="flex flex-1 flex-col items-center justify-center gap-4 px-6 pb-12 text-center">
|
||||
<div className="flex max-w-[260px] flex-col gap-1.5">
|
||||
<p className="text-sm font-semibold tracking-tight">No actions logged yet</p>
|
||||
<p className="text-xs leading-relaxed text-muted-foreground">
|
||||
A complete audit trail of every tool the agent uses in this thread will appear here
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -39,15 +29,15 @@ function EmptyState() {
|
|||
|
||||
function DisabledState() {
|
||||
return (
|
||||
<div className="flex flex-1 flex-col items-center justify-center gap-3 px-6 text-center">
|
||||
<div className="flex size-12 items-center justify-center rounded-full bg-muted">
|
||||
<Activity className="size-5 text-muted-foreground" />
|
||||
<div className="flex flex-1 flex-col items-center justify-center gap-4 px-6 pb-12 text-center">
|
||||
<div className="flex size-12 items-center justify-center rounded-full border border-popover-border bg-muted/40">
|
||||
<Workflow className="size-5 text-muted-foreground" strokeWidth={1.75} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-sm font-medium">Action log is disabled</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
This deployment hasn't enabled the agent action log. An admin can flip
|
||||
<code className="ml-1 rounded bg-muted px-1 text-[10px]">
|
||||
<div className="flex max-w-[280px] flex-col gap-1.5">
|
||||
<p className="text-sm font-semibold tracking-tight">Action log is disabled</p>
|
||||
<p className="text-xs leading-relaxed text-muted-foreground">
|
||||
This deployment hasn't enabled the agent action log. An admin can enable{" "}
|
||||
<code className="rounded bg-muted px-1 py-0.5 font-mono text-[10px] text-foreground">
|
||||
SURFSENSE_ENABLE_ACTION_LOG
|
||||
</code>
|
||||
.
|
||||
|
|
@ -69,13 +59,12 @@ function LoadingState() {
|
|||
);
|
||||
}
|
||||
|
||||
export function ActionLogSheet() {
|
||||
const [state, setState] = useAtom(actionLogSheetAtom);
|
||||
export function ActionLogDialog() {
|
||||
const [state, setState] = useAtom(actionLogDialogAtom);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data: flags } = useAtomValue(agentFlagsAtom);
|
||||
const actionLogEnabled = !!flags?.enable_action_log && !flags?.disable_new_agent_stack;
|
||||
const revertEnabled = !!flags?.enable_revert_route && !flags?.disable_new_agent_stack;
|
||||
|
||||
const threadId = state.threadId;
|
||||
|
||||
|
|
@ -84,6 +73,13 @@ export function ActionLogSheet() {
|
|||
{ enabled: state.open && actionLogEnabled }
|
||||
);
|
||||
|
||||
const handleOpenChange = useCallback(
|
||||
(open: boolean) => {
|
||||
setState((current) => (open ? { ...current, open } : { open: false, threadId: null }));
|
||||
},
|
||||
[setState]
|
||||
);
|
||||
|
||||
const handleRevertSuccess = useCallback(() => {
|
||||
if (threadId !== null) {
|
||||
queryClient.invalidateQueries({ queryKey: agentActionsQueryKey(threadId) });
|
||||
|
|
@ -91,42 +87,33 @@ export function ActionLogSheet() {
|
|||
}, [queryClient, threadId]);
|
||||
|
||||
return (
|
||||
<Sheet open={state.open} onOpenChange={(open) => setState((s) => ({ ...s, open }))}>
|
||||
<SheetContent
|
||||
side="right"
|
||||
className="flex h-full w-full flex-col gap-0 overflow-hidden p-0 sm:max-w-md"
|
||||
>
|
||||
<SheetHeader className="shrink-0 border-b px-4 py-4">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Activity className="size-4 text-muted-foreground" />
|
||||
<SheetTitle className="text-base font-semibold">Agent actions</SheetTitle>
|
||||
{data?.total !== undefined && data.total > 0 && (
|
||||
<Badge variant="secondary" className="text-[10px]">
|
||||
{data.total}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => refetch()}
|
||||
disabled={isFetching || !actionLogEnabled}
|
||||
className="size-8 p-0"
|
||||
aria-label="Refresh action log"
|
||||
>
|
||||
<RefreshCcw className={isFetching ? "size-3.5 animate-spin" : "size-3.5"} />
|
||||
</Button>
|
||||
<Dialog open={state.open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent className="select-none flex h-[90vh] max-h-[640px] w-[95vw] max-w-[900px] flex-col gap-0 overflow-hidden p-0 [--card:var(--popover)] md:h-[80vh]">
|
||||
<div className="shrink-0 px-6 pb-3 pt-6 pr-28">
|
||||
<div className="flex items-center gap-2">
|
||||
<DialogTitle className="text-lg font-semibold">Agent actions</DialogTitle>
|
||||
{data?.total !== undefined && data.total > 0 ? (
|
||||
<Badge variant="secondary" className="text-[10px]">
|
||||
{data.total}
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
<SheetDescription className="text-xs text-muted-foreground">
|
||||
<DialogDescription className="sr-only">
|
||||
Audit trail of every tool call the agent made in this thread.
|
||||
{revertEnabled
|
||||
? " Reversible actions can be undone in place."
|
||||
: " Reverts are read-only on this deployment."}
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
</DialogDescription>
|
||||
<Separator className="mt-4" />
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => refetch()}
|
||||
disabled={isFetching || !actionLogEnabled}
|
||||
className="absolute right-14 top-4 size-8 rounded-full p-0 text-muted-foreground hover:bg-accent hover:text-accent-foreground"
|
||||
aria-label="Refresh action log"
|
||||
>
|
||||
<RefreshCcw className={isFetching ? "size-3.5 animate-spin" : "size-3.5"} />
|
||||
</Button>
|
||||
|
||||
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto scrollbar-thin">
|
||||
{!actionLogEnabled ? (
|
||||
|
|
@ -148,7 +135,7 @@ export function ActionLogSheet() {
|
|||
) : items.length === 0 ? (
|
||||
<EmptyState />
|
||||
) : (
|
||||
<div className="flex flex-col gap-2 p-3">
|
||||
<div className="flex flex-col gap-2 px-4 pb-4">
|
||||
{items.map((action) => (
|
||||
<ActionLogItem
|
||||
key={action.id}
|
||||
|
|
@ -157,15 +144,15 @@ export function ActionLogSheet() {
|
|||
onRevertSuccess={handleRevertSuccess}
|
||||
/>
|
||||
))}
|
||||
{data?.has_more && (
|
||||
{data?.has_more ? (
|
||||
<p className="py-2 text-center text-[11px] text-muted-foreground">
|
||||
Showing {items.length} of {data.total}. Older actions are paginated.
|
||||
</p>
|
||||
)}
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { ChevronRight, RotateCcw, ShieldOff, Undo2 } from "lucide-react";
|
||||
import { Check, ChevronRight, Copy, RotateCcw, Undo2 } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
|
|
@ -16,7 +16,6 @@ import {
|
|||
} from "@/components/ui/alert-dialog";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { getToolDisplayName, getToolIcon } from "@/contracts/enums/toolIcons";
|
||||
import { type AgentAction, agentActionsApiService } from "@/lib/apis/agent-actions-api.service";
|
||||
import { AppError } from "@/lib/error";
|
||||
|
|
@ -29,10 +28,55 @@ interface ActionLogItemProps {
|
|||
onRevertSuccess: () => void;
|
||||
}
|
||||
|
||||
function formatPrimitiveValue(value: unknown) {
|
||||
if (value === null) return "null";
|
||||
if (value === undefined) return "undefined";
|
||||
if (typeof value === "string") return value;
|
||||
if (typeof value === "number" || typeof value === "boolean") return String(value);
|
||||
return JSON.stringify(value, null, 2);
|
||||
}
|
||||
|
||||
function ArgumentValue({ value }: { value: unknown }) {
|
||||
const formatted = formatPrimitiveValue(value);
|
||||
const isBlockValue =
|
||||
typeof value === "object" ||
|
||||
(typeof value === "string" && (value.includes("\n") || value.length > 120));
|
||||
|
||||
if (isBlockValue) {
|
||||
return (
|
||||
<pre className="mt-2 whitespace-pre-wrap break-words bg-popover px-4 py-3 text-[11px] leading-relaxed text-popover-foreground/80">
|
||||
{formatted}
|
||||
</pre>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<p className="mt-1 break-words font-mono text-[11px] leading-relaxed text-popover-foreground/80">
|
||||
{formatted}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
function StructuredArguments({ args }: { args: Record<string, unknown> }) {
|
||||
return (
|
||||
<div className="divide-y divide-popover-border border-t border-popover-border">
|
||||
{Object.entries(args).map(([key, value]) => (
|
||||
<div key={key} className="bg-popover">
|
||||
<div className="px-4 py-3">
|
||||
<p className="font-mono text-[10px] font-medium text-muted-foreground">{key}</p>
|
||||
<ArgumentValue value={value} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ActionLogItem({ action, threadId, onRevertSuccess }: ActionLogItemProps) {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const [isReverting, setIsReverting] = useState(false);
|
||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||
const [copiedSection, setCopiedSection] = useState<"arguments" | null>(null);
|
||||
|
||||
const isAlreadyReverted = action.reverted_by_action_id !== null;
|
||||
const isRevertAction = action.is_revert_action;
|
||||
|
|
@ -42,11 +86,22 @@ export function ActionLogItem({ action, threadId, onRevertSuccess }: ActionLogIt
|
|||
const displayName = getToolDisplayName(action.tool_name);
|
||||
|
||||
const argsPreview = action.args ? JSON.stringify(action.args, null, 2) : null;
|
||||
const truncatedArgs =
|
||||
argsPreview && argsPreview.length > 600 ? `${argsPreview.slice(0, 600)}…` : argsPreview;
|
||||
|
||||
const canRevert = action.reversible && !isAlreadyReverted && !isRevertAction && !hasError;
|
||||
|
||||
const handleCopyArguments = async () => {
|
||||
if (!argsPreview) return;
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(argsPreview);
|
||||
setCopiedSection("arguments");
|
||||
toast.success("Arguments copied");
|
||||
window.setTimeout(() => setCopiedSection(null), 1200);
|
||||
} catch {
|
||||
toast.error("Failed to copy arguments.");
|
||||
}
|
||||
};
|
||||
|
||||
const handleRevert = async () => {
|
||||
setIsReverting(true);
|
||||
try {
|
||||
|
|
@ -70,17 +125,18 @@ export function ActionLogItem({ action, threadId, onRevertSuccess }: ActionLogIt
|
|||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-lg border bg-card transition-colors",
|
||||
"overflow-hidden rounded-lg border border-popover-border bg-popover text-popover-foreground transition-colors",
|
||||
isAlreadyReverted && "opacity-70"
|
||||
)}
|
||||
>
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => setIsExpanded((v) => !v)}
|
||||
className="flex w-full items-start gap-3 p-3 text-left hover:bg-muted/40"
|
||||
className="h-auto w-full items-start justify-start gap-3 rounded-none p-3 text-left hover:bg-accent hover:text-accent-foreground"
|
||||
aria-expanded={isExpanded}
|
||||
>
|
||||
<div className="flex size-8 shrink-0 items-center justify-center rounded-md bg-muted">
|
||||
<div className="flex size-8 shrink-0 items-center justify-center rounded-md bg-accent">
|
||||
{isRevertAction ? (
|
||||
<Undo2 className="size-4 text-muted-foreground" />
|
||||
) : (
|
||||
|
|
@ -101,7 +157,10 @@ export function ActionLogItem({ action, threadId, onRevertSuccess }: ActionLogIt
|
|||
</Badge>
|
||||
)}
|
||||
{!isRevertAction && action.reversible && !isAlreadyReverted && (
|
||||
<Badge variant="outline" className="text-[10px]">
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="border-0 bg-neutral-200 px-1.5 py-0.5 text-[10px] text-neutral-700 dark:bg-neutral-700 dark:text-neutral-200"
|
||||
>
|
||||
Reversible
|
||||
</Badge>
|
||||
)}
|
||||
|
|
@ -115,55 +174,67 @@ export function ActionLogItem({ action, threadId, onRevertSuccess }: ActionLogIt
|
|||
</div>
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
"size-4 shrink-0 text-muted-foreground transition-transform",
|
||||
"size-4 shrink-0 self-center text-muted-foreground transition-transform",
|
||||
isExpanded && "rotate-90"
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
</Button>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="flex flex-col gap-3 border-t bg-muted/20 p-3">
|
||||
{truncatedArgs && (
|
||||
<div>
|
||||
<p className="mb-1 text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Arguments
|
||||
</p>
|
||||
<pre className="max-h-48 overflow-auto rounded-md bg-background p-2 text-[11px] text-foreground/80">
|
||||
{truncatedArgs}
|
||||
</pre>
|
||||
<div className="flex flex-col border-t border-popover-border bg-accent/80">
|
||||
{action.args && argsPreview && (
|
||||
<div className="border-b border-popover-border">
|
||||
<div className="flex items-center justify-between px-4 py-2">
|
||||
<p className="text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Arguments
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={handleCopyArguments}
|
||||
className="size-6 rounded-lg p-0 text-muted-foreground hover:bg-popover hover:text-popover-foreground"
|
||||
aria-label={copiedSection === "arguments" ? "Arguments copied" : "Copy arguments"}
|
||||
>
|
||||
{copiedSection === "arguments" ? (
|
||||
<Check className="size-3" />
|
||||
) : (
|
||||
<Copy className="size-3" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
<StructuredArguments args={action.args} />
|
||||
</div>
|
||||
)}
|
||||
{action.error && (
|
||||
<div>
|
||||
<p className="mb-1 text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
|
||||
<div className="border-b border-popover-border">
|
||||
<p className="px-4 py-2 text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Error
|
||||
</p>
|
||||
<pre className="max-h-32 overflow-auto rounded-md bg-destructive/10 p-2 text-[11px] text-destructive">
|
||||
<pre className="max-h-32 overflow-auto border-t border-popover-border bg-destructive/10 px-4 py-3 text-[11px] text-destructive">
|
||||
{JSON.stringify(action.error, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
{action.reverse_descriptor && (
|
||||
<div>
|
||||
<p className="mb-1 text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
|
||||
<div className="border-b border-popover-border">
|
||||
<p className="px-4 py-2 text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Reverse plan
|
||||
</p>
|
||||
<pre className="max-h-32 overflow-auto rounded-md bg-background p-2 text-[11px] text-foreground/80">
|
||||
<pre className="max-h-32 overflow-auto border-t border-popover-border bg-popover px-4 py-3 text-[11px] text-popover-foreground/80">
|
||||
{JSON.stringify(action.reverse_descriptor, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center justify-between px-4 py-3">
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
Action ID: <span className="font-mono">{action.id}</span>
|
||||
</p>
|
||||
{canRevert ? (
|
||||
<AlertDialog open={confirmOpen} onOpenChange={setConfirmOpen}>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button size="sm" variant="outline" className="gap-1.5">
|
||||
<Button size="sm" variant="secondary" className="gap-1.5">
|
||||
<RotateCcw className="size-3.5" />
|
||||
Revert
|
||||
</Button>
|
||||
|
|
@ -185,6 +256,7 @@ export function ActionLogItem({ action, threadId, onRevertSuccess }: ActionLogIt
|
|||
handleRevert();
|
||||
}}
|
||||
disabled={isReverting}
|
||||
className="bg-secondary text-secondary-foreground hover:bg-secondary/80 focus-visible:ring-0"
|
||||
>
|
||||
{isReverting ? "Reverting…" : "Revert"}
|
||||
</AlertDialogAction>
|
||||
|
|
@ -193,7 +265,6 @@ export function ActionLogItem({ action, threadId, onRevertSuccess }: ActionLogIt
|
|||
</AlertDialog>
|
||||
) : (
|
||||
<div className="flex items-center gap-1.5 text-[11px] text-muted-foreground">
|
||||
<ShieldOff className="size-3.5" />
|
||||
{isAlreadyReverted
|
||||
? "Already reverted"
|
||||
: isRevertAction
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -2,13 +2,13 @@ import { BellOff } from "lucide-react";
|
|||
|
||||
export function AnnouncementsEmptyState() {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-center">
|
||||
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-muted mb-4">
|
||||
<BellOff className="h-7 w-7 text-muted-foreground" />
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<div className="mb-3 flex h-12 w-12 items-center justify-center rounded-full bg-muted">
|
||||
<BellOff className="h-5 w-5 text-muted-foreground" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold">No announcements</h3>
|
||||
<p className="mt-1 text-sm text-muted-foreground max-w-sm">
|
||||
You're all caught up! New announcements will appear here.
|
||||
<h3 className="text-sm font-semibold">Nothing new yet</h3>
|
||||
<p className="mt-1 max-w-xs text-xs text-muted-foreground">
|
||||
You're all caught up! New updates will appear here.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -24,8 +24,6 @@ import dynamic from "next/dynamic";
|
|||
import type { FC } from "react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { commentsEnabledAtom, targetCommentIdAtom } from "@/atoms/chat/current-thread.atom";
|
||||
import { tryGetHostname } from "@/lib/url";
|
||||
|
||||
import {
|
||||
globalNewLLMConfigsAtom,
|
||||
newLLMConfigsAtom,
|
||||
|
|
@ -60,6 +58,7 @@ import { useComments } from "@/hooks/use-comments";
|
|||
import { useMediaQuery } from "@/hooks/use-media-query";
|
||||
import { useElectronAPI } from "@/hooks/use-platform";
|
||||
import { getProviderIcon } from "@/lib/provider-icons";
|
||||
import { tryGetHostname } from "@/lib/url";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// Captured once at module load — survives client-side navigations that strip the query param.
|
||||
|
|
@ -138,14 +137,15 @@ const MobileCitationDrawer: FC = () => {
|
|||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => setOpen(true)}
|
||||
className={cn(
|
||||
"isolate inline-flex cursor-pointer items-center gap-2 rounded-lg px-3 py-2",
|
||||
"isolate h-auto cursor-pointer gap-2 rounded-lg px-3 py-2",
|
||||
"bg-muted/40 outline-none",
|
||||
"transition-colors duration-150",
|
||||
"hover:bg-muted/70",
|
||||
"hover:bg-accent hover:text-accent-foreground",
|
||||
"focus-visible:ring-ring focus-visible:ring-2"
|
||||
)}
|
||||
>
|
||||
|
|
@ -188,7 +188,7 @@ const MobileCitationDrawer: FC = () => {
|
|||
<span className="text-muted-foreground text-sm tabular-nums">
|
||||
{citations.length} source{citations.length !== 1 && "s"}
|
||||
</span>
|
||||
</button>
|
||||
</Button>
|
||||
|
||||
<Drawer open={open} onOpenChange={setOpen}>
|
||||
<DrawerContent className="max-h-[85vh] flex flex-col">
|
||||
|
|
@ -198,11 +198,12 @@ const MobileCitationDrawer: FC = () => {
|
|||
</DrawerHeader>
|
||||
<div className="overflow-y-auto flex-1 min-h-0 px-1 pb-6">
|
||||
{citations.map((citation) => (
|
||||
<button
|
||||
<Button
|
||||
key={citation.id}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => handleNavigate(citation)}
|
||||
className="group flex w-full items-center gap-2.5 rounded-md px-3 py-2.5 text-left transition-colors hover:bg-muted focus-visible:bg-muted focus-visible:outline-none"
|
||||
className="group h-auto w-full justify-start gap-2.5 px-3 py-2.5 text-left hover:bg-accent hover:text-accent-foreground focus-visible:bg-muted"
|
||||
>
|
||||
{citation.favicon ? (
|
||||
// biome-ignore lint/performance/noImgElement: external favicon from arbitrary domain
|
||||
|
|
@ -224,7 +225,7 @@ const MobileCitationDrawer: FC = () => {
|
|||
<p className="text-muted-foreground truncate text-xs">{citation.domain}</p>
|
||||
</div>
|
||||
<ExternalLink className="text-muted-foreground size-3.5 shrink-0 opacity-0 transition-opacity group-hover:opacity-100" />
|
||||
</button>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</DrawerContent>
|
||||
|
|
@ -266,7 +267,7 @@ function formatTurnCost(micros: number): string {
|
|||
return "$0";
|
||||
}
|
||||
|
||||
const MessageInfoDropdown: FC = () => {
|
||||
const MessageInfoDropdown: FC<{ chatTurnId: string | null | undefined }> = ({ chatTurnId }) => {
|
||||
const messageId = useAuiState(({ message }) => message?.id);
|
||||
const createdAt = useAuiState(({ message }) => message?.createdAt);
|
||||
const usage = useTokenUsage(messageId);
|
||||
|
|
@ -305,7 +306,7 @@ const MessageInfoDropdown: FC = () => {
|
|||
</ActionBarMorePrimitive.Trigger>
|
||||
<ActionBarMorePrimitive.Content
|
||||
align="start"
|
||||
className="bg-muted text-popover-foreground z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[180px] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border dark:border-neutral-700 p-1 shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2"
|
||||
className="bg-popover text-popover-foreground z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[180px] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md p-1 shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2"
|
||||
>
|
||||
{createdAt && (
|
||||
<DropdownMenuLabel className="text-xs text-muted-foreground font-normal select-none">
|
||||
|
|
@ -314,7 +315,7 @@ const MessageInfoDropdown: FC = () => {
|
|||
)}
|
||||
{hasUsage && (
|
||||
<>
|
||||
<ActionBarMorePrimitive.Separator className="bg-border mx-2 my-1 h-px" />
|
||||
<ActionBarMorePrimitive.Separator className="bg-popover-border mx-1 my-1 h-px" />
|
||||
{models.length > 0 ? (
|
||||
models.map(([model, counts]) => {
|
||||
const { name, icon } = resolveModel(model);
|
||||
|
|
@ -322,7 +323,7 @@ const MessageInfoDropdown: FC = () => {
|
|||
return (
|
||||
<ActionBarMorePrimitive.Item
|
||||
key={model}
|
||||
className="focus:bg-neutral-200 dark:focus:bg-neutral-700 relative flex cursor-default flex-col items-start gap-0.5 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none"
|
||||
className="focus:bg-accent focus:text-accent-foreground relative flex cursor-default flex-col items-start gap-0.5 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none"
|
||||
onSelect={(e) => e.preventDefault()}
|
||||
>
|
||||
<span className="flex items-center gap-1.5 text-xs font-medium">
|
||||
|
|
@ -338,7 +339,7 @@ const MessageInfoDropdown: FC = () => {
|
|||
})
|
||||
) : (
|
||||
<ActionBarMorePrimitive.Item
|
||||
className="focus:bg-neutral-200 dark:focus:bg-neutral-700 relative flex cursor-default flex-col items-start gap-0.5 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none"
|
||||
className="focus:bg-accent focus:text-accent-foreground relative flex cursor-default flex-col items-start gap-0.5 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none"
|
||||
onSelect={(e) => e.preventDefault()}
|
||||
>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
|
|
@ -351,6 +352,7 @@ const MessageInfoDropdown: FC = () => {
|
|||
)}
|
||||
</>
|
||||
)}
|
||||
<RevertTurnButton chatTurnId={chatTurnId} variant="menu-item" />
|
||||
</ActionBarMorePrimitive.Content>
|
||||
</ActionBarMorePrimitive.Root>
|
||||
);
|
||||
|
|
@ -500,9 +502,10 @@ export const AssistantMessage: FC = () => {
|
|||
>
|
||||
{/* Fixed trigger slot prevents any vertical reflow when visibility changes */}
|
||||
<div className="mr-2 mb-1 flex h-7 justify-end">
|
||||
<button
|
||||
<Button
|
||||
ref={isDesktop ? commentTriggerRef : undefined}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={
|
||||
showCommentTrigger
|
||||
? isDesktop
|
||||
|
|
@ -513,14 +516,14 @@ export const AssistantMessage: FC = () => {
|
|||
aria-hidden={!showCommentTrigger}
|
||||
tabIndex={showCommentTrigger ? 0 : -1}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 rounded-full px-3 py-1 text-sm transition-colors",
|
||||
"h-auto gap-1.5 rounded-full px-3 py-1 text-sm transition-colors",
|
||||
"opacity-0 pointer-events-none",
|
||||
showCommentTrigger && "opacity-100 pointer-events-auto",
|
||||
isDesktop && isInlineOpen
|
||||
? "bg-primary/10 text-primary"
|
||||
: hasComments
|
||||
? "text-primary hover:bg-primary/10"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-muted"
|
||||
: "text-muted-foreground hover:text-accent-foreground hover:bg-accent hover:text-accent-foreground"
|
||||
)}
|
||||
>
|
||||
<MessageCircleReply className={cn("size-3.5", hasComments && "fill-current")} />
|
||||
|
|
@ -531,7 +534,7 @@ export const AssistantMessage: FC = () => {
|
|||
) : (
|
||||
<span>Add comment</span>
|
||||
)}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Desktop floating comment panel — overlays on top of chat content */}
|
||||
|
|
@ -582,7 +585,7 @@ const AssistantActionBar: FC = () => {
|
|||
className="aui-assistant-action-bar-root -ml-1 col-start-3 row-start-2 flex gap-1 text-muted-foreground md:data-floating:absolute md:data-floating:rounded-md md:data-floating:p-1 [&>button]:opacity-100 md:[&>button]:opacity-[var(--aui-button-opacity,1)]"
|
||||
>
|
||||
<ActionBarPrimitive.Copy asChild>
|
||||
<TooltipIconButton tooltip="Copy to clipboard">
|
||||
<TooltipIconButton tooltip="Copy">
|
||||
<AuiIf condition={({ message }) => message.isCopied}>
|
||||
<CheckIcon />
|
||||
</AuiIf>
|
||||
|
|
@ -614,10 +617,7 @@ const AssistantActionBar: FC = () => {
|
|||
<ClipboardPaste />
|
||||
</TooltipIconButton>
|
||||
)}
|
||||
<MessageInfoDropdown />
|
||||
<div className="ml-auto">
|
||||
<RevertTurnButton chatTurnId={chatTurnId} />
|
||||
</div>
|
||||
<MessageInfoDropdown chatTurnId={chatTurnId} />
|
||||
</ActionBarPrimitive.Root>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -9,8 +9,7 @@ const ChatScrollToBottom: FC = () => (
|
|||
<ThreadPrimitive.ScrollToBottom asChild>
|
||||
<TooltipIconButton
|
||||
tooltip="Scroll to bottom"
|
||||
variant="outline"
|
||||
className="aui-thread-scroll-to-bottom -top-12 absolute z-10 self-center rounded-full p-4 disabled:invisible dark:bg-main-panel dark:hover:bg-accent"
|
||||
className="aui-thread-scroll-to-bottom -top-12 absolute z-10 self-center rounded-full border-0 bg-muted p-4 text-foreground hover:bg-accent hover:text-accent-foreground disabled:invisible"
|
||||
>
|
||||
<ArrowDownIcon />
|
||||
</TooltipIconButton>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
"use client";
|
||||
|
||||
import { useAtomValue, useSetAtom } from "jotai";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { AlertTriangle, Settings } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { forwardRef, useEffect, useImperativeHandle, useMemo, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { statusInboxItemsAtom } from "@/atoms/inbox/status-inbox.atom";
|
||||
|
|
@ -10,7 +11,6 @@ import {
|
|||
llmPreferencesAtom,
|
||||
} from "@/atoms/new-llm-config/new-llm-config-query.atoms";
|
||||
import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms";
|
||||
import { searchSpaceSettingsDialogAtom } from "@/atoms/settings/settings-dialog.atoms";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
|
||||
|
|
@ -44,8 +44,8 @@ interface ConnectorIndicatorProps {
|
|||
|
||||
export const ConnectorIndicator = forwardRef<ConnectorIndicatorHandle, ConnectorIndicatorProps>(
|
||||
(_props, ref) => {
|
||||
const router = useRouter();
|
||||
const searchSpaceId = useAtomValue(activeSearchSpaceIdAtom);
|
||||
const setSearchSpaceSettingsDialog = useSetAtom(searchSpaceSettingsDialogAtom);
|
||||
const { data: preferences = {}, isFetching: preferencesLoading } =
|
||||
useAtomValue(llmPreferencesAtom);
|
||||
const { data: globalConfigs = [], isFetching: globalConfigsLoading } =
|
||||
|
|
@ -218,7 +218,7 @@ export const ConnectorIndicator = forwardRef<ConnectorIndicatorHandle, Connector
|
|||
onPointerDownOutside={(e) => {
|
||||
if (pickerOpen) e.preventDefault();
|
||||
}}
|
||||
className="max-w-3xl w-[95vw] sm:w-full h-[75vh] sm:h-[85vh] flex flex-col p-0 gap-0 overflow-hidden border border-border ring-0 dark:ring-0 bg-muted dark:bg-muted text-foreground [&>button]:right-4 sm:[&>button]:right-12 [&>button]:top-6 sm:[&>button]:top-10 [&>button]:opacity-80 [&>button]:hover:opacity-100 [&>button]:hover:bg-foreground/10 [&>button>svg]:size-5 select-none"
|
||||
className="max-w-3xl w-[95vw] sm:w-full h-[75vh] sm:h-[85vh] flex flex-col p-0 gap-0 overflow-hidden ring-0 dark:ring-0 [&>button]:right-4 sm:[&>button]:right-12 [&>button]:top-6 sm:[&>button]:top-10 [&>button]:opacity-80 [&>button]:hover:opacity-100 [&>button]:hover:bg-accent [&>button]:hover:text-accent-foreground [&>button>svg]:size-5 select-none"
|
||||
>
|
||||
<DialogTitle className="sr-only">Manage Connectors</DialogTitle>
|
||||
{/* YouTube Crawler View - shown when adding YouTube videos */}
|
||||
|
|
@ -380,34 +380,32 @@ export const ConnectorIndicator = forwardRef<ConnectorIndicatorHandle, Connector
|
|||
<div className="px-4 sm:px-12 py-4 sm:py-8 pb-12 sm:pb-16">
|
||||
{/* LLM Configuration Warning */}
|
||||
{!llmConfigLoading && !hasDocumentSummaryLLM && (
|
||||
<Alert
|
||||
variant="destructive"
|
||||
className="mb-6 bg-muted/50 rounded-xl border-destructive/30"
|
||||
>
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<AlertTitle>LLM Configuration Required</AlertTitle>
|
||||
<AlertDescription className="mt-2">
|
||||
<p className="mb-3">
|
||||
{isAutoMode && !hasGlobalConfigs
|
||||
? "Auto mode requires a global LLM configuration. Please add one in Settings"
|
||||
: "A Document Summary LLM is required to process uploads, configure one in Settings"}
|
||||
</p>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
handleOpenChange(false);
|
||||
setSearchSpaceSettingsDialog({
|
||||
open: true,
|
||||
initialTab: "models",
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Settings className="mr-2 h-4 w-4" />
|
||||
Go to Settings
|
||||
</Button>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<div className="mb-6">
|
||||
<Alert variant="destructive">
|
||||
<AlertTriangle />
|
||||
<AlertTitle>LLM Configuration Required</AlertTitle>
|
||||
<AlertDescription>
|
||||
<p>
|
||||
{isAutoMode && !hasGlobalConfigs
|
||||
? "Auto mode requires a global LLM configuration. Please add one in Settings"
|
||||
: "A Document Summary LLM is required to process uploads, configure one in Settings"}
|
||||
</p>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
handleOpenChange(false);
|
||||
router.push(
|
||||
`/dashboard/${searchSpaceId}/search-space-settings/models`
|
||||
);
|
||||
}}
|
||||
>
|
||||
<Settings className="mr-2 h-4 w-4" />
|
||||
Go to Settings
|
||||
</Button>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<TabsContent value="all" className="m-0">
|
||||
|
|
@ -446,7 +444,7 @@ export const ConnectorIndicator = forwardRef<ConnectorIndicatorHandle, Connector
|
|||
</div>
|
||||
</div>
|
||||
{/* Bottom fade shadow */}
|
||||
<div className="absolute bottom-0 left-0 right-0 h-7 bg-linear-to-t from-muted via-muted/80 to-transparent pointer-events-none z-10" />
|
||||
<div className="absolute bottom-0 left-0 right-0 h-7 bg-linear-to-t from-popover via-popover/80 to-transparent pointer-events-none z-10" />
|
||||
</div>
|
||||
</Tabs>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -81,8 +81,8 @@ export const ConnectorCard: FC<ConnectorCardProps> = ({
|
|||
className={cn(
|
||||
"group relative flex items-center gap-4 p-4 rounded-xl text-left transition-all duration-200 w-full border",
|
||||
status.status === "warning"
|
||||
? "border-yellow-500/30 bg-slate-400/5 dark:bg-white/5 hover:bg-slate-400/10 dark:hover:bg-white/10"
|
||||
: "border-border bg-slate-400/5 dark:bg-white/5 hover:bg-slate-400/10 dark:hover:bg-white/10"
|
||||
? "border-yellow-500/30 bg-slate-400/5 dark:bg-white/5 hover:bg-accent hover:text-accent-foreground"
|
||||
: "border-border bg-slate-400/5 dark:bg-white/5 hover:bg-accent hover:text-accent-foreground"
|
||||
)}
|
||||
>
|
||||
<div
|
||||
|
|
@ -145,9 +145,9 @@ export const ConnectorCard: FC<ConnectorCardProps> = ({
|
|||
size="sm"
|
||||
variant={isConnected ? "secondary" : "default"}
|
||||
className={cn(
|
||||
"relative h-8 text-[11px] px-3 rounded-lg shrink-0 font-medium items-center justify-center",
|
||||
"relative h-8 text-[11px] px-3 shrink-0 font-medium items-center justify-center",
|
||||
isConnected &&
|
||||
"bg-white text-slate-700 hover:bg-slate-50 border-0 shadow-xs dark:bg-secondary dark:text-secondary-foreground dark:hover:bg-secondary/80",
|
||||
"bg-white text-slate-700 hover:bg-accent hover:text-accent-foreground border-0 shadow-xs dark:bg-secondary dark:text-secondary-foreground",
|
||||
!isConnected && "shadow-xs"
|
||||
)}
|
||||
onClick={isConnected ? onManage : onConnect}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
import { Search, X } from "lucide-react";
|
||||
import type { FC } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
|
@ -25,7 +26,7 @@ export const ConnectorDialogHeader: FC<ConnectorDialogHeaderProps> = ({
|
|||
<div
|
||||
className={cn(
|
||||
"flex-shrink-0 px-4 sm:px-12 pt-5 sm:pt-10 transition-shadow duration-200 relative z-10",
|
||||
isScrolled && "shadow-xl bg-muted/50 backdrop-blur-md"
|
||||
isScrolled && "bg-popover shadow-xl"
|
||||
)}
|
||||
>
|
||||
<DialogHeader>
|
||||
|
|
@ -37,7 +38,7 @@ export const ConnectorDialogHeader: FC<ConnectorDialogHeaderProps> = ({
|
|||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex flex-col-reverse sm:flex-row sm:items-end justify-between gap-4 sm:gap-8 mt-4 sm:mt-8 border-b border-border/80 dark:border-white/5">
|
||||
<div className="flex flex-col-reverse sm:flex-row sm:items-end justify-between gap-4 sm:gap-8 mt-4 sm:mt-8 border-b border-popover-border">
|
||||
<TabsList className="bg-transparent p-0 gap-4 sm:gap-8 h-auto w-full sm:w-auto justify-center sm:justify-start">
|
||||
<TabsTrigger
|
||||
value="all"
|
||||
|
|
@ -63,27 +64,29 @@ export const ConnectorDialogHeader: FC<ConnectorDialogHeaderProps> = ({
|
|||
|
||||
<div className="w-full sm:w-72 sm:pb-1">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-gray-500 dark:text-gray-500" />
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
|
||||
<input
|
||||
type="text"
|
||||
autoComplete="off"
|
||||
placeholder="Search"
|
||||
className={cn(
|
||||
"w-full bg-slate-400/5 dark:bg-white/5 hover:bg-slate-400/10 dark:hover:bg-white/10 focus:bg-slate-400/10 dark:focus:bg-white/10 border border-border rounded-xl pl-9 py-2 text-sm transition-all outline-none placeholder:text-muted-foreground/50",
|
||||
"w-full bg-slate-400/5 dark:bg-white/5 hover:bg-accent hover:text-accent-foreground focus:bg-slate-400/10 dark:focus:bg-white/10 border border-border rounded-xl pl-9 py-2 text-sm transition-all outline-none placeholder:text-muted-foreground/50",
|
||||
searchQuery ? "pr-9" : "pr-4"
|
||||
)}
|
||||
value={searchQuery}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
type="button"
|
||||
onClick={() => onSearchChange("")}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 size-4 text-gray-500 dark:text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 transition-colors"
|
||||
className="absolute right-1.5 top-1/2 size-7 -translate-y-1/2 text-muted-foreground transition-colors hover:bg-transparent hover:text-accent-foreground"
|
||||
aria-label="Clear search"
|
||||
>
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
<X data-icon="inline-start" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
import { AlertTriangle, X } from "lucide-react";
|
||||
import type { FC } from "react";
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface ConnectorWarningBannerProps {
|
||||
|
|
@ -42,14 +43,16 @@ export const ConnectorWarningBanner: FC<ConnectorWarningBannerProps> = ({
|
|||
)}
|
||||
</div>
|
||||
{onDismiss && (
|
||||
<button
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
type="button"
|
||||
onClick={handleDismiss}
|
||||
className="shrink-0 p-0.5 rounded hover:bg-yellow-500/20 transition-colors"
|
||||
className="size-6 shrink-0 rounded p-0 transition-colors hover:bg-yellow-500/20"
|
||||
aria-label="Dismiss warning"
|
||||
>
|
||||
<X className="size-3.5 text-yellow-700 dark:text-yellow-300" />
|
||||
</button>
|
||||
<X data-icon="inline-start" className="text-yellow-700 dark:text-yellow-300" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -136,7 +136,7 @@ export const DateRangeSelector: FC<DateRangeSelectorProps> = ({
|
|||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleClearDates}
|
||||
className="text-xs sm:text-sm bg-slate-400/5 dark:bg-slate-400/5 border-slate-400/20 hover:bg-slate-400/10 dark:hover:bg-slate-400/10"
|
||||
className="text-xs sm:text-sm bg-slate-400/5 dark:bg-slate-400/5 border-slate-400/20 hover:bg-accent hover:text-accent-foreground"
|
||||
>
|
||||
Clear Dates
|
||||
</Button>
|
||||
|
|
@ -145,7 +145,7 @@ export const DateRangeSelector: FC<DateRangeSelectorProps> = ({
|
|||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleLast30Days}
|
||||
className="text-xs sm:text-sm bg-slate-400/5 dark:bg-slate-400/5 border-slate-400/20 hover:bg-slate-400/10 dark:hover:bg-slate-400/10"
|
||||
className="text-xs sm:text-sm bg-slate-400/5 dark:bg-slate-400/5 border-slate-400/20 hover:bg-accent hover:text-accent-foreground"
|
||||
>
|
||||
Last 30 Days
|
||||
</Button>
|
||||
|
|
@ -155,7 +155,7 @@ export const DateRangeSelector: FC<DateRangeSelectorProps> = ({
|
|||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleNext30Days}
|
||||
className="text-xs sm:text-sm bg-slate-400/5 dark:bg-slate-400/5 border-slate-400/20 hover:bg-slate-400/10 dark:hover:bg-slate-400/10"
|
||||
className="text-xs sm:text-sm bg-slate-400/5 dark:bg-slate-400/5 border-slate-400/20 hover:bg-accent hover:text-accent-foreground"
|
||||
>
|
||||
Next 30 Days
|
||||
</Button>
|
||||
|
|
@ -165,7 +165,7 @@ export const DateRangeSelector: FC<DateRangeSelectorProps> = ({
|
|||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleLastYear}
|
||||
className="text-xs sm:text-sm bg-slate-400/5 dark:bg-slate-400/5 border-slate-400/20 hover:bg-slate-400/10 dark:hover:bg-slate-400/10"
|
||||
className="text-xs sm:text-sm bg-slate-400/5 dark:bg-slate-400/5 border-slate-400/20 hover:bg-accent hover:text-accent-foreground"
|
||||
>
|
||||
Last Year
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -70,20 +70,22 @@ export const BaiduSearchApiConnectForm: FC<ConnectFormProps> = ({ onSubmit, isSu
|
|||
|
||||
return (
|
||||
<div className="space-y-6 pb-6">
|
||||
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20 p-2 sm:p-3">
|
||||
<Info className="size-4 shrink-0" />
|
||||
<AlertTitle className="text-xs sm:text-sm">API Key Required</AlertTitle>
|
||||
<AlertDescription className="text-[10px] sm:text-xs">
|
||||
You'll need a Baidu AppBuilder API key to use this connector. You can get one by signing
|
||||
up at{" "}
|
||||
<a
|
||||
href="https://qianfan.cloud.baidu.com/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-medium underline underline-offset-4"
|
||||
>
|
||||
qianfan.cloud.baidu.com
|
||||
</a>
|
||||
<Alert>
|
||||
<Info />
|
||||
<AlertTitle>API Key Required</AlertTitle>
|
||||
<AlertDescription>
|
||||
<p>
|
||||
You'll need a Baidu AppBuilder API key to use this connector. You can get one by signing
|
||||
up at{" "}
|
||||
<a
|
||||
href="https://qianfan.cloud.baidu.com/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-medium underline underline-offset-4"
|
||||
>
|
||||
qianfan.cloud.baidu.com
|
||||
</a>
|
||||
</p>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
|
|
|
|||
|
|
@ -96,10 +96,10 @@ export const BookStackConnectForm: FC<ConnectFormProps> = ({ onSubmit, isSubmitt
|
|||
|
||||
return (
|
||||
<div className="space-y-6 pb-6">
|
||||
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20 p-2 sm:p-3">
|
||||
<Info className="size-4 shrink-0" />
|
||||
<AlertTitle className="text-xs sm:text-sm">API Token Required</AlertTitle>
|
||||
<AlertDescription className="text-[10px] sm:text-xs">
|
||||
<Alert>
|
||||
<Info />
|
||||
<AlertTitle>API Token Required</AlertTitle>
|
||||
<AlertDescription>
|
||||
You'll need a BookStack API Token to use this connector. You can create one from your
|
||||
BookStack instance settings.
|
||||
</AlertDescription>
|
||||
|
|
|
|||
|
|
@ -172,10 +172,10 @@ export const ElasticsearchConnectForm: FC<ConnectFormProps> = ({ onSubmit, isSub
|
|||
|
||||
return (
|
||||
<div className="space-y-6 pb-6">
|
||||
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20 p-2 sm:p-3">
|
||||
<Info className="size-4 shrink-0" />
|
||||
<AlertTitle className="text-xs sm:text-sm">API Key Required</AlertTitle>
|
||||
<AlertDescription className="text-[10px] sm:text-xs">
|
||||
<Alert>
|
||||
<Info />
|
||||
<AlertTitle>API Key Required</AlertTitle>
|
||||
<AlertDescription>
|
||||
Enter your Elasticsearch cluster endpoint URL and authentication credentials to connect.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
|
@ -428,10 +428,10 @@ export const ElasticsearchConnectForm: FC<ConnectFormProps> = ({ onSubmit, isSub
|
|||
</div>
|
||||
)}
|
||||
|
||||
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20">
|
||||
<Info className="h-3 w-3 sm:h-4 sm:w-4" />
|
||||
<AlertTitle className="text-[10px] sm:text-xs">Index Selection Tips</AlertTitle>
|
||||
<AlertDescription className="text-[9px] sm:text-[10px] mt-2">
|
||||
<Alert>
|
||||
<Info />
|
||||
<AlertTitle>Index Selection Tips</AlertTitle>
|
||||
<AlertDescription>
|
||||
<ul className="list-disc pl-4 space-y-1">
|
||||
<li>Use wildcards like "logs-*" to match multiple indices</li>
|
||||
<li>Separate multiple indices with commas</li>
|
||||
|
|
@ -643,231 +643,6 @@ export const ElasticsearchConnectForm: FC<ConnectFormProps> = ({ onSubmit, isSub
|
|||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Documentation Section */}
|
||||
<Accordion
|
||||
type="single"
|
||||
collapsible
|
||||
className="w-full border border-border rounded-xl bg-slate-400/5 dark:bg-white/5"
|
||||
>
|
||||
<AccordionItem value="documentation" className="border-0">
|
||||
<AccordionTrigger className="text-sm sm:text-base font-medium px-3 sm:px-6 no-underline hover:no-underline">
|
||||
Documentation
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="px-3 sm:px-6 pb-3 sm:pb-6 space-y-6">
|
||||
<div>
|
||||
<h3 className="text-sm sm:text-base font-semibold mb-2">How it works</h3>
|
||||
<p className="text-[10px] sm:text-xs text-muted-foreground">
|
||||
The Elasticsearch connector allows you to search and retrieve documents from your
|
||||
Elasticsearch cluster. Configure connection details, select specific indices, and
|
||||
set search parameters to make your existing data searchable within SurfSense.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h3 className="text-sm sm:text-base font-semibold mb-2">Connection Setup</h3>
|
||||
<div className="space-y-4 sm:space-y-6">
|
||||
<div>
|
||||
<h4 className="text-[10px] sm:text-xs font-medium mb-2">
|
||||
Step 1: Get your Elasticsearch endpoint
|
||||
</h4>
|
||||
<p className="text-[10px] sm:text-xs text-muted-foreground mb-3">
|
||||
You'll need the endpoint URL for your Elasticsearch cluster. This typically
|
||||
looks like:
|
||||
</p>
|
||||
<ul className="list-disc pl-5 space-y-1 text-[10px] sm:text-xs text-muted-foreground mb-4">
|
||||
<li>
|
||||
Cloud:{" "}
|
||||
<code className="bg-muted px-1 py-0.5 rounded">
|
||||
https://your-cluster.es.region.aws.com:443
|
||||
</code>
|
||||
</li>
|
||||
<li>
|
||||
Self-hosted:{" "}
|
||||
<code className="bg-muted px-1 py-0.5 rounded">
|
||||
https://elasticsearch.example.com:9200
|
||||
</code>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="text-[10px] sm:text-xs font-medium mb-2">
|
||||
Step 2: Configure authentication
|
||||
</h4>
|
||||
<p className="text-[10px] sm:text-xs text-muted-foreground mb-3">
|
||||
Elasticsearch requires authentication. You can use either:
|
||||
</p>
|
||||
<ul className="list-disc pl-5 space-y-2 text-[10px] sm:text-xs text-muted-foreground mb-4">
|
||||
<li>
|
||||
<strong>API Key:</strong> A base64-encoded API key. You can create one in
|
||||
Elasticsearch by running:
|
||||
<pre className="bg-muted p-2 rounded mt-1 text-[9px] overflow-x-auto">
|
||||
<code>POST /_security/api_key</code>
|
||||
</pre>
|
||||
</li>
|
||||
<li>
|
||||
<strong>Username & Password:</strong> Basic authentication using your
|
||||
Elasticsearch username and password.
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="text-[10px] sm:text-xs font-medium mb-2">
|
||||
Step 3: Select indices
|
||||
</h4>
|
||||
<p className="text-[10px] sm:text-xs text-muted-foreground mb-3">
|
||||
Specify which indices to search. You can:
|
||||
</p>
|
||||
<ul className="list-disc pl-5 space-y-1 text-[10px] sm:text-xs text-muted-foreground">
|
||||
<li>
|
||||
Use wildcards: <code className="bg-muted px-1 py-0.5 rounded">logs-*</code>{" "}
|
||||
to match multiple indices
|
||||
</li>
|
||||
<li>
|
||||
List specific indices:{" "}
|
||||
<code className="bg-muted px-1 py-0.5 rounded">
|
||||
logs-2024, documents-2024
|
||||
</code>
|
||||
</li>
|
||||
<li>
|
||||
Leave empty to search all accessible indices (not recommended for
|
||||
performance)
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h3 className="text-sm sm:text-base font-semibold mb-2">Advanced Configuration</h3>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h4 className="text-[10px] sm:text-xs font-medium mb-2">Search Query</h4>
|
||||
<p className="text-[10px] sm:text-xs text-muted-foreground mb-2">
|
||||
The default query used for searches. Use{" "}
|
||||
<code className="bg-muted px-1 py-0.5 rounded">*</code> to match all
|
||||
documents, or specify a more complex Elasticsearch query.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="text-[10px] sm:text-xs font-medium mb-2">Search Fields</h4>
|
||||
<p className="text-[10px] sm:text-xs text-muted-foreground mb-2">
|
||||
Limit searches to specific fields for better performance. Common fields
|
||||
include:
|
||||
</p>
|
||||
<ul className="list-disc pl-5 space-y-1 text-[10px] sm:text-xs text-muted-foreground">
|
||||
<li>
|
||||
<code className="bg-muted px-1 py-0.5 rounded">title</code> - Document
|
||||
titles
|
||||
</li>
|
||||
<li>
|
||||
<code className="bg-muted px-1 py-0.5 rounded">content</code> - Main content
|
||||
</li>
|
||||
<li>
|
||||
<code className="bg-muted px-1 py-0.5 rounded">description</code> -
|
||||
Descriptions
|
||||
</li>
|
||||
</ul>
|
||||
<p className="text-[10px] sm:text-xs text-muted-foreground mt-2">
|
||||
Leave empty to search all fields in your documents.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="text-[10px] sm:text-xs font-medium mb-2">Maximum Documents</h4>
|
||||
<p className="text-[10px] sm:text-xs text-muted-foreground">
|
||||
Set a limit on the number of documents retrieved per search (1-10,000). This
|
||||
helps control response times and resource usage. Leave empty to use
|
||||
Elasticsearch's default limit.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h3 className="text-sm sm:text-base font-semibold mb-2">Troubleshooting</h3>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h4 className="text-[10px] sm:text-xs font-medium mb-2">Connection Issues</h4>
|
||||
<ul className="list-disc pl-5 space-y-2 text-[10px] sm:text-xs text-muted-foreground">
|
||||
<li>
|
||||
<strong>Invalid URL:</strong> Ensure your endpoint URL includes the protocol
|
||||
(https://) and port number if required.
|
||||
</li>
|
||||
<li>
|
||||
<strong>SSL/TLS Errors:</strong> Verify that your cluster uses HTTPS and the
|
||||
certificate is valid. Self-signed certificates may require additional
|
||||
configuration.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Connection Timeout:</strong> Check your network connectivity and
|
||||
firewall settings. Ensure the Elasticsearch cluster is accessible from
|
||||
SurfSense servers.
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="text-[10px] sm:text-xs font-medium mb-2">
|
||||
Authentication Issues
|
||||
</h4>
|
||||
<ul className="list-disc pl-5 space-y-2 text-[10px] sm:text-xs text-muted-foreground">
|
||||
<li>
|
||||
<strong>Invalid Credentials:</strong> Double-check your username/password or
|
||||
API key. API keys must be base64-encoded.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Permission Denied:</strong> Ensure your API key or user account has
|
||||
read permissions for the indices you want to search.
|
||||
</li>
|
||||
<li>
|
||||
<strong>API Key Format:</strong> Elasticsearch API keys are typically
|
||||
base64-encoded strings. Make sure you're using the full key value.
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="text-[10px] sm:text-xs font-medium mb-2">Search Issues</h4>
|
||||
<ul className="list-disc pl-5 space-y-2 text-[10px] sm:text-xs text-muted-foreground">
|
||||
<li>
|
||||
<strong>No Results:</strong> Verify that your index selection matches
|
||||
existing indices. Use wildcards carefully.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Slow Searches:</strong> Limit the number of indices or use specific
|
||||
index names instead of wildcards. Reduce the maximum documents limit.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Field Not Found:</strong> Ensure the search fields you specify
|
||||
actually exist in your Elasticsearch documents.
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20 mt-4">
|
||||
<Info className="h-3 w-3 sm:h-4 sm:w-4" />
|
||||
<AlertTitle className="text-[10px] sm:text-xs">Need More Help?</AlertTitle>
|
||||
<AlertDescription className="text-[9px] sm:text-[10px]">
|
||||
If you continue to experience issues, check your Elasticsearch cluster logs
|
||||
and ensure your cluster version is compatible. For Elasticsearch Cloud
|
||||
deployments, verify your access policies and IP allowlists.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -105,20 +105,23 @@ export const GithubConnectForm: FC<ConnectFormProps> = ({ onSubmit, isSubmitting
|
|||
|
||||
return (
|
||||
<div className="space-y-6 pb-6">
|
||||
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20 p-2 sm:p-3">
|
||||
<Info className="size-4 shrink-0" />
|
||||
<AlertTitle className="text-xs sm:text-sm">Personal Access Token (Optional)</AlertTitle>
|
||||
<AlertDescription className="text-[10px] sm:text-xs">
|
||||
A GitHub PAT is only required for private repositories. Public repos work without a token.{" "}
|
||||
<a
|
||||
href="https://github.com/settings/tokens/new?description=surfsense&scopes=repo"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-medium underline underline-offset-4 inline-flex items-center gap-1.5"
|
||||
>
|
||||
Get your token
|
||||
<ExternalLink className="h-3 w-3 sm:h-4 sm:w-4" />
|
||||
</a>{" "}
|
||||
<Alert>
|
||||
<Info />
|
||||
<AlertTitle>Personal Access Token (Optional)</AlertTitle>
|
||||
<AlertDescription>
|
||||
<p>
|
||||
A GitHub PAT is only required for private repositories. Public repos work without a
|
||||
token.{" "}
|
||||
<a
|
||||
href="https://github.com/settings/tokens/new?description=surfsense&scopes=repo"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-medium underline underline-offset-4 inline-flex items-center gap-1.5"
|
||||
>
|
||||
Get your token
|
||||
<ExternalLink className="h-3 w-3 sm:h-4 sm:w-4" />
|
||||
</a>
|
||||
</p>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
|
|
|
|||
|
|
@ -70,19 +70,21 @@ export const LinkupApiConnectForm: FC<ConnectFormProps> = ({ onSubmit, isSubmitt
|
|||
|
||||
return (
|
||||
<div className="space-y-6 pb-6">
|
||||
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20 p-2 sm:p-3">
|
||||
<Info className="size-4 shrink-0" />
|
||||
<AlertTitle className="text-xs sm:text-sm">API Key Required</AlertTitle>
|
||||
<AlertDescription className="text-[10px] sm:text-xs">
|
||||
You'll need a Linkup API key to use this connector. You can get one by signing up at{" "}
|
||||
<a
|
||||
href="https://linkup.so"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-medium underline underline-offset-4"
|
||||
>
|
||||
linkup.so
|
||||
</a>
|
||||
<Alert>
|
||||
<Info />
|
||||
<AlertTitle>API Key Required</AlertTitle>
|
||||
<AlertDescription>
|
||||
<p>
|
||||
You'll need a Linkup API key to use this connector. You can get one by signing up at{" "}
|
||||
<a
|
||||
href="https://linkup.so"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-medium underline underline-offset-4"
|
||||
>
|
||||
linkup.so
|
||||
</a>
|
||||
</p>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
|
|
|
|||
|
|
@ -88,19 +88,21 @@ export const LumaConnectForm: FC<ConnectFormProps> = ({ onSubmit, isSubmitting }
|
|||
|
||||
return (
|
||||
<div className="space-y-6 pb-6">
|
||||
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20 p-2 sm:p-3">
|
||||
<Info className="size-4 shrink-0" />
|
||||
<AlertTitle className="text-xs sm:text-sm">API Key Required</AlertTitle>
|
||||
<AlertDescription className="text-[10px] sm:text-xs">
|
||||
You'll need a Luma API Key to use this connector. You can create one from{" "}
|
||||
<a
|
||||
href="https://lu.ma/api"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-medium underline underline-offset-4"
|
||||
>
|
||||
Luma API Settings
|
||||
</a>
|
||||
<Alert>
|
||||
<Info />
|
||||
<AlertTitle>API Key Required</AlertTitle>
|
||||
<AlertDescription>
|
||||
<p>
|
||||
You'll need a Luma API Key to use this connector. You can create one from{" "}
|
||||
<a
|
||||
href="https://lu.ma/api"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-medium underline underline-offset-4"
|
||||
>
|
||||
Luma API Settings
|
||||
</a>
|
||||
</p>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
|
|
|
|||
|
|
@ -155,7 +155,7 @@ export const MCPConnectForm: FC<ConnectFormProps> = ({ onSubmit, isSubmitting })
|
|||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 px-2 text-xs text-muted-foreground hover:text-foreground"
|
||||
className="h-6 px-2 text-xs text-muted-foreground hover:text-accent-foreground"
|
||||
onClick={() => handleConfigChange(DEFAULT_STDIO_CONFIG)}
|
||||
>
|
||||
Local Example
|
||||
|
|
@ -164,7 +164,7 @@ export const MCPConnectForm: FC<ConnectFormProps> = ({ onSubmit, isSubmitting })
|
|||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 px-2 text-xs text-muted-foreground hover:text-foreground"
|
||||
className="h-6 px-2 text-xs text-muted-foreground hover:text-accent-foreground"
|
||||
onClick={() => handleConfigChange(DEFAULT_HTTP_CONFIG)}
|
||||
>
|
||||
Remote Example
|
||||
|
|
@ -210,7 +210,7 @@ export const MCPConnectForm: FC<ConnectFormProps> = ({ onSubmit, isSubmitting })
|
|||
onClick={handleTestConnection}
|
||||
disabled={isTesting}
|
||||
variant="secondary"
|
||||
className="w-full h-8 text-[13px] px-3 rounded-lg font-medium bg-white text-slate-700 hover:bg-slate-50 border-0 shadow-xs dark:bg-secondary dark:text-secondary-foreground dark:hover:bg-secondary/80"
|
||||
className="w-full h-8 text-[13px] px-3 font-medium bg-white text-slate-700 hover:bg-accent hover:text-accent-foreground border-0 shadow-xs dark:bg-secondary dark:text-secondary-foreground"
|
||||
>
|
||||
{isTesting ? (
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -1,20 +1,17 @@
|
|||
"use client";
|
||||
|
||||
import { Check, Copy, Info } from "lucide-react";
|
||||
import { type FC, useCallback, useRef, useState } from "react";
|
||||
import type { FC } from "react";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { EnumConnectorName } from "@/contracts/enums/connector";
|
||||
import { useApiKey } from "@/hooks/use-api-key";
|
||||
import { copyToClipboard as copyToClipboardUtil } from "@/lib/utils";
|
||||
import { getConnectorBenefits } from "../connector-benefits";
|
||||
import type { ConnectFormProps } from "../index";
|
||||
|
||||
const PLUGIN_RELEASES_URL =
|
||||
"https://github.com/MODSetter/SurfSense/releases?q=obsidian&expanded=true";
|
||||
|
||||
const BACKEND_URL = process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL ?? "https://surfsense.com";
|
||||
|
||||
/**
|
||||
* Obsidian connect form for the plugin-only architecture.
|
||||
*
|
||||
|
|
@ -30,16 +27,6 @@ const BACKEND_URL = process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL ?? "https://surf
|
|||
*/
|
||||
export const ObsidianConnectForm: FC<ConnectFormProps> = ({ onBack }) => {
|
||||
const { apiKey, isLoading, copied, copyToClipboard } = useApiKey();
|
||||
const [copiedUrl, setCopiedUrl] = useState(false);
|
||||
const urlCopyTimerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
||||
|
||||
const copyServerUrl = useCallback(async () => {
|
||||
const ok = await copyToClipboardUtil(BACKEND_URL);
|
||||
if (!ok) return;
|
||||
setCopiedUrl(true);
|
||||
if (urlCopyTimerRef.current) clearTimeout(urlCopyTimerRef.current);
|
||||
urlCopyTimerRef.current = setTimeout(() => setCopiedUrl(false), 2000);
|
||||
}, []);
|
||||
|
||||
const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
|
|
@ -52,10 +39,10 @@ export const ObsidianConnectForm: FC<ConnectFormProps> = ({ onBack }) => {
|
|||
that just closes the dialog (see component-level docstring). */}
|
||||
<form id="obsidian-connect-form" onSubmit={handleSubmit} />
|
||||
|
||||
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20 p-2 sm:p-3">
|
||||
<Info className="size-4 shrink-0 text-purple-500" />
|
||||
<AlertTitle className="text-xs sm:text-sm">Plugin-based sync</AlertTitle>
|
||||
<AlertDescription className="text-[10px] sm:text-xs">
|
||||
<Alert>
|
||||
<Info />
|
||||
<AlertTitle>Plugin-based sync</AlertTitle>
|
||||
<AlertDescription>
|
||||
SurfSense now syncs Obsidian via an official plugin that runs inside Obsidian itself.
|
||||
Works on desktop and mobile, in cloud and self-hosted deployments.
|
||||
</AlertDescription>
|
||||
|
|
@ -123,7 +110,7 @@ export const ObsidianConnectForm: FC<ConnectFormProps> = ({ onBack }) => {
|
|||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={copyToClipboard}
|
||||
className="size-7 shrink-0 text-muted-foreground hover:text-foreground"
|
||||
className="size-7 shrink-0 text-muted-foreground hover:text-accent-foreground"
|
||||
aria-label={copied ? "Copied" : "Copy API key"}
|
||||
>
|
||||
{copied ? (
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue