Merge pull request #1585 from AnishSarkar22/fix/ci-ui-changes
Some checks are pending
Build and Push Docker Images / compute_version (push) Waiting to run
Build and Push Docker Images / build (./surfsense_backend, cpu, ./surfsense_backend/Dockerfile, backend, surfsense-backend, ubuntu-24.04-arm, linux/arm64, arm64, , production, false, cpu) (push) Blocked by required conditions
Build and Push Docker Images / build (./surfsense_backend, cpu, ./surfsense_backend/Dockerfile, backend, surfsense-backend, ubuntu-latest, linux/amd64, amd64, , production, false, cpu) (push) Blocked by required conditions
Build and Push Docker Images / build (./surfsense_backend, cu126, ./surfsense_backend/Dockerfile, backend, surfsense-backend, ubuntu-24.04-arm, linux/arm64, arm64, -cuda126, production, true, cuda126) (push) Blocked by required conditions
Build and Push Docker Images / build (./surfsense_backend, cu126, ./surfsense_backend/Dockerfile, backend, surfsense-backend, ubuntu-latest, linux/amd64, amd64, -cuda126, production, true, cuda126) (push) Blocked by required conditions
Build and Push Docker Images / build (./surfsense_backend, cu128, ./surfsense_backend/Dockerfile, backend, surfsense-backend, ubuntu-24.04-arm, linux/arm64, arm64, -cuda, production, true, cuda) (push) Blocked by required conditions
Build and Push Docker Images / build (./surfsense_backend, cu128, ./surfsense_backend/Dockerfile, backend, surfsense-backend, ubuntu-latest, linux/amd64, amd64, -cuda, production, true, cuda) (push) Blocked by required conditions
Build and Push Docker Images / build (./surfsense_web, cpu, ./surfsense_web/Dockerfile, web, surfsense-web, ubuntu-24.04-arm, linux/arm64, arm64, , runner, false, cpu) (push) Blocked by required conditions
Build and Push Docker Images / build (./surfsense_web, cpu, ./surfsense_web/Dockerfile, web, surfsense-web, ubuntu-latest, linux/amd64, amd64, , runner, false, cpu) (push) Blocked by required conditions
Build and Push Docker Images / verify_digests (push) Blocked by required conditions
Build and Push Docker Images / create_manifest (backend, surfsense-backend, , cpu) (push) Blocked by required conditions
Build and Push Docker Images / create_manifest (backend, surfsense-backend, -cuda, cuda) (push) Blocked by required conditions
Build and Push Docker Images / create_manifest (backend, surfsense-backend, -cuda126, cuda126) (push) Blocked by required conditions
Build and Push Docker Images / create_manifest (web, surfsense-web, , cpu) (push) Blocked by required conditions
Build and Push Docker Images / finalize_release (push) Blocked by required conditions

feat(ui): Unify notifications, sidebar, and API playground UX
This commit is contained in:
Anish Sarkar 2026-07-08 04:14:49 +05:30 committed by GitHub
commit ba08d6392f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
54 changed files with 1800 additions and 2396 deletions

View file

@ -71,6 +71,7 @@ class CapabilitySummary(BaseModel):
name: str
description: str
docs_url: str | None = None
input_schema: dict
output_schema: dict
# Empty list = free (billing disabled or an unmetered verb).
@ -145,6 +146,7 @@ def _register_capabilities_list(
CapabilitySummary(
name=capability.name,
description=capability.description,
docs_url=capability.docs_url,
input_schema=capability.input_schema.model_json_schema(),
output_schema=capability.output_schema.model_json_schema(),
),

View file

@ -62,3 +62,4 @@ class Capability:
output_schema: type[BaseModel]
executor: Executor
billing_unit: BillingUnit | None
docs_url: str | None = None

View file

@ -10,15 +10,14 @@ from app.capabilities.google_maps.reviews.schemas import ReviewsInput, ReviewsOu
GOOGLE_MAPS_REVIEWS = Capability(
name="google_maps.reviews",
description=(
"Fetch public reviews for one or more Google Maps places. Give it place "
"URLs or place IDs; returns structured review items with author, text, "
"star rating, like count, owner response, and timestamps. Use it to "
"gauge sentiment or pull recent feedback on specific places."
"Fetch public Google Maps reviews with authors, ratings, text, and "
"owner responses. Use urls or place IDs."
),
input_schema=ReviewsInput,
output_schema=ReviewsOutput,
executor=build_reviews_executor(),
billing_unit=BillingUnit.GOOGLE_MAPS_REVIEW,
docs_url="/docs/connectors/native/google-maps",
)
register_capability(GOOGLE_MAPS_REVIEWS)

View file

@ -11,17 +11,14 @@ from app.capabilities.google_maps.scrape.schemas import ScrapeInput, ScrapeOutpu
GOOGLE_MAPS_SCRAPE = Capability(
name="google_maps.scrape",
description=(
"Scrape public Google Maps places. Give it search queries (optionally "
"scoped by location), Google Maps URLs, or place IDs, and it returns "
"structured place items — name, address, category, phone, website, "
"rating, review count, coordinates, and opening hours. Set "
"include_details for richer detail-page fields, or max_reviews/"
"max_images to attach reviews and photos per place."
"Scrape public Google Maps places, details, reviews, and photos. Use "
"search_queries, urls, or place IDs."
),
input_schema=ScrapeInput,
output_schema=ScrapeOutput,
executor=build_scrape_executor(),
billing_unit=BillingUnit.GOOGLE_MAPS_PLACE,
docs_url="/docs/connectors/native/google-maps",
)
register_capability(GOOGLE_MAPS_SCRAPE)

View file

@ -10,16 +10,14 @@ from app.capabilities.google_search.scrape.schemas import ScrapeInput, ScrapeOut
GOOGLE_SEARCH_SCRAPE = Capability(
name="google_search.scrape",
description=(
"Search Google and return structured results. Give it search terms "
"(optionally scoped by country/language or to a single site) or full "
"Google Search URLs, and it returns SERP items — organic results "
"(title, url, description), related queries, people-also-ask, and any "
"AI overview. Use max_pages_per_query to page deeper."
"Search Google and return structured SERP results. Use search_queries "
"or Google Search URLs."
),
input_schema=ScrapeInput,
output_schema=ScrapeOutput,
executor=build_scrape_executor(),
billing_unit=BillingUnit.GOOGLE_SEARCH_SERP,
docs_url="/docs/connectors/native/google-search",
)
register_capability(GOOGLE_SEARCH_SCRAPE)

View file

@ -10,16 +10,14 @@ from app.capabilities.reddit.scrape.schemas import ScrapeInput, ScrapeOutput
REDDIT_SCRAPE = Capability(
name="reddit.scrape",
description=(
"Scrape public Reddit data. Give it Reddit URLs (post, subreddit, or "
"user) and/or search terms, and it returns structured items — posts "
"(title, body, score, comment count, subreddit, author), their comments, "
"and community/user metadata. Use search_queries (optionally scoped to a "
"community) to discover posts, or urls to pull a known post/subreddit/user."
"Scrape public Reddit posts, comments, and metadata. Use urls or "
"search_queries."
),
input_schema=ScrapeInput,
output_schema=ScrapeOutput,
executor=build_scrape_executor(),
billing_unit=BillingUnit.REDDIT_ITEM,
docs_url="/docs/connectors/native/reddit",
)
register_capability(REDDIT_SCRAPE)

View file

@ -9,29 +9,14 @@ from app.capabilities.web.crawl.schemas import CrawlInput, CrawlOutput
WEB_CRAWL = Capability(
name="web.crawl",
description=(
"Scrape a single web page or crawl a whole website. Give it one or more "
"startUrls. Set maxCrawlDepth=0 to fetch just those URLs, or higher to "
"also follow the links on each page (depth 1 = the start pages plus the "
"pages they link to, and so on) — staying on the same site and stopping "
"at maxCrawlPages. On a deeper crawl, narrow which links are followed with "
"includeUrlPatterns / excludeUrlPatterns (regexes). Returns one item per "
"fetched page with clean markdown content, metadata (title, description), "
"crawl provenance, every link with its anchor text and kind "
"(internal/external/social/email/tel — use the text/context to tie a "
"profile URL to a person or company), and contact signals (emails, phone "
"numbers, social profiles). The site-wide contacts summary deduplicates "
"them with provenance: siteWide=true marks footer/header values (the "
"company's own contacts) vs page-local finds (e.g. team members' "
"profiles). Useful for lead generation and competitive intelligence; "
"contact details often live on about/contact/privacy pages, so crawl "
"with maxCrawlDepth >= 1 to surface them. JS-rendered pages are loaded "
"in a real browser and auto-scrolled, so lazy-loaded listings "
"(directories, infinite-scroll feeds) are captured too."
"Scrape pages or crawl websites for clean markdown, links, metadata, "
"and contact signals. Use startUrls and crawl-depth controls."
),
input_schema=CrawlInput,
output_schema=CrawlOutput,
executor=build_crawl_executor(),
billing_unit=BillingUnit.WEB_CRAWL,
docs_url="/docs/connectors/native/web-crawl",
)
register_capability(WEB_CRAWL)

View file

@ -10,15 +10,14 @@ from app.capabilities.youtube.comments.schemas import CommentsInput, CommentsOut
YOUTUBE_COMMENTS = Capability(
name="youtube.comments",
description=(
"Fetch public comments (and their replies) for one or more YouTube "
"videos. Give it the video URLs; returns structured comment items with "
"author, text, like count, reply relationships, and timestamps. Use it "
"to gauge sentiment or pull discussion on specific videos."
"Fetch public YouTube comments and replies with authors, text, likes, "
"and timestamps. Use video URLs."
),
input_schema=CommentsInput,
output_schema=CommentsOutput,
executor=build_comments_executor(),
billing_unit=BillingUnit.YOUTUBE_COMMENT,
docs_url="/docs/connectors/native/youtube",
)
register_capability(YOUTUBE_COMMENTS)

View file

@ -10,16 +10,14 @@ from app.capabilities.youtube.scrape.schemas import ScrapeInput, ScrapeOutput
YOUTUBE_SCRAPE = Capability(
name="youtube.scrape",
description=(
"Scrape public YouTube data. Give it YouTube URLs (video, channel, "
"playlist, shorts, or hashtag) and/or search queries, and it returns "
"structured video items — title, views, likes, publish date, channel "
"info, description, and optionally subtitles. Use search_queries to "
"discover videos, or urls to pull a known video/channel/playlist."
"Scrape public YouTube videos, channels, playlists, and subtitles. Use "
"urls or search_queries."
),
input_schema=ScrapeInput,
output_schema=ScrapeOutput,
executor=build_scrape_executor(),
billing_unit=BillingUnit.YOUTUBE_VIDEO,
docs_url="/docs/connectors/native/youtube",
)
register_capability(YOUTUBE_SCRAPE)

View file

@ -1,4 +1,4 @@
import { ApiKeysSection } from "../components/api-keys-section";
import { redirect } from "next/navigation";
export default async function PlaygroundApiKeysPage({
params,
@ -7,9 +7,5 @@ export default async function PlaygroundApiKeysPage({
}) {
const { workspace_id } = await params;
return (
<div className="mx-auto w-full max-w-3xl">
<ApiKeysSection workspaceId={Number(workspace_id)} />
</div>
);
redirect(`/dashboard/${workspace_id}/user-settings/api-key`);
}

View file

@ -1,82 +0,0 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { useAtomValue } from "jotai";
import { useState } from "react";
import { toast } from "sonner";
import { updateWorkspaceApiAccessMutationAtom } from "@/atoms/workspaces/workspace-mutation.atoms";
import { Label } from "@/components/ui/label";
import { Skeleton } from "@/components/ui/skeleton";
import { Switch } from "@/components/ui/switch";
import { workspacesApiService } from "@/lib/apis/workspaces-api.service";
import { cacheKeys } from "@/lib/query-client/cache-keys";
import { ApiKeyContent } from "../../user-settings/components/ApiKeyContent";
/**
* One-stop API key management for the playground: the workspace API-access
* toggle (otherwise buried in workspace settings) plus the personal API key
* manager (otherwise buried in user settings).
*/
export function ApiKeysSection({ workspaceId }: { workspaceId: number }) {
const {
data: workspace,
isLoading,
refetch,
} = useQuery({
queryKey: cacheKeys.workspaces.detail(workspaceId.toString()),
queryFn: () => workspacesApiService.getWorkspace({ id: workspaceId }),
enabled: !!workspaceId,
});
const { mutateAsync: updateWorkspaceApiAccess } = useAtomValue(
updateWorkspaceApiAccessMutationAtom
);
const [saving, setSaving] = useState(false);
const handleToggle = async (enabled: boolean) => {
try {
setSaving(true);
await updateWorkspaceApiAccess({ id: workspaceId, api_access_enabled: enabled });
await refetch();
} catch (error) {
console.error("Error updating API access:", error);
toast.error(error instanceof Error ? error.message : "Failed to update API access");
} finally {
setSaving(false);
}
};
const apiAccessEnabled = !!workspace?.api_access_enabled;
return (
<div className="space-y-6">
<div>
<h1 className="text-xl font-semibold">API Keys</h1>
<p className="mt-1 text-sm text-muted-foreground">
Enable API access for this workspace and manage the keys that use it.
</p>
</div>
<div className="flex items-center justify-between gap-4 rounded-lg border border-border/60 px-4 py-3">
<div className="space-y-1">
<Label htmlFor="playground-api-access">API key access</Label>
<p className="text-xs text-muted-foreground">
Allow API keys to access this workspace.
{!isLoading && !apiAccessEnabled && " Currently disabled — keys won't work here."}
</p>
</div>
{isLoading ? (
<Skeleton className="h-5 w-9 rounded-full" />
) : (
<Switch
id="playground-api-access"
checked={apiAccessEnabled}
disabled={saving}
onCheckedChange={handleToggle}
/>
)}
</div>
<ApiKeyContent />
</div>
);
}

View file

@ -1,6 +1,6 @@
"use client";
import { Check, Copy } from "lucide-react";
import { Check, ChevronRight, Copy } from "lucide-react";
import { useMemo, useState } from "react";
import { Button } from "@/components/ui/button";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
@ -22,10 +22,10 @@ function CopyButton({ text }: { text: string }) {
variant="ghost"
size="sm"
onClick={copy}
className="absolute right-2 top-2 h-7 gap-1.5 px-2 text-xs"
aria-label={copied ? "Copied" : "Copy"}
className="absolute right-2 top-2 h-7 w-7 p-0"
>
{copied ? <Check className="h-3.5 w-3.5" /> : <Copy className="h-3.5 w-3.5" />}
{copied ? "Copied" : "Copy"}
</Button>
);
}
@ -45,8 +45,9 @@ function SchemaBlock({ title, schema }: { title: string; schema: Record<string,
const json = useMemo(() => JSON.stringify(schema, null, 2), [schema]);
return (
<details className="group rounded-md border border-border/60">
<summary className="cursor-pointer select-none px-3 py-2 text-sm font-medium text-muted-foreground transition-colors hover:text-foreground">
{title}
<summary className="flex cursor-pointer list-none items-center justify-between gap-3 px-3 py-2 text-sm font-medium text-muted-foreground transition-colors hover:text-foreground [&::-webkit-details-marker]:hidden">
<span>{title}</span>
<ChevronRight className="h-4 w-4 shrink-0 transition-transform group-open:rotate-90" />
</summary>
<div className="relative border-t border-border/60">
<CopyButton text={json} />
@ -90,11 +91,7 @@ export function ApiReference({
<div>
<h2 className="text-base font-semibold">API reference</h2>
<p className="mt-1 text-sm text-muted-foreground">
Call this API from your own project. Create a key under{" "}
<span className="font-medium text-foreground">API Keys</span> (and enable API access for
this workspace), then send it as a{" "}
<code className="rounded bg-muted/40 px-1 py-0.5 text-xs">Authorization: Bearer</code>{" "}
header.
Create an API key, enable API access for this workspace, then use the examples below to call this endpoint.
</p>
</div>

View file

@ -3,6 +3,7 @@
import { Check, Copy, Download } from "lucide-react";
import { useMemo, useState } from "react";
import { Button } from "@/components/ui/button";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
Table,
TableBody,
@ -12,7 +13,6 @@ import {
TableRow,
} from "@/components/ui/table";
import { downloadCsv, rowsToCsv } from "@/lib/playground/csv";
import { cn } from "@/lib/utils";
const MAX_TABLE_ROWS = 200;
@ -109,34 +109,12 @@ export function OutputViewer({ data, filenameBase }: { data: unknown; filenameBa
return (
<div className="space-y-3">
<div className="flex items-center justify-between">
<div className="inline-flex rounded-md border border-border/60 p-0.5">
{items && (
<button
type="button"
onClick={() => setView("table")}
className={cn(
"rounded px-2.5 py-1 text-xs font-medium transition-colors",
view === "table"
? "bg-accent text-accent-foreground"
: "text-muted-foreground hover:text-foreground"
)}
>
Table
</button>
)}
<button
type="button"
onClick={() => setView("json")}
className={cn(
"rounded px-2.5 py-1 text-xs font-medium transition-colors",
view === "json"
? "bg-accent text-accent-foreground"
: "text-muted-foreground hover:text-foreground"
)}
>
JSON
</button>
</div>
<Tabs value={view} onValueChange={(value) => setView(value as "table" | "json")}>
<TabsList className="h-auto">
{items && <TabsTrigger value="table">Table</TabsTrigger>}
<TabsTrigger value="json">JSON</TabsTrigger>
</TabsList>
</Tabs>
<div className="flex items-center gap-1">
{items && items.length > 0 && (
<Button
@ -150,9 +128,15 @@ export function OutputViewer({ data, filenameBase }: { data: unknown; filenameBa
Export CSV
</Button>
)}
<Button type="button" variant="ghost" size="sm" onClick={copy} className="gap-1.5">
<Button
type="button"
variant="ghost"
size="sm"
onClick={copy}
aria-label={copied ? "Copied JSON" : "Copy JSON"}
className="h-8 w-8 p-0"
>
{copied ? <Check className="h-3.5 w-3.5" /> : <Copy className="h-3.5 w-3.5" />}
{copied ? "Copied" : "Copy JSON"}
</Button>
</div>
</div>
@ -168,7 +152,7 @@ export function OutputViewer({ data, filenameBase }: { data: unknown; filenameBa
<ResultTable items={items} />
{truncated && (
<p className="text-xs text-muted-foreground">
Showing first {MAX_TABLE_ROWS} of {items.length} items. Use Copy JSON for the full
Showing first {MAX_TABLE_ROWS} of {items.length} items. Switch to JSON for the full
output.
</p>
)}

View file

@ -1,8 +1,9 @@
"use client";
import { ArrowRight, History, KeyRound } from "lucide-react";
import { ArrowRight, History, Info } from "lucide-react";
import Link from "next/link";
import { useMemo } from "react";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { useScraperCapabilities } from "@/hooks/use-scraper-capabilities";
import { PLAYGROUND_PLATFORMS } from "@/lib/playground/catalog";
import { formatPricing } from "@/lib/playground/format";
@ -20,13 +21,21 @@ export function PlaygroundIndex({ workspaceId }: { workspaceId: number }) {
return (
<div className="space-y-8">
<div>
<h1 className="text-xl font-semibold">API Playground</h1>
<p className="mt-1 text-sm text-muted-foreground">
Manually run SurfSense's platform-native APIs and inspect their output. Every run is
captured under Runs.
</p>
</div>
<Alert>
<Info />
<AlertDescription>
<p>
Manually run SurfSense's platform-native APIs and inspect their output. To use these APIs outside SurfSense,{" "}
<Link
href={`/dashboard/${workspaceId}/user-settings/api-key`}
className="font-medium text-foreground underline-offset-4 hover:underline"
>
create an API key
</Link>
.
</p>
</AlertDescription>
</Alert>
<div className="grid gap-3 sm:grid-cols-2">
<Link
@ -36,27 +45,12 @@ export function PlaygroundIndex({ workspaceId }: { workspaceId: number }) {
<div className="flex items-center gap-3">
<History className="h-5 w-5 text-muted-foreground" />
<div>
<p className="text-sm font-medium">Runs</p>
<p className="text-sm font-medium">API Runs</p>
<p className="text-xs text-muted-foreground">See every API run in this workspace</p>
</div>
</div>
<ArrowRight className="h-4 w-4 text-muted-foreground" />
</Link>
<Link
href={`${base}/api-keys`}
className="flex items-center justify-between rounded-lg border border-border/60 bg-accent/40 px-4 py-3 transition-colors hover:bg-accent"
>
<div className="flex items-center gap-3">
<KeyRound className="h-5 w-5 text-muted-foreground" />
<div>
<p className="text-sm font-medium">API Keys</p>
<p className="text-xs text-muted-foreground">
Enable workspace access and manage keys
</p>
</div>
</div>
<ArrowRight className="h-4 w-4 text-muted-foreground" />
</Link>
</div>
<div className="space-y-6">

View file

@ -1,14 +1,23 @@
"use client";
import { AlertTriangle, Coins, Hash, Loader2, Play, Timer, X } from "lucide-react";
import {
Check,
Copy,
Hash,
Info,
Coins,
Timer,
} from "lucide-react";
import Link from "next/link";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { Spinner } from "@/components/ui/spinner";
import { useRunStream } from "@/hooks/use-run-stream";
import { useScraperCapabilities } from "@/hooks/use-scraper-capabilities";
import { scrapersApiService } from "@/lib/apis/scrapers-api.service";
import { AbortedError, AppError } from "@/lib/error";
import { AppError } from "@/lib/error";
import { findVerb } from "@/lib/playground/catalog";
import { formatCost, formatDuration, formatPricing } from "@/lib/playground/format";
import { buildPayload, initialFormValues, parseSchemaFields } from "@/lib/playground/json-schema";
@ -58,36 +67,44 @@ function RunStat({
);
}
function ErrorPanel({ error, workspaceId }: { error: unknown; workspaceId: number }) {
if (error instanceof AbortedError) {
return null;
}
function getRunErrorMessage(error: unknown): string {
const status = error instanceof AppError ? error.status : undefined;
if (status === 402) {
return (
<div className="rounded-md border border-destructive/40 bg-destructive/5 p-4 text-sm">
<p className="font-medium text-destructive">Insufficient credits</p>
<p className="mt-1 text-muted-foreground">You don't have enough credits to run this API.</p>
<Button asChild size="sm" variant="outline" className="mt-3">
<Link href={`/dashboard/${workspaceId}/buy-more`}>Buy credits</Link>
</Button>
</div>
);
return "Insufficient credits. Add credits to run this API.";
}
const message =
status === 422
? "Invalid input. Check the fields above and try again."
: error instanceof Error && error.message
? error.message
: "Something went wrong running this API.";
if (status === 422) {
return "Invalid input. Check the fields above and try again.";
}
return error instanceof Error && error.message
? error.message
: "Something went wrong running this API.";
}
function EndpointCopyButton({ endpoint }: { endpoint: string }) {
const [copied, setCopied] = useState(false);
const handleCopy = () => {
navigator.clipboard.writeText(endpoint).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 1500);
});
};
return (
<div className="flex items-start gap-2 rounded-md border border-destructive/40 bg-destructive/5 p-4 text-sm text-destructive">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
<span>{message}</span>
</div>
<Button
type="button"
variant="ghost"
size="sm"
onClick={handleCopy}
className="h-auto justify-start gap-2 rounded bg-muted/40 px-2 py-1 font-mono text-xs text-muted-foreground hover:bg-muted hover:text-foreground"
>
<code>{endpoint}</code>
{copied ? <Check className="h-3.5 w-3.5" /> : <Copy className="h-3.5 w-3.5" />}
<span className="sr-only">{copied ? "Copied endpoint" : "Copy endpoint"}</span>
</Button>
);
}
@ -110,6 +127,8 @@ export function PlaygroundRunner({ workspaceId, platform, verb }: PlaygroundRunn
const [values, setValues] = useState<Record<string, unknown>>({});
const run = useRunStream(workspaceId);
const isRunning = run.status === "running";
const previousStatusRef = useRef(run.status);
const notifiedRunRef = useRef<string | null>(null);
// Seed form defaults once the schema is available.
useEffect(() => {
@ -158,6 +177,29 @@ export function PlaygroundRunner({ workspaceId, platform, verb }: PlaygroundRunn
() => (run.detail ? parseJsonlOutput(run.detail.output_text) : null),
[run.detail]
);
const endpoint = `POST /workspaces/${workspaceId}/scrapers/${platform}/${verb}`;
useEffect(() => {
const previousStatus = previousStatusRef.current;
previousStatusRef.current = run.status;
if (previousStatus !== "running") return;
if (run.status === "success") {
const key = `${run.runId ?? "run"}:success`;
if (notifiedRunRef.current === key) return;
notifiedRunRef.current = key;
toast.success("API run completed.");
return;
}
if (run.status === "error") {
const key = `${run.runId ?? "run"}:error`;
if (notifiedRunRef.current === key) return;
notifiedRunRef.current = key;
toast.error(getRunErrorMessage(run.error));
}
}, [run.status, run.runId, run.error]);
if (isLoading) {
return (
@ -186,26 +228,40 @@ export function PlaygroundRunner({ workspaceId, platform, verb }: PlaygroundRunn
return (
<div className="space-y-10">
<div className="grid gap-6 lg:grid-cols-2">
<div className="space-y-6">
{capability.description && (
<Alert>
<Info />
<AlertDescription>
<p>
{capability.description}
{capability.docs_url ? (
<>
{" "}
<Link
href={capability.docs_url}
className="font-medium text-foreground underline-offset-4 hover:underline"
>
Read docs
</Link>
{" "}for more info.
</>
) : null}
</p>
</AlertDescription>
</Alert>
)}
<div className="space-y-5">
<div>
<h1 className="text-lg font-semibold">
{catalogVerb.label} <span className="text-muted-foreground">· {platform}</span>
</h1>
{capability.description && (
<p className="mt-1 text-sm text-muted-foreground">{capability.description}</p>
)}
<code className="mt-2 inline-block rounded bg-muted/40 px-1.5 py-0.5 text-xs text-muted-foreground">
POST /workspaces/{workspaceId}/scrapers/{platform}/{verb}
</code>
<div className="mt-2 flex items-center gap-1.5 text-xs text-muted-foreground">
<Coins className="h-3.5 w-3.5" />
<div className="space-y-2">
<EndpointCopyButton endpoint={endpoint} />
<div className="text-xs text-muted-foreground">
<span>Pricing: </span>
<span className="font-medium tabular-nums text-foreground">
{formatPricing(capability.pricing)}
</span>
</div>
</div>
<SchemaForm
fields={fields}
values={values}
@ -214,23 +270,17 @@ export function PlaygroundRunner({ workspaceId, platform, verb }: PlaygroundRunn
/>
<div className="flex items-center gap-2">
<Button type="button" onClick={handleRun} disabled={isRunning} className="gap-1.5">
{isRunning ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Play className="h-4 w-4" />
)}
Run
<Button type="button" onClick={handleRun} disabled={isRunning} className="relative">
<span className={isRunning ? "opacity-0" : ""}>Run</span>
{isRunning && <Spinner size="sm" className="absolute" />}
</Button>
{isRunning && (
<Button type="button" variant="outline" onClick={run.cancel} className="gap-1.5">
<X className="h-4 w-4" />
<Button type="button" variant="secondary" onClick={run.cancel}>
Cancel
</Button>
)}
</div>
{run.status === "error" && <ErrorPanel error={run.error} workspaceId={workspaceId} />}
</div>
<div className="space-y-3">

View file

@ -1,8 +1,9 @@
"use client";
import { useInfiniteQuery } from "@tanstack/react-query";
import { ChevronDown, ChevronRight, History } from "lucide-react";
import { ChevronDown, ChevronRight, History, Info } from "lucide-react";
import { Fragment, useState } from "react";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import {
Select,
@ -67,13 +68,12 @@ export function RunsTable({ workspaceId }: { workspaceId: number }) {
return (
<div className="space-y-4">
<div>
<h1 className="text-xl font-semibold">Runs</h1>
<p className="mt-1 text-sm text-muted-foreground">
Every platform-native API run in this workspace from the playground, API keys, and
agents. Newest first.
</p>
</div>
<Alert>
<Info />
<AlertDescription>
View all API runs for this workspace, including runs from the playground, API keys, and agents.
</AlertDescription>
</Alert>
<div className="flex flex-wrap items-center gap-2">
<Select value={capability} onValueChange={setCapability}>

View file

@ -2,6 +2,7 @@
import { ChevronDown } from "lucide-react";
import { useMemo, useState } from "react";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
@ -133,7 +134,12 @@ function FieldRow({
<Label htmlFor={`field-${field.name}`} className="text-sm font-medium">
{field.title}
</Label>
{field.required && <span className="text-xs text-destructive">required</span>}
<Badge
variant={field.required ? "destructive" : "secondary"}
className="px-1.5 py-0 text-[10px]"
>
{field.required ? "required" : "optional"}
</Badge>
</div>
{field.description && (
<p className="text-xs text-muted-foreground">{field.description}</p>

View file

@ -0,0 +1,95 @@
"use client";
import { History, LayoutGrid } from "lucide-react";
import { useSelectedLayoutSegments } from "next/navigation";
import type React from "react";
import { useMemo } from "react";
import {
type RoutedSectionGroup,
type RoutedSectionItem,
RoutedSectionShell,
} from "@/components/layout";
import { PLAYGROUND_PLATFORMS } from "@/lib/playground/catalog";
interface PlaygroundLayoutShellProps {
workspaceId: string;
children: React.ReactNode;
}
export function PlaygroundLayoutShell({ workspaceId, children }: PlaygroundLayoutShellProps) {
const segments = useSelectedLayoutSegments();
const base = `/dashboard/${workspaceId}/playground`;
const topLevelItems = useMemo<RoutedSectionItem[]>(
() => [
{
value: "overview",
label: "Overview",
href: base,
icon: <LayoutGrid className="h-4 w-4" />,
},
{
value: "runs",
label: "API Runs",
href: `${base}/runs`,
icon: <History className="h-4 w-4" />,
},
],
[base]
);
const providerGroups = useMemo<RoutedSectionGroup[]>(
() =>
PLAYGROUND_PLATFORMS.map((platform) => {
const Icon = platform.icon;
return {
value: platform.id,
label: platform.label,
icon: <Icon className="h-4 w-4 shrink-0" />,
items: platform.verbs.map((verb) => ({
value: `${platform.id}/${verb.verb}`,
label: verb.label,
href: `${base}/${platform.id}/${verb.verb}`,
})),
};
}),
[base]
);
const activeValue =
segments.length >= 2
? `${segments[0]}/${segments[1]}`
: segments[0] && topLevelItems.some((item) => item.value === segments[0])
? segments[0]
: "overview";
const selectedLabel = getSelectedLabel(activeValue, topLevelItems, providerGroups);
return (
<RoutedSectionShell
title="API Playground"
items={topLevelItems}
groups={providerGroups}
activeValue={activeValue}
selectedLabel={selectedLabel}
mobileNav="drawer"
>
{children}
</RoutedSectionShell>
);
}
function getSelectedLabel(
activeValue: string,
items: RoutedSectionItem[],
groups: RoutedSectionGroup[]
): string {
const topLevelItem = items.find((item) => item.value === activeValue);
if (topLevelItem) return topLevelItem.label;
const group = groups.find((item) => item.items.some((child) => child.value === activeValue));
const child = group?.items.find((item) => item.value === activeValue);
if (group && child) return `${group.label}: ${child.label}`;
return "API Playground";
}

View file

@ -0,0 +1,15 @@
import type React from "react";
import { use } from "react";
import { PlaygroundLayoutShell } from "./layout-shell";
export default function PlaygroundLayout({
params,
children,
}: {
params: Promise<{ workspace_id: string }>;
children: React.ReactNode;
}) {
const { workspace_id } = use(params);
return <PlaygroundLayoutShell workspaceId={workspace_id}>{children}</PlaygroundLayoutShell>;
}

View file

@ -108,9 +108,8 @@ function normalizeCreditPurchase(p: CreditPurchase): UnifiedPurchase {
function formatGranted(p: UnifiedPurchase): string {
if (p.kind === "credits") {
const dollars = p.granted / 1_000_000;
// Credit packs are always whole dollars at the moment, but future
// fractional grants (refunds, partial top-ups, auto-reload) shouldn't
// silently round to "$0".
// Credit packs are always whole dollars today, but future fractional grants
// such as refunds or low-balance refills shouldn't silently round to "$0".
if (dollars >= 1) return `$${dollars.toFixed(2)} of credit`;
if (dollars > 0) return `$${dollars.toFixed(3)} of credit`;
return "$0 of credit";

View file

@ -11,14 +11,12 @@ import {
ShieldCheck,
WandSparkles,
} 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 { useMemo } from "react";
import { type RoutedSectionItem, RoutedSectionShell } from "@/components/layout";
import { usePlatform } from "@/hooks/use-platform";
import { cn } from "@/lib/utils";
export type UserSettingsTab =
| "profile"
@ -42,50 +40,49 @@ export function UserSettingsLayoutShell({ workspaceId, children }: UserSettingsL
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(
const navItems = useMemo<RoutedSectionItem[]>(
() => [
{
value: "profile" as const,
label: t("profile_nav_label"),
href: `/dashboard/${workspaceId}/user-settings/profile`,
icon: <CircleUser className="h-4 w-4" />,
},
{
value: "api-key" as const,
label: t("api_key_nav_label"),
href: `/dashboard/${workspaceId}/user-settings/api-key`,
icon: <KeyRound className="h-4 w-4" />,
},
{
value: "prompts" as const,
label: "My Prompts",
href: `/dashboard/${workspaceId}/user-settings/prompts`,
icon: <WandSparkles className="h-4 w-4" />,
},
{
value: "community-prompts" as const,
label: "Community Prompts",
href: `/dashboard/${workspaceId}/user-settings/community-prompts`,
icon: <Library className="h-4 w-4" />,
},
{
value: "agent-permissions" as const,
label: "Agent Permissions",
href: `/dashboard/${workspaceId}/user-settings/agent-permissions`,
icon: <ShieldCheck className="h-4 w-4" />,
},
{
value: "messaging-channels" as const,
label: "Messaging Channels",
href: `/dashboard/${workspaceId}/user-settings/messaging-channels`,
icon: <MessageCircle className="h-4 w-4" />,
},
{
value: "purchases" as const,
label: "Purchase History",
href: `/dashboard/${workspaceId}/user-settings/purchases`,
icon: <ReceiptText className="h-4 w-4" />,
},
...(isDesktop
@ -93,17 +90,19 @@ export function UserSettingsLayoutShell({ workspaceId, children }: UserSettingsL
{
value: "desktop" as const,
label: "App Preferences",
href: `/dashboard/${workspaceId}/user-settings/desktop`,
icon: <Monitor className="h-4 w-4" />,
},
{
value: "hotkeys" as const,
label: "Hotkeys",
href: `/dashboard/${workspaceId}/user-settings/hotkeys`,
icon: <Keyboard className="h-4 w-4" />,
},
]
: []),
],
[t, isDesktop]
[t, isDesktop, workspaceId]
);
const activeTab: UserSettingsTab =
@ -112,70 +111,15 @@ export function UserSettingsLayoutShell({ workspaceId, children }: UserSettingsL
: DEFAULT_TAB;
const selectedLabel = navItems.find((item) => item.value === activeTab)?.label ?? t("title");
const hrefFor = (tab: UserSettingsTab) => `/dashboard/${workspaceId}/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: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>
<RoutedSectionShell
title={t("title")}
items={navItems}
activeValue={activeTab}
selectedLabel={selectedLabel}
contentClassName="md:max-w-3xl"
>
{children}
</RoutedSectionShell>
);
}

View file

@ -1,13 +1,11 @@
"use client";
import { BookText, Cpu, Earth, Settings, 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";
import { useMemo } from "react";
import { type RoutedSectionItem, RoutedSectionShell } from "@/components/layout";
export type WorkspaceSettingsTab = "general" | "models" | "team-roles" | "prompts" | "public-links";
@ -24,44 +22,41 @@ export function WorkspaceSettingsLayoutShell({
}: WorkspaceSettingsLayoutShellProps) {
const t = useTranslations("workspaceSettings");
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(
const navItems = useMemo<RoutedSectionItem[]>(
() => [
{
value: "general" as const,
label: t("nav_general"),
href: `/dashboard/${workspaceId}/workspace-settings/general`,
icon: <Settings className="h-4 w-4" />,
},
{
value: "models" as const,
label: t("nav_models"),
href: `/dashboard/${workspaceId}/workspace-settings/models`,
icon: <Cpu className="h-4 w-4" />,
},
{
value: "team-roles" as const,
label: t("nav_team_roles"),
href: `/dashboard/${workspaceId}/workspace-settings/team-roles`,
icon: <UserKey className="h-4 w-4" />,
},
{
value: "prompts" as const,
label: t("nav_system_instructions"),
href: `/dashboard/${workspaceId}/workspace-settings/prompts`,
icon: <BookText className="h-4 w-4" />,
},
{
value: "public-links" as const,
label: t("nav_public_links"),
href: `/dashboard/${workspaceId}/workspace-settings/public-links`,
icon: <Earth className="h-4 w-4" />,
},
],
[t]
[t, workspaceId]
);
const activeTab: WorkspaceSettingsTab =
@ -70,71 +65,15 @@ export function WorkspaceSettingsLayoutShell({
: DEFAULT_TAB;
const selectedLabel = navItems.find((item) => item.value === activeTab)?.label ?? t("title");
const hrefFor = (tab: WorkspaceSettingsTab) =>
`/dashboard/${workspaceId}/workspace-settings/${tab}`;
return (
<section className="flex h-full min-h-[min(680px,calc(100vh-5rem))] w-full select-none flex-col gap-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>
<RoutedSectionShell
title={t("title")}
items={navItems}
activeValue={activeTab}
selectedLabel={selectedLabel}
contentClassName="md:max-w-3xl"
>
{children}
</RoutedSectionShell>
);
}

View file

@ -1,10 +0,0 @@
import { atom } from "jotai";
/**
* Whether the second-level API Playground sidebar is open. Toggled by the
* Playground nav button and kept in memory for the session, so it survives
* in-app navigation (opening a new chat won't close it) and only closes when
* the user clicks the toggle. It defaults to open, so a fresh app load a new
* signup or a relogin always starts with the playground visible.
*/
export const playgroundSidebarOpenAtom = atom<boolean>(true);

View file

@ -22,7 +22,7 @@ import {
Wrench,
X,
} from "lucide-react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { AnimatePresence, motion } from "motion/react";
import Image from "next/image";
import { useParams } from "next/navigation";
import { type FC, useCallback, useEffect, useMemo, useRef, useState } from "react";
@ -119,7 +119,7 @@ import {
} from "../new-chat/document-mention-picker";
const COMPOSER_PLACEHOLDER =
"Track competitors, scrape platforms, automate briefs / for prompts, @ for docs";
"Track competitors, scrape platforms, automate briefs. Use / for prompts, @ for docs";
type ComposerSuggestionAnchorPoint = {
left: number;
@ -986,14 +986,12 @@ const Composer: FC = () => {
* Full-color brand marks for the platform-native scraper APIs (web, Google
* Search, Google Maps, Reddit, YouTube) available in this workspace, shown beside the
* composer "+" so the user can see these native endpoints are connected. Laid
* out as a clean row (not stacked) after a hairline divider that separates them
* out as the same overlapping avatar group used by the connect-tools tray
* from the composer actions. The capability registry is the source of truth;
* icons are display-only with a status tooltip. One-time staggered entrance,
* reduced-motion aware.
* icons are display-only with a status tooltip.
*/
const ConnectedScraperIcons: FC<{ workspaceId: number }> = ({ workspaceId }) => {
const { data: capabilities } = useScraperCapabilities(workspaceId);
const reduceMotion = useReducedMotion();
const platforms = useMemo<PlaygroundPlatform[]>(() => {
if (!capabilities?.length) return [];
@ -1014,30 +1012,26 @@ const ConnectedScraperIcons: FC<{ workspaceId: number }> = ({ workspaceId }) =>
return (
<div className="hidden items-center gap-1 sm:flex">
<div aria-hidden className="h-5 w-px shrink-0 bg-border" />
<div className="flex items-center gap-0.5" aria-label="Connected data sources">
<AvatarGroup className="shrink-0">
{platforms.map((platform, i) => {
const Icon = platform.icon;
return (
<Tooltip key={platform.id}>
<TooltipTrigger asChild>
<motion.span
initial={{ opacity: 0, y: reduceMotion ? 0 : 4 }}
animate={{ opacity: 1, y: 0 }}
transition={{
duration: 0.2,
ease: [0.23, 1, 0.32, 1],
delay: reduceMotion ? 0 : i * 0.04,
}}
className="flex size-5 items-center justify-center"
<Avatar
className="size-5"
style={{ zIndex: platforms.length - i }}
>
<Icon className="size-3.5" />
</motion.span>
<AvatarFallback className="bg-popover text-[10px]">
<Icon className="size-3" />
</AvatarFallback>
</Avatar>
</TooltipTrigger>
<TooltipContent side="bottom">{platform.label} · Connected</TooltipContent>
<TooltipContent side="bottom">{platform.label} scraper available</TooltipContent>
</Tooltip>
);
})}
</div>
</AvatarGroup>
</div>
);
};

View file

@ -129,6 +129,7 @@ export const FolderNode = React.memo(function FolderNode({
const rowRef = useRef<HTMLDivElement>(null);
const [dropZone, setDropZone] = useState<DropZone | null>(null);
const [isRescanning, setIsRescanning] = useState(false);
const [dropdownOpen, setDropdownOpen] = useState(false);
const handleRescan = useCallback(async () => {
if (isRescanning) return;
@ -349,12 +350,17 @@ export const FolderNode = React.memo(function FolderNode({
)}
{!isRenaming && (
<DropdownMenu>
<DropdownMenu open={dropdownOpen} onOpenChange={setDropdownOpen}>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="hidden sm:inline-flex h-6 w-6 shrink-0 opacity-0 group-hover:opacity-100 transition-opacity"
className={cn(
"hidden sm:inline-flex h-6 w-6 shrink-0 hover:bg-transparent transition-opacity",
dropdownOpen
? "opacity-100 bg-accent hover:bg-accent"
: "opacity-0 group-hover:opacity-100"
)}
onClick={(e) => e.stopPropagation()}
>
<MoreHorizontal className="h-3.5 w-3.5 text-muted-foreground" />

View file

@ -27,7 +27,7 @@ export type HeroChatDemoScript = {
type Stage = "typing" | "steps" | "answer" | "done";
const PLACEHOLDER = "Track competitors, scrape platforms, automate briefs / for prompts, @ for docs";
const PLACEHOLDER = "Track competitors, scrape platforms, automate briefs. Use / for prompts, @ for docs";
/** Blinking caret for the typewriter (overlay only, never inside the real input). */
function Caret() {

View file

@ -9,6 +9,7 @@ export type {
User,
Workspace,
} from "./types/layout.types";
export type { RoutedSectionGroup, RoutedSectionItem } from "./ui";
export {
ChatListItem,
CreateWorkspaceDialog,
@ -20,6 +21,7 @@ export {
MobileSidebarTrigger,
NavIcon,
NavSection,
RoutedSectionShell,
Sidebar,
SidebarCollapseButton,
SidebarHeader,

View file

@ -1,14 +1,13 @@
"use client";
import { Inbox } from "lucide-react";
import { useRouter } from "next/navigation";
import type { ReactNode } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useState } from "react";
import { useAnonymousMode } from "@/contexts/anonymous-mode";
import { useLoginGate } from "@/contexts/login-gate";
import { useAnnouncements } from "@/hooks/use-announcements";
import { anonymousChatApiService } from "@/lib/apis/anonymous-chat-api.service";
import type { ChatItem, NavItem, PageUsage, Workspace } from "../types/layout.types";
import type { ChatItem, PageUsage, Workspace } from "../types/layout.types";
import { LayoutShell } from "../ui/shell";
interface FreeLayoutDataProviderProps {
@ -47,34 +46,12 @@ export function FreeLayoutDataProvider({ children }: FreeLayoutDataProviderProps
const gatedAction = useCallback((feature: string) => () => gate(feature), [gate]);
const navItems: NavItem[] = useMemo(
() =>
(
[
{
title: "Inbox",
url: "#inbox",
icon: Inbox,
isActive: false,
},
] as (NavItem | null)[]
).filter((item): item is NavItem => item !== null),
[]
);
const pageUsage: PageUsage | undefined = quota
? { pagesUsed: quota.used, pagesLimit: quota.limit }
: undefined;
const handleChatSelect = useCallback((_chat: ChatItem) => gate("view chat history"), [gate]);
const handleNavItemClick = useCallback(
(item: NavItem) => {
if (item.title === "Inbox") gate("use the inbox");
},
[gate]
);
const handleAnnouncements = useCallback(() => gate("see what's new"), [gate]);
const handleWorkspaceSelect = useCallback((_id: number) => gate("switch workspaces"), [gate]);
@ -87,8 +64,7 @@ export function FreeLayoutDataProvider({ children }: FreeLayoutDataProviderProps
onWorkspaceSettings={gatedAction("workspace settings")}
onAddWorkspace={gatedAction("create workspaces")}
workspace={GUEST_SPACE}
navItems={navItems}
onNavItemClick={handleNavItemClick}
navItems={[]}
chats={[]}
activeChatId={null}
onNewChat={resetChat}

View file

@ -1,8 +1,8 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { useAtom, useAtomValue, useSetAtom } from "jotai";
import { AlarmClock, AlertTriangle, Boxes, Inbox, SquareTerminal } from "lucide-react";
import { useAtomValue, useSetAtom } from "jotai";
import { AlarmClock, AlertTriangle, Boxes, SquareTerminal } from "lucide-react";
import { useParams, usePathname, useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { useTheme } from "next-themes";
@ -11,7 +11,6 @@ import { toast } from "sonner";
import { currentThreadAtom, resetCurrentThreadAtom } from "@/atoms/chat/current-thread.atom";
import { statusInboxItemsAtom } from "@/atoms/inbox/status-inbox.atom";
import { announcementsDialogAtom } from "@/atoms/layout/dialogs.atom";
import { playgroundSidebarOpenAtom } from "@/atoms/layout/playground.atom";
import { removeChatTabAtom, syncChatTabAtom, type Tab } from "@/atoms/tabs/tabs.atom";
import { currentUserAtom } from "@/atoms/user/user-query.atoms";
import { deleteWorkspaceMutationAtom } from "@/atoms/workspaces/workspace-mutation.atoms";
@ -43,7 +42,6 @@ import { Spinner } from "@/components/ui/spinner";
import { useActivateChatThread } from "@/hooks/use-activate-chat-thread";
import { useAnnouncements } from "@/hooks/use-announcements";
import { useInbox } from "@/hooks/use-inbox";
import { useIsMobile } from "@/hooks/use-mobile";
import { useArchiveThread, useDeleteThread, useRenameThread } from "@/hooks/use-thread-mutations";
import { notificationsApiService } from "@/lib/apis/notifications-api.service";
import { workspacesApiService } from "@/lib/apis/workspaces-api.service";
@ -60,17 +58,6 @@ interface LayoutDataProviderProps {
children: React.ReactNode;
}
/**
* Format count for display: shows numbers up to 999, then "1k+", "2k+", etc.
*/
function formatInboxCount(count: number): string {
if (count <= 999) {
return count.toString();
}
const thousands = Math.floor(count / 1000);
return `${thousands}k+`;
}
export function LayoutDataProvider({ workspaceId, children }: LayoutDataProviderProps) {
const t = useTranslations("dashboard");
const tCommon = useTranslations("common");
@ -79,8 +66,6 @@ export function LayoutDataProvider({ workspaceId, children }: LayoutDataProvider
const params = useParams();
const pathname = usePathname();
const { theme, setTheme } = useTheme();
const isMobile = useIsMobile();
const [playgroundSidebarOpen, setPlaygroundSidebarOpen] = useAtom(playgroundSidebarOpenAtom);
// Announcements
const { unreadCount: announcementUnreadCount } = useAnnouncements();
@ -125,12 +110,6 @@ export function LayoutDataProvider({ workspaceId, children }: LayoutDataProvider
enabled: !!workspaceId,
});
// Unified slide-out panel state (only one can be open at a time)
type SlideoutPanel = "inbox" | null;
const [activeSlideoutPanel, setActiveSlideoutPanel] = useState<SlideoutPanel>(null);
const isInboxSidebarOpen = activeSlideoutPanel === "inbox";
// Search space dialog state
const [isCreateWorkspaceDialogOpen, setIsCreateWorkspaceDialogOpen] = useState(false);
@ -228,17 +207,6 @@ export function LayoutDataProvider({ workspaceId, children }: LayoutDataProvider
const [isDeletingWorkspace, setIsDeletingWorkspace] = useState(false);
const [isLeavingWorkspace, setIsLeavingWorkspace] = useState(false);
// Reset transient slide-out panels when switching workspaces.
// Tabs intentionally persist across spaces — opening tabs from multiple
// workspaces is a supported flow (browser-tab semantics).
const prevWorkspaceIdRef = useRef(workspaceId);
useEffect(() => {
if (prevWorkspaceIdRef.current !== workspaceId) {
prevWorkspaceIdRef.current = workspaceId;
setActiveSlideoutPanel(null);
}
}, [workspaceId]);
const workspaces: Workspace[] = useMemo(() => {
if (!workspacesData || !Array.isArray(workspacesData)) return [];
return workspacesData.map((space) => ({
@ -318,9 +286,9 @@ export function LayoutDataProvider({ workspaceId, children }: LayoutDataProvider
}, [threadsData, workspaceId]);
// Navigation items
// Inbox, Automations, and Artifacts are rendered explicitly below "New chat"
// in the sidebar (also surfaced in the icon rail's collapsed mode via this
// list). Documents is embedded below Recents; announcements live in the avatar dropdown.
// Automations and Artifacts are rendered explicitly below "New chat"
// in the sidebar. Documents is embedded below Recents; notifications and
// announcements live in the avatar rail/dropdown.
const isAutomationsActive = pathname?.includes("/automations") === true;
const isArtifactsActive = pathname?.endsWith("/artifacts") === true;
const isPlaygroundRoute = pathname?.includes("/playground") === true;
@ -328,13 +296,6 @@ export function LayoutDataProvider({ workspaceId, children }: LayoutDataProvider
() =>
(
[
{
title: "Inbox",
url: "#inbox",
icon: Inbox,
isActive: isInboxSidebarOpen,
badge: totalUnreadCount > 0 ? formatInboxCount(totalUnreadCount) : undefined,
},
{
title: "Automations",
url: `/dashboard/${workspaceId}/automations`,
@ -351,22 +312,11 @@ export function LayoutDataProvider({ workspaceId, children }: LayoutDataProvider
title: "Playground",
url: `/dashboard/${workspaceId}/playground`,
icon: SquareTerminal,
// Mobile has no second-level sidebar: Playground is a plain link
// there, so highlight by route. Desktop highlights the toggle state.
isActive: isMobile ? isPlaygroundRoute : playgroundSidebarOpen,
isActive: isPlaygroundRoute,
},
] as (NavItem | null)[]
).filter((item): item is NavItem => item !== null),
[
isInboxSidebarOpen,
totalUnreadCount,
workspaceId,
isAutomationsActive,
isArtifactsActive,
isPlaygroundRoute,
playgroundSidebarOpen,
isMobile,
]
[workspaceId, isAutomationsActive, isArtifactsActive, isPlaygroundRoute]
);
// Handlers
@ -503,22 +453,9 @@ export function LayoutDataProvider({ workspaceId, children }: LayoutDataProvider
const handleNavItemClick = useCallback(
(item: NavItem) => {
if (item.url === "#inbox") {
setActiveSlideoutPanel((prev) => (prev === "inbox" ? null : "inbox"));
return;
}
// Desktop: Playground is a persistent toggle, not a plain link — it just
// opens the second-level sidebar (which holds the whole API playground)
// and only closes on a second click, never navigating away from the
// current page (e.g. a new chat). Mobile has no second-level sidebar,
// so there it navigates to the playground index page instead.
if (item.url.endsWith("/playground") && !isMobile) {
setPlaygroundSidebarOpen((prev) => !prev);
return;
}
router.push(item.url);
},
[router, setPlaygroundSidebarOpen, setActiveSlideoutPanel, isMobile]
[router]
);
const handleNewChat = useCallback(() => {
@ -688,7 +625,6 @@ export function LayoutDataProvider({ workspaceId, children }: LayoutDataProvider
const isPlaygroundPage = pathname?.includes("/playground") === true;
const isAllChatsPage = pathname?.endsWith("/chats") === true;
const handleViewAllChats = useCallback(() => {
setActiveSlideoutPanel(null);
router.push(
isAllChatsPage ? `/dashboard/${workspaceId}/new-chat` : `/dashboard/${workspaceId}/chats`
);
@ -741,7 +677,6 @@ export function LayoutDataProvider({ workspaceId, children }: LayoutDataProvider
setTheme={setTheme}
isChatPage={isChatPage}
isAllChatsPage={isAllChatsPage}
showPlaygroundSidebar={playgroundSidebarOpen}
useWorkspacePanel={useWorkspacePanel}
workspacePanelViewportClassName={
isUserSettingsPage ||
@ -755,23 +690,15 @@ export function LayoutDataProvider({ workspaceId, children }: LayoutDataProvider
: undefined
}
workspacePanelContentClassName={
isAutomationsPage || isPlaygroundPage
? "max-w-none select-none"
: isAllChatsPage
? "max-w-5xl"
: isUserSettingsPage || isWorkspaceSettingsPage || isTeamPage || isArtifactsPage
? "max-w-5xl"
: undefined
useWorkspacePanel ? "max-w-5xl select-none" : undefined
}
isLoadingChats={isLoadingThreads}
activeSlideoutPanel={activeSlideoutPanel}
onSlideoutPanelChange={setActiveSlideoutPanel}
inbox={{
isOpen: isInboxSidebarOpen,
notifications={{
totalUnreadCount,
comments: {
items: commentsInbox.inboxItems,
unreadCount: commentsInbox.unreadCount,
totalCount: commentsInbox.totalCount,
loading: commentsInbox.loading,
loadingMore: commentsInbox.loadingMore,
hasMore: commentsInbox.hasMore,
@ -782,6 +709,7 @@ export function LayoutDataProvider({ workspaceId, children }: LayoutDataProvider
status: {
items: statusInbox.inboxItems,
unreadCount: statusInbox.unreadCount,
totalCount: statusInbox.totalCount,
loading: statusInbox.loading,
loadingMore: statusInbox.loadingMore,
hasMore: statusInbox.hasMore,

View file

@ -0,0 +1,267 @@
"use client";
import { ChevronRight } from "lucide-react";
import Link from "next/link";
import type React from "react";
import { useCallback, useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import {
Drawer,
DrawerContent,
DrawerHandle,
DrawerTitle,
DrawerTrigger,
} from "@/components/ui/drawer";
import { Separator } from "@/components/ui/separator";
import { cn } from "@/lib/utils";
export interface RoutedSectionItem {
value: string;
label: string;
href: string;
icon?: React.ReactNode;
}
export interface RoutedSectionGroup {
value: string;
label: string;
icon?: React.ReactNode;
items: RoutedSectionItem[];
}
interface RoutedSectionShellProps {
title: string;
items: RoutedSectionItem[];
activeValue: string;
selectedLabel: string;
children: React.ReactNode;
groups?: RoutedSectionGroup[];
mobileNav?: "scroll" | "drawer";
contentClassName?: string;
}
function findActiveGroupValue(groups: RoutedSectionGroup[], activeValue: string): string | null {
return (
groups.find((group) => group.items.some((item) => item.value === activeValue))?.value ?? null
);
}
function SectionNavLink({
item,
activeValue,
onNavigate,
}: {
item: RoutedSectionItem;
activeValue: string;
onNavigate?: () => void;
}) {
const isActive = activeValue === item.value;
return (
<Link
href={item.href}
replace
scroll={false}
prefetch
onClick={onNavigate}
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",
isActive
? "bg-accent text-accent-foreground"
: "bg-transparent text-muted-foreground hover:bg-accent hover:text-accent-foreground"
)}
>
{item.icon}
<span className="min-w-0 truncate">{item.label}</span>
</Link>
);
}
function SectionNavGroup({
group,
activeValue,
isExpanded,
onToggle,
onNavigate,
}: {
group: RoutedSectionGroup;
activeValue: string;
isExpanded: boolean;
onToggle: () => void;
onNavigate?: () => void;
}) {
return (
<div className="flex flex-col gap-0.5">
<button
type="button"
aria-expanded={isExpanded}
onClick={onToggle}
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",
isExpanded
? "text-accent-foreground"
: "text-muted-foreground hover:bg-accent hover:text-accent-foreground"
)}
>
{group.icon}
<span className="min-w-0 truncate">{group.label}</span>
<ChevronRight
className={cn("ml-auto h-4 w-4 shrink-0 transition-transform", isExpanded && "rotate-90")}
/>
</button>
<div
className={cn(
"grid overflow-hidden transition-[grid-template-rows,opacity] duration-200 ease-out",
isExpanded ? "grid-rows-[1fr] opacity-100" : "grid-rows-[0fr] opacity-0"
)}
aria-hidden={!isExpanded}
>
<div className="min-h-0 overflow-hidden">
<div className="flex flex-col gap-0.5 pl-10">
{group.items.map((item) => (
<SectionNavLink
key={item.value}
item={item}
activeValue={activeValue}
onNavigate={onNavigate}
/>
))}
</div>
</div>
</div>
</div>
);
}
export function RoutedSectionShell({
title,
items,
activeValue,
selectedLabel,
children,
groups = [],
mobileNav = "scroll",
contentClassName,
}: RoutedSectionShellProps) {
const [tabScrollPos, setTabScrollPos] = useState<"start" | "middle" | "end">("start");
const [drawerOpen, setDrawerOpen] = useState(false);
const [expandedGroup, setExpandedGroup] = useState<string | null>(() =>
findActiveGroupValue(groups, activeValue)
);
useEffect(() => {
const activeGroup = findActiveGroupValue(groups, activeValue);
if (activeGroup) {
setExpandedGroup(activeGroup);
}
}, [activeValue, groups]);
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 renderNav = (onNavigate?: () => void) => (
<>
{items.map((item) => (
<SectionNavLink
key={item.value}
item={item}
activeValue={activeValue}
onNavigate={onNavigate}
/>
))}
{groups.length > 0 && (
<>
<Separator className="my-3 bg-border" />
<div className="flex flex-col gap-0.5">
{groups.map((group) => (
<SectionNavGroup
key={group.value}
group={group}
activeValue={activeValue}
isExpanded={expandedGroup === group.value}
onToggle={() =>
setExpandedGroup((current) => (current === group.value ? null : group.value))
}
onNavigate={onNavigate}
/>
))}
</div>
</>
)}
</>
);
return (
<section className="flex h-full min-h-[min(680px,calc(100vh-5rem))] w-full select-none flex-col gap-6 md:flex-row">
<div className="md:w-[220px] md:shrink-0">
<h1 className="mb-4 px-1 text-xl font-semibold tracking-tight text-foreground md:text-2xl">
{title}
</h1>
<nav className="hidden flex-col gap-0.5 md:flex">{renderNav()}</nav>
{mobileNav === "drawer" ? (
<Drawer open={drawerOpen} onOpenChange={setDrawerOpen} shouldScaleBackground={false}>
<DrawerTrigger asChild>
<Button
type="button"
variant="outline"
className="flex h-10 w-full justify-between bg-transparent px-3 hover:bg-accent md:hidden"
>
<span className="truncate">{selectedLabel}</span>
<ChevronRight className="h-4 w-4 rotate-90 text-muted-foreground" />
</Button>
</DrawerTrigger>
<DrawerContent className="h-[88vh] overflow-hidden rounded-t-2xl border bg-popover text-popover-foreground">
<DrawerHandle className="mt-3 h-1.5 w-10" />
<DrawerTitle className="sr-only">{title} navigation</DrawerTitle>
<nav className="flex min-h-0 flex-1 flex-col gap-0.5 overflow-y-auto p-4">
{renderNav(() => setDrawerOpen(false))}
</nav>
</DrawerContent>
</Drawer>
) : (
<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">
{items.map((item) => (
<Link
key={item.value}
href={item.href}
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",
activeValue === 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={cn("min-w-0 pt-4", contentClassName)}>{children}</div>
</div>
</section>
);
}

View file

@ -6,6 +6,10 @@ import { ScrollArea } from "@/components/ui/scroll-area";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import type { NavItem, User, Workspace } from "../../types/layout.types";
import {
NotificationsDropdown,
type NotificationsDropdownData,
} from "../sidebar/NotificationsDropdown";
import { SidebarUserProfile } from "../sidebar/SidebarUserProfile";
import { WorkspaceAvatar } from "./WorkspaceAvatar";
@ -24,6 +28,7 @@ interface IconRailProps {
onUserSettings?: () => void;
onAnnouncements?: () => void;
announcementUnreadCount?: number;
notifications?: NotificationsDropdownData;
onLogout?: () => void;
theme?: string;
setTheme?: (theme: "light" | "dark" | "system") => void;
@ -45,6 +50,7 @@ export function IconRail({
onUserSettings,
onAnnouncements,
announcementUnreadCount = 0,
notifications,
onLogout,
theme,
setTheme,
@ -145,6 +151,9 @@ export function IconRail({
isCollapsed
theme={theme}
setTheme={setTheme}
topContent={
notifications ? <NotificationsDropdown notifications={notifications} /> : undefined
}
/>
</div>
);

View file

@ -1,6 +1,8 @@
export { CreateWorkspaceDialog } from "./dialogs";
export { Header } from "./header";
export { IconRail, NavIcon, WorkspaceAvatar } from "./icon-rail";
export type { RoutedSectionGroup, RoutedSectionItem } from "./RoutedSectionShell";
export { RoutedSectionShell } from "./RoutedSectionShell";
export { LayoutShell } from "./shell";
export {
ChatListItem,

View file

@ -1,14 +1,12 @@
"use client";
import { useAtomValue } from "jotai";
import { AnimatePresence, motion } from "motion/react";
import dynamic from "next/dynamic";
import { useCallback, useMemo, useState } from "react";
import { useMemo, useState } from "react";
import { activeTabAtom, type Tab } from "@/atoms/tabs/tabs.atom";
import { Logo } from "@/components/Logo";
import { Spinner } from "@/components/ui/spinner";
import { TooltipProvider } from "@/components/ui/tooltip";
import type { InboxItem } from "@/hooks/use-inbox";
import { useIsMobile } from "@/hooks/use-mobile";
import { useElectronAPI } from "@/hooks/use-platform";
import { cn } from "@/lib/utils";
@ -27,14 +25,12 @@ import {
RightPanelToggleButton,
} from "../right-panel/RightPanel";
import {
InboxSidebarContent,
MobileSidebar,
MobileSidebarTrigger,
PlaygroundSidebar,
Sidebar,
SidebarCollapseButton,
} from "../sidebar";
import { SidebarSlideOutPanel } from "../sidebar/SidebarSlideOutPanel";
import type { NotificationsDropdownData } from "../sidebar/NotificationsDropdown";
import { TabBar } from "../tabs/TabBar";
import { WorkspacePanel } from "./WorkspacePanel";
@ -80,28 +76,6 @@ function MacDesktopTitleBar({
);
}
// Per-tab data source
interface TabDataSource {
items: InboxItem[];
unreadCount: number;
loading: boolean;
loadingMore: boolean;
hasMore: boolean;
loadMore: () => void;
markAsRead: (id: number) => Promise<boolean>;
markAllAsRead: () => Promise<boolean>;
}
export type ActiveSlideoutPanel = "inbox" | null;
// Inbox-related props — per-tab data sources with independent loading/pagination
interface InboxProps {
isOpen: boolean;
totalUnreadCount: number;
comments: TabDataSource;
status: TabDataSource;
}
interface LayoutShellProps {
workspaces: Workspace[];
activeWorkspaceId: number | null;
@ -134,17 +108,12 @@ interface LayoutShellProps {
defaultCollapsed?: boolean;
isChatPage?: boolean;
isAllChatsPage?: boolean;
showPlaygroundSidebar?: boolean;
useWorkspacePanel?: boolean;
workspacePanelViewportClassName?: string;
workspacePanelContentClassName?: string;
children: React.ReactNode;
className?: string;
// Unified slide-out panel state
activeSlideoutPanel?: ActiveSlideoutPanel;
onSlideoutPanelChange?: (panel: ActiveSlideoutPanel) => void;
// Inbox props
inbox?: InboxProps;
notifications?: NotificationsDropdownData;
isLoadingChats?: boolean;
onTabSwitch?: (tab: Tab) => void;
onTabPrefetch?: (tab: Tab) => void;
@ -239,15 +208,12 @@ export function LayoutShell({
defaultCollapsed = false,
isChatPage = false,
isAllChatsPage = false,
showPlaygroundSidebar = false,
useWorkspacePanel = false,
workspacePanelViewportClassName,
workspacePanelContentClassName,
children,
className,
activeSlideoutPanel = null,
onSlideoutPanelChange,
inbox,
notifications,
isLoadingChats = false,
onTabSwitch,
onTabPrefetch,
@ -269,17 +235,6 @@ export function LayoutShell({
[isCollapsed, setIsCollapsed, toggleCollapsed]
);
const closeSlideout = useCallback(
(open: boolean) => {
if (!open) onSlideoutPanelChange?.(null);
},
[onSlideoutPanelChange]
);
const anySlideOutOpen = activeSlideoutPanel !== null;
const panelAriaLabel = activeSlideoutPanel === "inbox" ? "Inbox" : "Panel";
// Mobile layout
if (isMobile) {
return (
@ -316,6 +271,7 @@ export function LayoutShell({
onUserSettings={onUserSettings}
onAnnouncements={onAnnouncements}
announcementUnreadCount={announcementUnreadCount}
notifications={notifications}
onLogout={onLogout}
pageUsage={pageUsage}
theme={theme}
@ -335,34 +291,6 @@ export function LayoutShell({
{children}
</main>
)}
{/* Mobile unified slide-out panel */}
<SidebarSlideOutPanel
open={anySlideOutOpen}
onOpenChange={closeSlideout}
ariaLabel={panelAriaLabel}
>
<AnimatePresence mode="popLayout" initial={false}>
{activeSlideoutPanel === "inbox" && inbox && (
<motion.div
key="inbox"
className="h-full flex flex-col"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15 }}
>
<InboxSidebarContent
onOpenChange={(open) => closeSlideout(open)}
comments={inbox.comments}
status={inbox.status}
totalUnreadCount={inbox.totalUnreadCount}
onCloseMobileSidebar={() => setMobileMenuOpen(false)}
/>
</motion.div>
)}
</AnimatePresence>
</SidebarSlideOutPanel>
</div>
</TooltipProvider>
</SidebarProvider>
@ -400,36 +328,18 @@ export function LayoutShell({
onUserSettings={onUserSettings}
onAnnouncements={onAnnouncements}
announcementUnreadCount={announcementUnreadCount}
notifications={notifications}
onLogout={onLogout}
theme={theme}
setTheme={setTheme}
/>
</div>
{/* Playground second-level sidebar contextual, desktop only. Sits
between the icon rail and the main sidebar. On Mac it becomes the
leftmost panel, so it takes the rounded-corner/left-border treatment. */}
{showPlaygroundSidebar && activeWorkspaceId != null && (
<div
className={cn(
"relative hidden md:flex shrink-0 z-20 -mr-2 bg-panel",
isMacDesktop ? "rounded-tl-xl border-t border-r border-l" : "border-r"
)}
>
<PlaygroundSidebar workspaceId={activeWorkspaceId} />
</div>
)}
{/* Sidebar + slide-out panels share one container; overflow visible so panels can overlay main content. Negative right margin closes the flex gap so the sidebar sits flush against the main panel, separated only by a border. */}
<div
className={cn(
"relative hidden md:flex shrink-0 z-20 -mr-2 bg-panel",
isMacDesktop
? cn(
"border-t border-r",
!showPlaygroundSidebar && "rounded-tl-xl border-l"
)
: "border-r"
isMacDesktop ? "rounded-tl-xl border-l border-t border-r" : "border-r"
)}
>
<Sidebar
@ -467,7 +377,7 @@ export function LayoutShell({
}
className={cn(
"flex shrink-0",
isMacDesktop && !showPlaygroundSidebar && "rounded-tl-xl"
isMacDesktop && "rounded-tl-xl"
)}
isLoadingChats={isLoadingChats}
sidebarWidth={sidebarWidth}
@ -491,33 +401,6 @@ export function LayoutShell({
)}
/>
)}
{/* Unified slide-out panel — shell stays open, content cross-fades */}
<SidebarSlideOutPanel
open={anySlideOutOpen}
onOpenChange={closeSlideout}
ariaLabel={panelAriaLabel}
>
<AnimatePresence mode="popLayout" initial={false}>
{activeSlideoutPanel === "inbox" && inbox && (
<motion.div
key="inbox"
className="h-full flex flex-col"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15 }}
>
<InboxSidebarContent
onOpenChange={(open) => closeSlideout(open)}
comments={inbox.comments}
status={inbox.status}
totalUnreadCount={inbox.totalUnreadCount}
/>
</motion.div>
)}
</AnimatePresence>
</SidebarSlideOutPanel>
</div>
<DesktopWorkspaceRegion>

View file

@ -47,6 +47,7 @@ export function ChatListItem({
const dropdownOpen = controlledOpen ?? internalOpen;
const setDropdownOpen = onDropdownOpenChange ?? setInternalOpen;
const animatedName = useTypewriter(name);
const isHighlighted = isActive || dropdownOpen;
const { handlers: longPressHandlers, wasLongPress } = useLongPress(
useCallback(() => setDropdownOpen(true), [setDropdownOpen])
@ -67,9 +68,9 @@ export function ChatListItem({
onFocus={onPrefetch}
{...(isMobile ? longPressHandlers : {})}
className={sidebarListItemClassName({
active: isActive,
active: isHighlighted,
className:
"justify-start gap-2 overflow-hidden px-2 py-1.5 font-normal focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
"justify-start gap-2 overflow-hidden px-2 py-1.5 font-normal group-hover/item:bg-accent group-hover/item:text-accent-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
})}
>
<span className="min-w-0 flex-1 truncate">{animatedName}</span>
@ -79,7 +80,7 @@ export function ChatListItem({
<div
className={cn(
"pointer-events-none absolute right-0 top-0 bottom-0 flex items-center pr-1 pl-6 rounded-r-md",
isActive
isHighlighted
? "bg-gradient-to-l from-accent from-60% to-transparent"
: "bg-gradient-to-l from-sidebar from-60% to-transparent group-hover/item:from-accent",
isMobile

View file

@ -27,7 +27,7 @@ function formatUsd(micros: number): string {
* premium-credit meters. Values come from Zero (live-replicated from Postgres)
* as integer micro-USD (1_000_000 == $1.00). A low-balance warning highlights
* the amount when it falls below $0.50 so the user knows to top up or enable
* auto-reload.
* automatic top-ups.
*/
export function CreditBalanceDisplay() {
const isAnonymous = useIsAnonymous();
@ -46,7 +46,7 @@ export function CreditBalanceDisplay() {
"font-medium tabular-nums",
isLow ? "text-amber-600 dark:text-amber-500" : "text-foreground"
)}
title={isLow ? "Low balance — buy credits or enable auto-reload" : undefined}
title={isLow ? "Low balance: add credits or enable top-ups" : undefined}
>
{formatUsd(balanceMicros)}
</span>

View file

@ -3,6 +3,7 @@
import { useQuery } from "@rocicorp/zero/react";
import { useAtom, useAtomValue, useSetAtom } from "jotai";
import {
Check,
FolderInput,
FolderPlus,
FolderSync,
@ -50,6 +51,7 @@ import {
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { Drawer, DrawerContent, DrawerHandle, DrawerTitle } from "@/components/ui/drawer";
import {
DropdownMenu,
DropdownMenuCheckboxItem,
@ -69,6 +71,7 @@ import { EnumConnectorName } from "@/contracts/enums/connector";
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
import type { SearchSourceConnector } from "@/contracts/types/connector.types";
import type { DocumentTypeEnum } from "@/contracts/types/document.types";
import { useIsMobile } from "@/hooks/use-mobile";
import { useElectronAPI, usePlatform } from "@/hooks/use-platform";
import { documentsApiService } from "@/lib/apis/documents-api.service";
import { foldersApiService } from "@/lib/apis/folders-api.service";
@ -127,60 +130,111 @@ export function EmbeddedDocumentsMenu({
onToggleType: (type: DocumentTypeEnum, checked: boolean) => void;
onCreateFolder: () => void;
}) {
const isMobile = useIsMobile();
const [filterDrawerOpen, setFilterDrawerOpen] = useState(false);
const documentTypes = useMemo(
() => Object.keys(typeCounts).sort() as DocumentTypeEnum[],
[typeCounts]
);
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="relative h-7 w-7 text-muted-foreground hover:bg-accent hover:text-accent-foreground"
aria-label="Document actions"
<>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="relative h-6 w-6 text-muted-foreground hover:bg-accent hover:text-accent-foreground"
aria-label="Document actions"
>
<SlidersVertical className="size-3.5" />
{activeTypes.length > 0 ? (
<span className="absolute right-0.5 top-0.5 h-1.5 w-1.5 rounded-full bg-primary" />
) : null}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-44">
<DropdownMenuItem onSelect={onCreateFolder}>
<FolderPlus className="h-4 w-4" />
New folder
</DropdownMenuItem>
{isMobile ? (
<DropdownMenuItem onSelect={() => setFilterDrawerOpen(true)}>
<ListFilter className="h-4 w-4" />
<span className="flex-1">Filter by type</span>
</DropdownMenuItem>
) : (
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<ListFilter className="h-4 w-4" />
Filter by type
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="w-52 max-h-72 overflow-y-auto">
{documentTypes.length > 0 ? (
documentTypes.map((type) => (
<DropdownMenuCheckboxItem
key={type}
checked={activeTypes.includes(type)}
onCheckedChange={(checked) => onToggleType(type, checked === true)}
onSelect={(event) => event.preventDefault()}
>
{getDocumentTypeIcon(type, "h-4 w-4")}
<span className="min-w-0 flex-1 truncate">{getDocumentTypeLabel(type)}</span>
<span className="ml-auto text-xs text-muted-foreground">
{typeCounts[type] ?? 0}
</span>
</DropdownMenuCheckboxItem>
))
) : (
<DropdownMenuItem disabled>No document types</DropdownMenuItem>
)}
</DropdownMenuSubContent>
</DropdownMenuSub>
)}
</DropdownMenuContent>
</DropdownMenu>
<Drawer
open={filterDrawerOpen}
onOpenChange={setFilterDrawerOpen}
shouldScaleBackground={false}
>
<DrawerContent
className="z-80 max-h-[75vh] rounded-t-2xl border bg-popover text-popover-foreground"
overlayClassName="z-80"
>
<SlidersVertical className="h-3.5 w-3.5" />
{activeTypes.length > 0 ? (
<span className="absolute right-0.5 top-0.5 h-1.5 w-1.5 rounded-full bg-primary" />
) : null}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-44">
<DropdownMenuItem onSelect={onCreateFolder}>
<FolderPlus className="h-4 w-4" />
New folder
</DropdownMenuItem>
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<ListFilter className="h-4 w-4" />
<DrawerHandle className="mt-3 h-1.5 w-10" />
<DrawerTitle className="px-4 pb-2 pt-3 text-center text-base font-semibold">
Filter by type
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="w-52 max-h-72 overflow-y-auto">
</DrawerTitle>
<div className="px-4 pb-6 pt-1">
{documentTypes.length > 0 ? (
documentTypes.map((type) => (
<DropdownMenuCheckboxItem
key={type}
checked={activeTypes.includes(type)}
onCheckedChange={(checked) => onToggleType(type, checked === true)}
onSelect={(event) => event.preventDefault()}
>
{getDocumentTypeIcon(type, "h-4 w-4")}
<span className="min-w-0 flex-1 truncate">{getDocumentTypeLabel(type)}</span>
<span className="ml-auto text-xs text-muted-foreground">
{typeCounts[type] ?? 0}
</span>
</DropdownMenuCheckboxItem>
))
documentTypes.map((type, index) => {
const isActive = activeTypes.includes(type);
return (
<div key={type}>
{index > 0 && <div className="mx-3 h-px bg-popover-border" />}
<button
type="button"
className="flex h-12 w-full items-center gap-3 rounded-lg px-3 text-left text-sm transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
onClick={() => onToggleType(type, !isActive)}
>
{getDocumentTypeIcon(type, "h-4 w-4")}
<span className="min-w-0 flex-1 truncate">{getDocumentTypeLabel(type)}</span>
<span className="text-xs text-muted-foreground">{typeCounts[type] ?? 0}</span>
{isActive && <Check className="h-4 w-4 shrink-0 text-primary" />}
</button>
</div>
);
})
) : (
<DropdownMenuItem disabled>No document types</DropdownMenuItem>
<p className="px-3 py-4 text-sm text-muted-foreground">No document types</p>
)}
</DropdownMenuSubContent>
</DropdownMenuSub>
</DropdownMenuContent>
</DropdownMenu>
</div>
</DrawerContent>
</Drawer>
</>
);
}
@ -230,10 +284,10 @@ export function EmbeddedImportMenu({
type="button"
variant="ghost"
size="icon"
className="h-7 w-7 text-muted-foreground hover:bg-accent hover:text-accent-foreground"
className="h-6 w-6 text-muted-foreground hover:bg-accent hover:text-accent-foreground"
aria-label="Import documents"
>
<FolderInput className="h-3.5 w-3.5" />
<FolderInput className="size-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
@ -1213,7 +1267,7 @@ function AuthenticatedDocumentsSidebarBase({
defaultOpen={true}
contentClassName="px-0"
persistentAction={
<div className="flex items-center gap-0.5">
<div className="flex items-center gap-1.5">
<EmbeddedImportMenu onFolderWatched={refreshWatchedIds} />
<EmbeddedDocumentsMenu
typeCounts={typeCounts}
@ -1389,7 +1443,7 @@ function AnonymousDocumentsSidebar({ embedded = false }: DocumentsSidebarProps)
defaultOpen={true}
contentClassName="px-0"
persistentAction={
<div className="flex items-center gap-0.5">
<div className="flex items-center gap-1.5">
<EmbeddedImportMenu gate={gate} />
<EmbeddedDocumentsMenu
typeCounts={hasDoc ? { FILE: 1 } : {}}

File diff suppressed because it is too large Load diff

View file

@ -6,6 +6,7 @@ import { ScrollArea } from "@/components/ui/scroll-area";
import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet";
import type { ChatItem, NavItem, PageUsage, User, Workspace } from "../../types/layout.types";
import { WorkspaceAvatar } from "../icon-rail/WorkspaceAvatar";
import { NotificationsDropdown, type NotificationsDropdownData } from "./NotificationsDropdown";
import { Sidebar } from "./Sidebar";
import { SidebarUserProfile } from "./SidebarUserProfile";
@ -35,6 +36,7 @@ interface MobileSidebarProps {
onUserSettings?: () => void;
onAnnouncements?: () => void;
announcementUnreadCount?: number;
notifications?: NotificationsDropdownData;
onLogout?: () => void;
pageUsage?: PageUsage;
theme?: string;
@ -83,6 +85,7 @@ export function MobileSidebar({
onUserSettings,
onAnnouncements,
announcementUnreadCount = 0,
notifications,
onLogout,
pageUsage,
theme,
@ -161,11 +164,19 @@ export function MobileSidebar({
isCollapsed
theme={theme}
setTheme={setTheme}
topContent={
notifications ? (
<NotificationsDropdown
notifications={notifications}
onCloseMobileSidebar={() => onOpenChange(false)}
/>
) : undefined
}
/>
</div>
{/* Sidebar Content - right side */}
<div className="flex-1 overflow-hidden flex flex-col [&>*]:!w-full">
<div className="flex-1 overflow-hidden flex flex-col border-r [&>*]:!w-full">
<Sidebar
workspace={workspace}
isCollapsed={false}

View file

@ -0,0 +1,460 @@
"use client";
import { useAtom } from "jotai";
import { Bell } from "lucide-react";
import { useRouter } from "next/navigation";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { setTargetCommentIdAtom } from "@/atoms/chat/current-thread.atom";
import { Button } from "@/components/ui/button";
import {
Drawer,
DrawerContent,
DrawerHandle,
DrawerTitle,
DrawerTrigger,
} from "@/components/ui/drawer";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Skeleton } from "@/components/ui/skeleton";
import { Spinner } from "@/components/ui/spinner";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import {
isCommentReplyMetadata,
isInsufficientCreditsMetadata,
isNewMentionMetadata,
} from "@/contracts/types/inbox.types";
import type { InboxItem } from "@/hooks/use-inbox";
import { useIsMobile } from "@/hooks/use-mobile";
import { cn } from "@/lib/utils";
export interface NotificationsDataSource {
items: InboxItem[];
unreadCount: number;
totalCount?: number;
loading: boolean;
loadingMore?: boolean;
hasMore?: boolean;
loadMore?: () => void;
markAsRead: (id: number) => Promise<boolean>;
markAllAsRead: () => Promise<boolean>;
}
export interface NotificationsDropdownData {
totalUnreadCount: number;
comments: NotificationsDataSource;
status: NotificationsDataSource;
}
interface NotificationsDropdownProps {
notifications: NotificationsDropdownData;
onCloseMobileSidebar?: () => void;
}
type NotificationFilter = "all" | "mentions" | "unread";
function formatNotificationCount(count: number): string {
if (count <= 999) {
return count.toString();
}
const thousands = Math.floor(count / 1000);
return `${thousands}k+`;
}
function formatTime(dateString: string): string {
try {
const date = new Date(dateString);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMins = Math.floor(diffMs / (1000 * 60));
const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
if (diffMins < 1) return "now";
if (diffMins < 60) return `${diffMins}m ago`;
if (diffHours < 24) return `${diffHours}h ago`;
if (diffDays < 7) return `${diffDays}d ago`;
return `${Math.floor(diffDays / 7)}w ago`;
} catch {
return "now";
}
}
function isCommentNotification(item: InboxItem): boolean {
return item.type === "new_mention" || item.type === "comment_reply";
}
export function NotificationsDropdown({
notifications,
onCloseMobileSidebar,
}: NotificationsDropdownProps) {
const router = useRouter();
const isMobile = useIsMobile();
const [, setTargetCommentId] = useAtom(setTargetCommentIdAtom);
const [open, setOpen] = useState(false);
const [activeFilter, setActiveFilter] = useState<NotificationFilter>("all");
const [markingAsReadId, setMarkingAsReadId] = useState<number | null>(null);
const [markingAllAsRead, setMarkingAllAsRead] = useState(false);
const scrollContainerRef = useRef<HTMLDivElement>(null);
const loadMoreTriggerRef = useRef<HTMLDivElement>(null);
const unreadLabel = formatNotificationCount(notifications.totalUnreadCount);
const allCount =
(notifications.comments.totalCount ?? 0) + (notifications.status.totalCount ?? 0);
const mentionsCount = notifications.comments.totalCount ?? notifications.comments.items.length;
const visibleUnreadCount =
activeFilter === "mentions"
? notifications.comments.unreadCount
: notifications.totalUnreadCount;
const isLoading =
activeFilter === "mentions"
? notifications.comments.loading
: notifications.comments.loading || notifications.status.loading;
const isLoadingMore =
activeFilter === "mentions"
? !!notifications.comments.loadingMore
: !!notifications.comments.loadingMore || !!notifications.status.loadingMore;
const hasMore =
activeFilter === "mentions"
? !!notifications.comments.hasMore
: !!notifications.comments.hasMore || !!notifications.status.hasMore;
const items = useMemo(() => {
const sourceItems =
activeFilter === "mentions"
? notifications.comments.items
: [...notifications.comments.items, ...notifications.status.items];
return sourceItems
.filter((item) => activeFilter !== "unread" || !item.read)
.toSorted((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime());
}, [activeFilter, notifications.comments.items, notifications.status.items]);
const loadMoreForActiveFilter = useCallback(() => {
if (isLoadingMore) return;
if (activeFilter === "mentions") {
if (notifications.comments.hasMore) {
notifications.comments.loadMore?.();
}
return;
}
if (notifications.comments.hasMore) {
notifications.comments.loadMore?.();
}
if (notifications.status.hasMore) {
notifications.status.loadMore?.();
}
}, [activeFilter, isLoadingMore, notifications.comments, notifications.status]);
useEffect(() => {
if (!open || isLoading || isLoadingMore || !hasMore) return;
const root = scrollContainerRef.current;
const target = loadMoreTriggerRef.current;
if (!root || !target) return;
const observer = new IntersectionObserver(
(entries) => {
if (entries[0]?.isIntersecting) {
loadMoreForActiveFilter();
}
},
{
root,
rootMargin: "120px",
threshold: 0,
}
);
observer.observe(target);
return () => observer.disconnect();
}, [hasMore, isLoading, isLoadingMore, loadMoreForActiveFilter, open]);
const markItemAsRead = useCallback(
async (item: InboxItem) => {
if (item.read) return;
setMarkingAsReadId(item.id);
try {
await (isCommentNotification(item)
? notifications.comments.markAsRead(item.id)
: notifications.status.markAsRead(item.id));
} finally {
setMarkingAsReadId(null);
}
},
[notifications.comments, notifications.status]
);
const handleItemClick = useCallback(
async (item: InboxItem) => {
await markItemAsRead(item);
if (item.type === "new_mention" && isNewMentionMetadata(item.metadata)) {
const threadId = item.metadata.thread_id;
const commentId = item.metadata.comment_id;
if (item.workspace_id && threadId) {
if (commentId) setTargetCommentId(commentId);
setOpen(false);
onCloseMobileSidebar?.();
router.push(
commentId
? `/dashboard/${item.workspace_id}/new-chat/${threadId}?commentId=${commentId}`
: `/dashboard/${item.workspace_id}/new-chat/${threadId}`
);
}
return;
}
if (item.type === "comment_reply" && isCommentReplyMetadata(item.metadata)) {
const threadId = item.metadata.thread_id;
const replyId = item.metadata.reply_id;
if (item.workspace_id && threadId) {
if (replyId) setTargetCommentId(replyId);
setOpen(false);
onCloseMobileSidebar?.();
router.push(
replyId
? `/dashboard/${item.workspace_id}/new-chat/${threadId}?commentId=${replyId}`
: `/dashboard/${item.workspace_id}/new-chat/${threadId}`
);
}
return;
}
if (item.type === "insufficient_credits" && isInsufficientCreditsMetadata(item.metadata)) {
if (item.metadata.action_url) {
setOpen(false);
onCloseMobileSidebar?.();
router.push(item.metadata.action_url);
}
}
},
[markItemAsRead, onCloseMobileSidebar, router, setTargetCommentId]
);
const handleMarkAllAsRead = useCallback(async () => {
if (visibleUnreadCount === 0 || markingAllAsRead) return;
setMarkingAllAsRead(true);
try {
if (activeFilter === "mentions") {
await notifications.comments.markAllAsRead();
} else {
await Promise.all([
notifications.comments.markAllAsRead(),
notifications.status.markAllAsRead(),
]);
}
} finally {
setMarkingAllAsRead(false);
}
}, [activeFilter, markingAllAsRead, notifications, visibleUnreadCount]);
const emptyStateCopy =
activeFilter === "mentions"
? {
title: "No mentions",
description: "Mentions and replies will appear here.",
}
: activeFilter === "unread"
? {
title: "No unread notifications",
description: "New mentions and status updates will appear here.",
}
: {
title: "No notifications",
description: "Mentions, replies, and status updates will appear here.",
};
const tabs: { value: NotificationFilter; label: string; count: number }[] = [
{ value: "all", label: "All", count: allCount },
{ value: "mentions", label: "Mentions", count: mentionsCount },
{ value: "unread", label: "Unread", count: notifications.totalUnreadCount },
];
const triggerButton = (
<Button
type="button"
variant="ghost"
size="icon"
aria-label="Notifications"
className={cn(
"relative h-10 w-10 rounded-lg text-muted-foreground hover:bg-accent hover:text-accent-foreground",
open && "bg-accent text-accent-foreground"
)}
>
<Bell className="h-4 w-4" />
{notifications.totalUnreadCount > 0 ? (
<span className="absolute right-1 top-1 inline-flex h-4 min-w-4 items-center justify-center rounded-full bg-destructive px-1 text-[10px] font-semibold leading-none text-destructive-foreground">
{unreadLabel}
</span>
) : null}
</Button>
);
const panelContent = (
<>
<div className="flex shrink-0 items-center justify-between gap-3 border-b px-4 py-3">
<div className="min-w-0">
<h2 className="text-base font-semibold">Notifications</h2>
</div>
<Button
type="button"
variant="ghost"
size="sm"
onClick={handleMarkAllAsRead}
disabled={visibleUnreadCount === 0 || markingAllAsRead}
className="h-8 shrink-0 gap-1.5 px-2 text-xs text-muted-foreground hover:text-accent-foreground"
>
{markingAllAsRead ? <Spinner size="xs" /> : null}
Mark all read
</Button>
</div>
<div className="relative flex shrink-0 items-end gap-4 px-4 after:absolute after:inset-x-0 after:bottom-0 after:z-0 after:h-px after:bg-muted-foreground/25 after:content-['']">
{tabs.map((tab) => {
const isActive = activeFilter === tab.value;
return (
<button
key={tab.value}
type="button"
aria-pressed={isActive}
onClick={() => setActiveFilter(tab.value)}
className={cn(
"relative z-10 flex h-11 items-center gap-2 border-b-2 border-transparent px-0 text-sm font-medium text-muted-foreground transition-colors",
"hover:text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
isActive && "border-primary text-primary"
)}
>
<span>{tab.label}</span>
<span
className="inline-flex h-5 min-w-5 items-center justify-center rounded-full bg-muted px-1.5 text-[11px] font-semibold text-muted-foreground"
>
{formatNotificationCount(tab.count)}
</span>
</button>
);
})}
</div>
<div ref={scrollContainerRef} className="min-h-0 flex-1 overflow-y-auto p-2">
{isLoading ? (
<div className="space-y-1">
{[82, 64, 74].map((width) => (
<div key={width} className="flex h-[72px] items-center rounded-lg px-2 py-2">
<div className="min-w-0 flex-1 space-y-2">
<Skeleton className="h-3 rounded" style={{ width: `${width}%` }} />
<Skeleton className="h-2.5 w-1/2 rounded" />
</div>
</div>
))}
</div>
) : items.length > 0 ? (
<div className="space-y-1">
{items.map((item) => {
const isMarkingAsRead = markingAsReadId === item.id;
return (
<Button
key={item.id}
type="button"
variant="ghost"
disabled={isMarkingAsRead}
onClick={() => handleItemClick(item)}
className={cn(
"group h-auto w-full justify-start rounded-lg px-2 py-2 text-left",
"hover:bg-accent hover:text-accent-foreground",
!item.read && "bg-accent/40"
)}
style={{ contentVisibility: "auto", containIntrinsicSize: "0 72px" }}
>
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-start gap-2">
<p
className={cn(
"line-clamp-1 flex-1 text-sm font-medium",
!item.read && "font-semibold"
)}
>
{item.title}
</p>
{!item.read ? (
<span className="mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full bg-primary" />
) : null}
</div>
<p className="mt-0.5 line-clamp-2 text-xs font-normal text-muted-foreground group-hover:text-accent-foreground/80">
{item.message}
</p>
<p className="mt-1 text-[11px] font-normal text-muted-foreground/70">
{formatTime(item.created_at)}
</p>
</div>
{isMarkingAsRead ? <Spinner size="xs" className="shrink-0" /> : null}
</Button>
);
})}
{hasMore ? (
<div
ref={loadMoreTriggerRef}
className="flex min-h-10 items-center justify-center py-2"
>
{isLoadingMore ? <Spinner size="xs" /> : null}
</div>
) : null}
</div>
) : (
<div className="flex min-h-full flex-col items-center justify-center px-6 py-10 text-center">
<p className="text-sm font-medium">{emptyStateCopy.title}</p>
<p className="mt-1 text-xs text-muted-foreground">{emptyStateCopy.description}</p>
{hasMore ? (
<Button
type="button"
variant="ghost"
size="sm"
onClick={loadMoreForActiveFilter}
disabled={isLoadingMore}
className="mt-3 text-xs"
>
{isLoadingMore ? <Spinner size="xs" /> : null}
Load more
</Button>
) : null}
</div>
)}
</div>
</>
);
if (isMobile) {
return (
<Drawer open={open} onOpenChange={setOpen} shouldScaleBackground={false}>
<DrawerTrigger asChild>{triggerButton}</DrawerTrigger>
<DrawerContent
className="z-80 h-[78vh] max-h-[90vh] overflow-hidden rounded-t-2xl border bg-popover text-popover-foreground"
overlayClassName="z-80"
>
<DrawerHandle className="mt-3 h-1.5 w-10" />
<DrawerTitle className="sr-only">Notifications</DrawerTitle>
<div className="flex min-h-0 flex-1 select-none flex-col">{panelContent}</div>
</DrawerContent>
</Drawer>
);
}
return (
<Popover open={open} onOpenChange={setOpen}>
<Tooltip>
<TooltipTrigger asChild>
<PopoverTrigger asChild>{triggerButton}</PopoverTrigger>
</TooltipTrigger>
<TooltipContent side="right" sideOffset={8}>
Notifications
</TooltipContent>
</Tooltip>
<PopoverContent
side="right"
align="end"
sideOffset={10}
className="z-80 flex h-[min(420px,calc(100vh-2rem))] w-[360px] select-none flex-col overflow-hidden rounded-xl border bg-popover p-0 text-popover-foreground shadow-lg"
>
{panelContent}
</PopoverContent>
</Popover>
);
}

View file

@ -1,98 +0,0 @@
"use client";
import { History, KeyRound } from "lucide-react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { ConnectAgentDialog } from "@/components/mcp/connect-agent-dialog";
import { PLAYGROUND_PLATFORMS, type PlatformIcon } from "@/lib/playground/catalog";
import { cn } from "@/lib/utils";
interface PlaygroundSidebarProps {
workspaceId: number | string;
}
function PlaygroundNavLink({
href,
label,
icon: Icon,
isActive,
indented = false,
}: {
href: string;
label: string;
icon?: PlatformIcon;
isActive: boolean;
indented?: boolean;
}) {
return (
<Link
href={href}
aria-current={isActive ? "page" : undefined}
className={cn(
"group/link relative flex h-9 items-center gap-2 rounded-md mx-2 px-2 text-sm text-left",
"transition-colors hover:bg-accent hover:text-accent-foreground",
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
indented && "pl-8",
isActive && "bg-accent text-accent-foreground"
)}
>
{Icon ? <Icon className="h-3.5 w-3.5 shrink-0" /> : null}
<span className="min-w-0 flex-1 truncate">{label}</span>
</Link>
);
}
export function PlaygroundSidebar({ workspaceId }: PlaygroundSidebarProps) {
const pathname = usePathname();
const base = `/dashboard/${workspaceId}/playground`;
return (
<div className="relative flex h-full w-[240px] flex-col bg-panel text-sidebar-foreground overflow-hidden select-none">
<div className="flex h-12 shrink-0 items-center px-4">
<span className="text-sm font-semibold">API Playground</span>
</div>
<div className="flex flex-col gap-0.5 pt-1.5 pb-1.5 after:mx-3 after:mt-1.5 after:block after:h-px after:bg-border">
<PlaygroundNavLink
href={`${base}/runs`}
label="Runs"
icon={History}
isActive={pathname === `${base}/runs`}
/>
<PlaygroundNavLink
href={`${base}/api-keys`}
label="API Keys"
icon={KeyRound}
isActive={pathname === `${base}/api-keys`}
/>
</div>
<div className="flex-1 w-full min-h-0 overflow-y-auto overflow-x-hidden scrollbar-thin scrollbar-thumb-muted-foreground/20 scrollbar-track-transparent pb-2">
{PLAYGROUND_PLATFORMS.map((platform) => (
<div key={platform.id} className="flex flex-col gap-0.5 pt-2">
<div className="flex items-center gap-2 pl-4 pr-2.5 py-1 text-xs font-medium text-muted-foreground">
<platform.icon className="h-3.5 w-3.5 shrink-0" />
<span className="truncate">{platform.label}</span>
</div>
{platform.verbs.map((verb) => {
const href = `${base}/${platform.id}/${verb.verb}`;
return (
<PlaygroundNavLink
key={verb.name}
href={href}
label={verb.label}
isActive={pathname === href}
indented
/>
);
})}
</div>
))}
</div>
<div className="shrink-0 py-1.5 before:mx-3 before:mb-1.5 before:block before:h-px before:bg-border">
<ConnectAgentDialog />
</div>
</div>
);
}

View file

@ -5,7 +5,7 @@ import Link from "next/link";
import { useParams } from "next/navigation";
import { useTranslations } from "next-intl";
import { type ReactNode, useMemo, useState } from "react";
import { Badge } from "@/components/ui/badge";
import { ConnectAgentDialog } from "@/components/mcp/connect-agent-dialog";
import { Button } from "@/components/ui/button";
import { Progress } from "@/components/ui/progress";
import { Skeleton } from "@/components/ui/skeleton";
@ -18,7 +18,7 @@ import { ChatListItem } from "./ChatListItem";
import { CreditBalanceDisplay } from "./CreditBalanceDisplay";
import { DocumentsSidebar } from "./DocumentsSidebar";
import { NavSection } from "./NavSection";
import { SidebarButton } from "./SidebarButton";
import { SidebarButton, SidebarButtonBadge } from "./SidebarButton";
import { SidebarCollapseButton } from "./SidebarCollapseButton";
import { SidebarHeader } from "./SidebarHeader";
import { SidebarSection } from "./SidebarSection";
@ -46,21 +46,6 @@ function ChatListSkeletonRows() {
);
}
function CollapsedInboxIcon({ item }: { item: NavItem }) {
const Icon = item.icon;
return (
<span className="relative flex h-3.5 w-3.5 items-center justify-center">
<Icon className="h-3.5 w-3.5" />
{typeof item.badge === "string" ? (
<span className="absolute right-0 top-0 flex min-w-3.5 -translate-y-1/2 translate-x-1/2 items-center justify-center rounded-full bg-destructive px-1 text-[9px] font-medium leading-3 text-destructive-foreground">
{item.badge}
</span>
) : null}
</span>
);
}
interface SidebarProps {
workspace: Workspace | null;
isCollapsed?: boolean;
@ -138,10 +123,9 @@ export function Sidebar({
const [openDropdownChatId, setOpenDropdownChatId] = useState<number | null>(null);
const [isSidebarNavScrolled, setIsSidebarNavScrolled] = useState(false);
// Inbox, Automations, and Artifacts are rendered explicitly right below
// Automations, Artifacts, and Playground are rendered explicitly right below
// New Chat. Pull them out of the nav items list so they don't also appear
// in the bottom NavSection. Documents is embedded below Recents.
const inboxItem = useMemo(() => navItems.find((item) => item.url === "#inbox"), [navItems]);
const automationsItem = useMemo(
() => navItems.find((item) => item.url.endsWith("/automations")),
[navItems]
@ -158,7 +142,6 @@ export function Sidebar({
() =>
navItems.filter(
(item) =>
item.url !== "#inbox" &&
!item.url.endsWith("/automations") &&
!item.url.endsWith("/artifacts") &&
!item.url.endsWith("/playground")
@ -218,7 +201,7 @@ export function Sidebar({
<div
className={cn(
"relative flex flex-col gap-0.5 pt-1.5 pb-0 after:absolute after:inset-x-3 after:bottom-0 after:h-px after:bg-border after:transition-opacity",
"relative flex flex-col gap-0.5 pt-1.5 pb-0 after:absolute after:inset-x-0 after:bottom-0 after:h-px after:bg-border after:transition-opacity",
isSidebarNavScrolled ? "after:opacity-100" : "after:opacity-0"
)}
>
@ -235,23 +218,6 @@ export function Sidebar({
onScroll={(event) => setIsSidebarNavScrolled(event.currentTarget.scrollTop > 0)}
>
<div className="flex flex-col gap-0.5 pt-0.5 pb-1.5">
{inboxItem && (
<SidebarButton
icon={inboxItem.icon}
label={inboxItem.title}
onClick={() => onNavItemClick?.(inboxItem)}
isCollapsed={isCollapsed}
isActive={inboxItem.isActive}
badge={inboxItem.badge}
collapsedIconNode={<CollapsedInboxIcon item={inboxItem} />}
tooltipContent={isCollapsed ? inboxItem.title : undefined}
buttonProps={
{
"data-joyride": "inbox-sidebar",
} as React.ButtonHTMLAttributes<HTMLButtonElement>
}
/>
)}
{automationsItem && (
<SidebarButton
icon={automationsItem.icon}
@ -279,6 +245,7 @@ export function Sidebar({
onClick={() => onNavItemClick?.(playgroundItem)}
isCollapsed={isCollapsed}
isActive={playgroundItem.isActive}
badge={<SidebarButtonBadge>New</SidebarButtonBadge>}
tooltipContent={isCollapsed ? playgroundItem.title : undefined}
/>
)}
@ -356,10 +323,16 @@ export function Sidebar({
/>
)}
{!isCollapsed && (
<div className="shrink-0 py-1.5">
<ConnectAgentDialog className="w-[calc(100%-1rem)]" />
</div>
)}
<SidebarUsageFooter
pageUsage={pageUsage}
isCollapsed={isCollapsed}
hasNavSectionAbove={footerNavItems.length > 0}
hasNavSectionAbove={footerNavItems.length > 0 || !isCollapsed}
onNavigate={onNavigate}
/>
@ -436,29 +409,25 @@ function SidebarUsageFooter({
return (
<div className={containerClass}>
<CreditBalanceDisplay />
<div className="space-y-0.5">
<div className="relative grid grid-cols-2 before:absolute before:inset-y-1 before:left-1/2 before:w-px before:-translate-x-1/2 before:bg-border">
<Link
href={`/dashboard/${workspaceId}/earn-credits`}
onClick={onNavigate}
className="group flex w-full items-center justify-between rounded-md px-1.5 py-1 transition-colors hover:bg-accent"
className="group relative z-10 mx-0.5 flex min-w-0 items-center justify-center gap-1 rounded-md px-1.5 py-1 text-xs text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground"
>
<span className="flex items-center gap-1.5 text-xs text-muted-foreground group-hover:text-accent-foreground">
<Zap className="h-3 w-3 shrink-0" />
Earn credits
</span>
<Badge className="h-4 rounded px-1 text-[10px] font-semibold leading-none bg-emerald-600 text-white border-transparent hover:bg-emerald-600">
<Zap className="h-3 w-3 shrink-0" />
<span className="truncate">Earn</span>
<SidebarButtonBadge className="h-4 px-1 text-[10px] bg-emerald-600 text-white hover:bg-emerald-600">
FREE
</Badge>
</SidebarButtonBadge>
</Link>
<Link
href={`/dashboard/${workspaceId}/buy-more`}
onClick={onNavigate}
className="group flex w-full items-center justify-between rounded-md px-1.5 py-1 transition-colors hover:bg-accent"
className="group relative z-10 mx-0.5 flex min-w-0 items-center justify-center gap-1 rounded-md px-1.5 py-1 text-xs text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground"
>
<span className="flex items-center gap-1.5 text-xs text-muted-foreground group-hover:text-accent-foreground">
<CreditCard className="h-3 w-3 shrink-0" />
Buy credits
</span>
<CreditCard className="h-3 w-3 shrink-0" />
<span className="truncate">Buy</span>
</Link>
</div>
</div>

View file

@ -2,6 +2,7 @@
import type { LucideIcon } from "lucide-react";
import type React from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
@ -28,6 +29,26 @@ const baseClassName = cn(
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
);
export function SidebarButtonBadge({
children,
className,
}: {
children: React.ReactNode;
className?: string;
}) {
return (
<Badge
variant="secondary"
className={cn(
"h-5 shrink-0 rounded-sm border-0 bg-popover-foreground/10 px-1.5 text-[11px] text-popover-foreground hover:bg-popover-foreground/10",
className
)}
>
{children}
</Badge>
);
}
export function SidebarButton({
icon: Icon,
label,
@ -73,17 +94,19 @@ export function SidebarButton({
isCollapsed ? "max-w-0 opacity-0 ml-0" : "max-w-[260px] flex-1 opacity-100 ml-2"
)}
>
<span className="block truncate">{label}</span>
<span className="flex min-w-0 items-center gap-1.5">
<span className="truncate">{label}</span>
{!isCollapsed && badge && typeof badge !== "string" ? badge : null}
{!isCollapsed && badge && typeof badge === "string" ? (
<span className="inline-flex h-4 min-w-4 items-center justify-center rounded-full bg-red-500 px-1 text-[10px] font-medium text-white">
{badge}
</span>
) : null}
</span>
</span>
</span>
{!isCollapsed && trailingContent}
{!isCollapsed && badge && typeof badge !== "string" ? badge : null}
{!isCollapsed && badge && typeof badge === "string" ? (
<span className="ml-1 inline-flex items-center justify-center min-w-4 h-4 px-1 rounded-full bg-red-500 text-white text-[10px] font-medium">
{badge}
</span>
) : null}
{collapsedOverlay && (
<span

View file

@ -1,115 +0,0 @@
"use client";
import { useSetAtom } from "jotai";
import { AnimatePresence, motion } from "motion/react";
import { useCallback, useEffect } from "react";
import { useIsMobile } from "@/hooks/use-mobile";
import { slideoutOpenedTickAtom } from "@/lib/layout-events";
interface SidebarSlideOutPanelProps {
open: boolean;
onOpenChange: (open: boolean) => void;
ariaLabel: string;
width?: number;
children: React.ReactNode;
}
/**
* Reusable slide-out panel that extends from the sidebar.
*
* Desktop: absolutely positioned at the sidebar's right edge, overlaying the main
* content with a blur backdrop. Does not push/shrink the main content.
*
* Mobile: full-width absolute overlay (unchanged).
*/
export function SidebarSlideOutPanel({
open,
onOpenChange,
ariaLabel,
width = 360,
children,
}: SidebarSlideOutPanelProps) {
const isMobile = useIsMobile();
const bumpSlideoutOpenedTick = useSetAtom(slideoutOpenedTickAtom);
useEffect(() => {
if (open) {
bumpSlideoutOpenedTick((tick) => tick + 1);
}
}, [open, bumpSlideoutOpenedTick]);
const handleEscape = useCallback(
(e: KeyboardEvent) => {
if (e.key === "Escape") onOpenChange(false);
},
[onOpenChange]
);
useEffect(() => {
if (!open) return;
document.addEventListener("keydown", handleEscape);
return () => document.removeEventListener("keydown", handleEscape);
}, [open, handleEscape]);
if (isMobile) {
return (
<AnimatePresence>
{open && (
<div className="absolute left-0 inset-y-0 z-30 w-full overflow-hidden pointer-events-none">
<motion.div
initial={{ x: "-100%" }}
animate={{ x: 0 }}
exit={{ x: "-100%" }}
transition={{ type: "tween", duration: 0.2, ease: [0.4, 0, 0.2, 1] }}
className="h-full w-full bg-sidebar text-sidebar-foreground flex flex-col pointer-events-auto select-none"
role="dialog"
aria-modal="true"
aria-label={ariaLabel}
>
{children}
</motion.div>
</div>
)}
</AnimatePresence>
);
}
return (
<AnimatePresence initial={false}>
{open && (
<>
{/* Blur backdrop covering the main content area (right of sidebar) */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15 }}
className="absolute z-10 bg-black/30 backdrop-blur-sm rounded-xl"
style={{ top: -9, bottom: -9, left: "calc(100% + 1px)", width: "200vw" }}
onClick={() => onOpenChange(false)}
aria-hidden="true"
/>
{/* Panel extending from sidebar's right edge, flush with the wrapper border */}
<motion.div
initial={{ width: 0 }}
animate={{ width }}
exit={{ width: 0 }}
transition={{ type: "tween", duration: 0.2, ease: [0.4, 0, 0.2, 1] }}
className="absolute z-20 overflow-hidden"
style={{ left: "100%", top: -1, bottom: -1 }}
>
<div
style={{ width }}
className="h-full bg-sidebar text-sidebar-foreground flex flex-col select-none border shadow-xl"
role="dialog"
aria-label={ariaLabel}
>
{children}
</div>
</motion.div>
</>
)}
</AnimatePresence>
);
}

View file

@ -2,6 +2,7 @@
import {
Check,
ChevronRight,
ChevronUp,
Download,
ExternalLink,
@ -16,8 +17,10 @@ import {
} from "lucide-react";
import Image from "next/image";
import { useTranslations } from "next-intl";
import { useState } from "react";
import type React from "react";
import { Fragment, useState } from "react";
import { Button } from "@/components/ui/button";
import { Drawer, DrawerContent, DrawerHandle, DrawerTitle } from "@/components/ui/drawer";
import {
DropdownMenu,
DropdownMenuContent,
@ -63,6 +66,8 @@ const LEARN_MORE_LINKS = [
{ key: "github" as const, href: "https://github.com/MODSetter/SurfSense" },
];
type MobileProfileSubmenu = "theme" | "language" | "learn_more";
interface SidebarUserProfileProps {
user: User;
onUserSettings?: () => void;
@ -72,6 +77,7 @@ interface SidebarUserProfileProps {
isCollapsed?: boolean;
theme?: string;
setTheme?: (theme: "light" | "dark" | "system") => void;
topContent?: React.ReactNode;
}
function formatAnnouncementCount(count: number): string {
@ -134,6 +140,7 @@ export function SidebarUserProfile({
isCollapsed = false,
theme,
setTheme,
topContent,
}: SidebarUserProfileProps) {
const t = useTranslations("sidebar");
const { locale, setLocale } = useLocaleContext();
@ -141,12 +148,14 @@ export function SidebarUserProfile({
const isDesktopViewport = useMediaQuery("(min-width: 768px)");
const { os, primary, isMobileOS } = usePrimaryDownload();
const [isLoggingOut, setIsLoggingOut] = useState(false);
const [mobileSubmenu, setMobileSubmenu] = useState<MobileProfileSubmenu | null>(null);
const bgColor = getUserAvatarColor(user.email);
const initials = getUserInitials(user.email);
const displayName = user.name || user.email.split("@")[0];
const downloadUrl = primary?.url ?? GITHUB_RELEASES_URL;
const downloadLabel = t("download_for_os", { os });
const showDownloadCta = !isDesktop && !isMobileOS && isDesktopViewport;
const useMobileSubmenus = !isDesktopViewport;
const handleLanguageChange = (newLocale: "en" | "es" | "pt" | "hi" | "zh") => {
setLocale(newLocale);
@ -166,11 +175,248 @@ export function SidebarUserProfile({
}
};
const submenuTriggerClassName = "cursor-default";
const drawerItemClassName = cn(
"flex h-12 w-full items-center gap-3 rounded-lg px-3 text-left text-sm transition-colors",
"hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
);
const drawerSeparatorClassName = "mx-3 h-px bg-popover-border";
const renderThemeSubmenu = () => {
if (!setTheme) return null;
if (useMobileSubmenus) {
return (
<DropdownMenuItem
className={submenuTriggerClassName}
onSelect={() => setMobileSubmenu("theme")}
>
<Sun className="h-4 w-4" />
<span className="flex-1">{t("theme")}</span>
<ChevronRight className="h-4 w-4 text-muted-foreground" />
</DropdownMenuItem>
);
}
return (
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<Sun className="h-4 w-4" />
{t("theme")}
</DropdownMenuSubTrigger>
<DropdownMenuPortal>
<DropdownMenuSubContent className="gap-1">
{THEMES.map((themeOption) => {
const Icon = themeOption.icon;
const isSelected = theme === themeOption.value;
return (
<DropdownMenuItem
key={themeOption.value}
onClick={() => handleThemeChange(themeOption.value)}
className={cn(
"mb-1 last:mb-0 transition-all",
"hover:bg-accent hover:text-accent-foreground",
isSelected && "text-primary"
)}
>
<Icon className="h-4 w-4" />
<span className="flex-1">{t(themeOption.value)}</span>
{isSelected && <Check className="h-4 w-4 shrink-0" />}
</DropdownMenuItem>
);
})}
</DropdownMenuSubContent>
</DropdownMenuPortal>
</DropdownMenuSub>
);
};
const renderLanguageSubmenu = () => {
if (useMobileSubmenus) {
return (
<DropdownMenuItem
className={submenuTriggerClassName}
onSelect={() => setMobileSubmenu("language")}
>
<Languages className="h-4 w-4" />
<span className="flex-1">{t("language")}</span>
<ChevronRight className="h-4 w-4 text-muted-foreground" />
</DropdownMenuItem>
);
}
return (
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<Languages className="h-4 w-4" />
{t("language")}
</DropdownMenuSubTrigger>
<DropdownMenuPortal>
<DropdownMenuSubContent className="gap-1">
{LANGUAGES.map((language) => {
const isSelected = locale === language.code;
return (
<DropdownMenuItem
key={language.code}
onClick={() => handleLanguageChange(language.code)}
className={cn(
"mb-1 last:mb-0 transition-all",
"hover:bg-accent hover:text-accent-foreground",
isSelected && "text-primary"
)}
>
<span className="mr-2">{language.flag}</span>
<span className="flex-1">{language.name}</span>
{isSelected && <Check className="h-4 w-4 shrink-0" />}
</DropdownMenuItem>
);
})}
</DropdownMenuSubContent>
</DropdownMenuPortal>
</DropdownMenuSub>
);
};
const renderLearnMoreSubmenu = () => {
if (useMobileSubmenus) {
return (
<DropdownMenuItem
className={submenuTriggerClassName}
onSelect={() => setMobileSubmenu("learn_more")}
>
<Info className="h-4 w-4" />
<span className="flex-1">{t("learn_more")}</span>
<ChevronRight className="h-4 w-4 text-muted-foreground" />
</DropdownMenuItem>
);
}
return (
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<Info className="h-4 w-4" />
{t("learn_more")}
</DropdownMenuSubTrigger>
<DropdownMenuPortal>
<DropdownMenuSubContent className="min-w-[180px] gap-1">
{LEARN_MORE_LINKS.map((link) => (
<DropdownMenuItem key={link.key} asChild>
<a href={link.href} target="_blank" rel="noopener noreferrer">
<span className="flex-1">{t(link.key)}</span>
<ExternalLink className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
</a>
</DropdownMenuItem>
))}
<DropdownMenuSeparator />
<p className="select-none px-2 py-1 text-xs leading-tight text-muted-foreground/50">
v{APP_VERSION}
</p>
</DropdownMenuSubContent>
</DropdownMenuPortal>
</DropdownMenuSub>
);
};
const mobileSubmenuDrawer = (
<Drawer
open={mobileSubmenu !== null}
onOpenChange={(open) => {
if (!open) setMobileSubmenu(null);
}}
shouldScaleBackground={false}
>
<DrawerContent
className="z-80 max-h-[75vh] rounded-t-2xl border bg-popover text-popover-foreground"
overlayClassName="z-80"
>
<DrawerHandle className="mt-3 h-1.5 w-10" />
<DrawerTitle className="px-4 pb-2 pt-3 text-center text-base font-semibold">
{mobileSubmenu === "theme"
? t("theme")
: mobileSubmenu === "language"
? t("language")
: t("learn_more")}
</DrawerTitle>
<div className="space-y-1 px-4 pb-6 pt-1">
{mobileSubmenu === "theme" &&
setTheme &&
THEMES.map((themeOption, index) => {
const Icon = themeOption.icon;
const isSelected = theme === themeOption.value;
return (
<Fragment key={themeOption.value}>
{index > 0 && <div className={drawerSeparatorClassName} />}
<button
type="button"
className={cn(drawerItemClassName, isSelected && "text-primary")}
onClick={() => {
handleThemeChange(themeOption.value);
setMobileSubmenu(null);
}}
>
<Icon className="h-4 w-4 text-muted-foreground" />
<span className="flex-1">{t(themeOption.value)}</span>
{isSelected && <Check className="h-4 w-4 shrink-0" />}
</button>
</Fragment>
);
})}
{mobileSubmenu === "language" &&
LANGUAGES.map((language, index) => {
const isSelected = locale === language.code;
return (
<Fragment key={language.code}>
{index > 0 && <div className={drawerSeparatorClassName} />}
<button
type="button"
className={cn(drawerItemClassName, isSelected && "text-primary")}
onClick={() => {
handleLanguageChange(language.code);
setMobileSubmenu(null);
}}
>
<span className="text-base">{language.flag}</span>
<span className="flex-1">{language.name}</span>
{isSelected && <Check className="h-4 w-4 shrink-0" />}
</button>
</Fragment>
);
})}
{mobileSubmenu === "learn_more" && (
<>
{LEARN_MORE_LINKS.map((link, index) => (
<Fragment key={link.key}>
{index > 0 && <div className={drawerSeparatorClassName} />}
<a
href={link.href}
target="_blank"
rel="noopener noreferrer"
className={drawerItemClassName}
onClick={() => setMobileSubmenu(null)}
>
<span className="flex-1">{t(link.key)}</span>
<ExternalLink className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
</a>
</Fragment>
))}
<p className="select-none px-3 py-1 text-xs leading-tight text-muted-foreground/50">
v{APP_VERSION}
</p>
</>
)}
</div>
</DrawerContent>
</Drawer>
);
// Collapsed view - just show avatar with dropdown
if (isCollapsed) {
return (
<div className="w-full border-t px-1.5 py-2">
<div className="relative w-full px-1.5 py-2 before:absolute before:inset-x-1.5 before:top-0 before:h-px before:bg-border">
<div className="flex flex-col items-center gap-2">
{topContent}
{showDownloadCta && (
<Tooltip>
<TooltipTrigger asChild>
@ -247,89 +493,11 @@ export function SidebarUserProfile({
</DropdownMenuItem>
)}
{setTheme && (
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<Sun className="h-4 w-4" />
{t("theme")}
</DropdownMenuSubTrigger>
<DropdownMenuPortal>
<DropdownMenuSubContent className="gap-1">
{THEMES.map((themeOption) => {
const Icon = themeOption.icon;
const isSelected = theme === themeOption.value;
return (
<DropdownMenuItem
key={themeOption.value}
onClick={() => handleThemeChange(themeOption.value)}
className={cn(
"mb-1 last:mb-0 transition-all",
"hover:bg-accent hover:text-accent-foreground",
isSelected && "text-primary"
)}
>
<Icon className="h-4 w-4" />
<span className="flex-1">{t(themeOption.value)}</span>
{isSelected && <Check className="h-4 w-4 shrink-0" />}
</DropdownMenuItem>
);
})}
</DropdownMenuSubContent>
</DropdownMenuPortal>
</DropdownMenuSub>
)}
{renderThemeSubmenu()}
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<Languages className="h-4 w-4" />
{t("language")}
</DropdownMenuSubTrigger>
<DropdownMenuPortal>
<DropdownMenuSubContent className="gap-1">
{LANGUAGES.map((language) => {
const isSelected = locale === language.code;
return (
<DropdownMenuItem
key={language.code}
onClick={() => handleLanguageChange(language.code)}
className={cn(
"mb-1 last:mb-0 transition-all",
"hover:bg-accent hover:text-accent-foreground",
isSelected && "text-primary"
)}
>
<span className="mr-2">{language.flag}</span>
<span className="flex-1">{language.name}</span>
{isSelected && <Check className="h-4 w-4 shrink-0" />}
</DropdownMenuItem>
);
})}
</DropdownMenuSubContent>
</DropdownMenuPortal>
</DropdownMenuSub>
{renderLanguageSubmenu()}
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<Info className="h-4 w-4" />
{t("learn_more")}
</DropdownMenuSubTrigger>
<DropdownMenuPortal>
<DropdownMenuSubContent className="min-w-[180px] gap-1">
{LEARN_MORE_LINKS.map((link) => (
<DropdownMenuItem key={link.key} asChild>
<a href={link.href} target="_blank" rel="noopener noreferrer">
<span className="flex-1">{t(link.key)}</span>
<ExternalLink className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
</a>
</DropdownMenuItem>
))}
<DropdownMenuSeparator />
<p className="select-none px-2 py-1 text-xs leading-tight text-muted-foreground/50">
v{APP_VERSION}
</p>
</DropdownMenuSubContent>
</DropdownMenuPortal>
</DropdownMenuSub>
{renderLearnMoreSubmenu()}
{!isDesktop && !isMobileOS && (
<DropdownMenuItem asChild className="font-medium">
@ -352,6 +520,7 @@ export function SidebarUserProfile({
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{mobileSubmenuDrawer}
</div>
</div>
);
@ -429,89 +598,11 @@ export function SidebarUserProfile({
</DropdownMenuItem>
)}
{setTheme && (
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<Sun className="h-4 w-4" />
{t("theme")}
</DropdownMenuSubTrigger>
<DropdownMenuPortal>
<DropdownMenuSubContent className="gap-1">
{THEMES.map((themeOption) => {
const Icon = themeOption.icon;
const isSelected = theme === themeOption.value;
return (
<DropdownMenuItem
key={themeOption.value}
onClick={() => handleThemeChange(themeOption.value)}
className={cn(
"mb-1 last:mb-0 transition-all",
"hover:bg-accent hover:text-accent-foreground",
isSelected && "text-primary"
)}
>
<Icon className="h-4 w-4" />
<span className="flex-1">{t(themeOption.value)}</span>
{isSelected && <Check className="h-4 w-4 shrink-0" />}
</DropdownMenuItem>
);
})}
</DropdownMenuSubContent>
</DropdownMenuPortal>
</DropdownMenuSub>
)}
{renderThemeSubmenu()}
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<Languages className="h-4 w-4" />
{t("language")}
</DropdownMenuSubTrigger>
<DropdownMenuPortal>
<DropdownMenuSubContent className="gap-1">
{LANGUAGES.map((language) => {
const isSelected = locale === language.code;
return (
<DropdownMenuItem
key={language.code}
onClick={() => handleLanguageChange(language.code)}
className={cn(
"mb-1 last:mb-0 transition-all",
"hover:bg-accent hover:text-accent-foreground",
isSelected && "text-primary"
)}
>
<span className="mr-2">{language.flag}</span>
<span className="flex-1">{language.name}</span>
{isSelected && <Check className="h-4 w-4 shrink-0" />}
</DropdownMenuItem>
);
})}
</DropdownMenuSubContent>
</DropdownMenuPortal>
</DropdownMenuSub>
{renderLanguageSubmenu()}
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<Info className="h-4 w-4" />
{t("learn_more")}
</DropdownMenuSubTrigger>
<DropdownMenuPortal>
<DropdownMenuSubContent className="min-w-[180px] gap-1">
{LEARN_MORE_LINKS.map((link) => (
<DropdownMenuItem key={link.key} asChild>
<a href={link.href} target="_blank" rel="noopener noreferrer">
<span className="flex-1">{t(link.key)}</span>
<ExternalLink className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
</a>
</DropdownMenuItem>
))}
<DropdownMenuSeparator />
<p className="select-none px-2 py-1 text-xs leading-tight text-muted-foreground/50">
v{APP_VERSION}
</p>
</DropdownMenuSubContent>
</DropdownMenuPortal>
</DropdownMenuSub>
{renderLearnMoreSubmenu()}
{!isDesktop && (
<DropdownMenuItem asChild className="font-medium">
@ -530,6 +621,7 @@ export function SidebarUserProfile({
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{mobileSubmenuDrawer}
</div>
);
}

View file

@ -2,10 +2,9 @@ export { AllChatsWorkspaceContent } from "./AllChatsSidebar";
export { ChatListItem } from "./ChatListItem";
export { CreditBalanceDisplay } from "./CreditBalanceDisplay";
export { DocumentsSidebar } from "./DocumentsSidebar";
export { InboxSidebar, InboxSidebarContent } from "./InboxSidebar";
export { MobileSidebar, MobileSidebarTrigger } from "./MobileSidebar";
export { NavSection } from "./NavSection";
export { PlaygroundSidebar } from "./PlaygroundSidebar";
export { NotificationsDropdown } from "./NotificationsDropdown";
export { Sidebar } from "./Sidebar";
export { SidebarCollapseButton } from "./SidebarCollapseButton";
export { SidebarHeader } from "./SidebarHeader";

View file

@ -1,6 +1,6 @@
"use client";
import { Cable } from "lucide-react";
import { SidebarButtonBadge } from "@/components/layout/ui/sidebar/SidebarButton";
import { AgentSetupTabs } from "@/components/mcp/agent-setup-tabs";
import {
Dialog,
@ -28,8 +28,18 @@ export function ConnectAgentDialog({ className }: { className?: string }) {
className
)}
>
<Cable className="h-3.5 w-3.5 shrink-0" />
<span className="min-w-0 flex-1 truncate">Connect your AI Agent</span>
<span
aria-hidden="true"
className="size-3.5 shrink-0 bg-current"
style={{
mask: "url('/connectors/modelcontextprotocol.svg') center / contain no-repeat",
WebkitMask: "url('/connectors/modelcontextprotocol.svg') center / contain no-repeat",
}}
/>
<span className="flex min-w-0 flex-1 items-center gap-1.5">
<span className="min-w-0 truncate">Connect your agent</span>
<SidebarButtonBadge>New</SidebarButtonBadge>
</span>
</DialogTrigger>
<DialogContent className="max-h-[85vh] overflow-y-auto sm:max-w-2xl">
<DialogHeader>

View file

@ -41,7 +41,7 @@ const demoPlans = [
"Premium models like GPT-5.5, Claude Sonnet 5, Gemini 3.1 Pro billed at provider cost",
"Scheduled and event-triggered agents for briefs, alerts, and monitoring",
"Write results back to Notion, Slack, Linear, and Jira",
"Top up any amount, $1 buys $1 of credit, optional auto-reload",
"Add credit any time. $1 buys $1 of credit, with optional automatic refills",
"Priority support on Discord",
],
description: "",
@ -95,7 +95,7 @@ const faqData: FAQSection[] = [
{
question: "How does Pay As You Go work?",
answer:
"There is no monthly subscription. Start with $5 of free credit, and when you need more, top up any amount. $1 buys exactly $1 of credit, added to your balance immediately. You can enable auto-reload to top up automatically when your balance runs low, and turn it off any time.",
"There is no monthly subscription. Start with $5 of free credit, and when you need more, add any amount. $1 buys exactly $1 of credit, added to your balance immediately. You can enable automatic refills when your balance runs low, and turn them off any time."
},
{
question: "What happens if I run out of credit?",

View file

@ -2,15 +2,15 @@
import { useQuery as useZeroQuery } from "@rocicorp/zero/react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { AlertTriangle, CreditCard, RefreshCw } from "lucide-react";
import { AlertTriangle, Info } from "lucide-react";
import { useParams, usePathname, useRouter, useSearchParams } from "next/navigation";
import { useEffect, useRef, useState } from "react";
import { toast } from "sonner";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Separator } from "@/components/ui/separator";
import { Spinner } from "@/components/ui/spinner";
import { Switch } from "@/components/ui/switch";
import { stripeApiService } from "@/lib/apis/stripe-api.service";
@ -69,7 +69,7 @@ export function AutoReloadSettings() {
const setupResult = searchParams.get("auto_reload_setup");
if (!setupResult) return;
if (setupResult === "success") {
toast.success("Card saved. You can now enable auto-reload.");
toast.success("Card saved. You can now enable top-ups.");
queryClient.invalidateQueries({ queryKey: ["auto-reload-settings"] });
} else if (setupResult === "cancel") {
toast.info("Card setup canceled.");
@ -92,19 +92,19 @@ export function AutoReloadSettings() {
mutationFn: stripeApiService.updateAutoReloadSettings,
onSuccess: (updated) => {
queryClient.setQueryData(["auto-reload-settings"], updated);
toast.success(updated.enabled ? "Auto-reload is on." : "Auto-reload settings saved.");
toast.success(updated.enabled ? "Top-ups are on." : "Top-up settings saved.");
},
onError: (error) => {
if (error instanceof AppError && error.message) {
toast.error(error.message);
return;
}
toast.error("Couldn't save auto-reload settings. Please try again.");
toast.error("Couldn't save top-up settings. Please try again.");
},
});
// Render nothing while loading (avoids a spinner flash on pages where the
// feature flag turns out to be off) and when auto-reload is disabled
// feature flag turns out to be off) and when top-ups are disabled
// server-side.
if (isLoading || !settings || !settings.feature_enabled) {
return null;
@ -131,7 +131,7 @@ export function AutoReloadSettings() {
return;
}
if (amountMicros == null || amountMicros < settings.min_amount_micros) {
toast.error(`Reload amount must be at least $${minAmountDollars}.`);
toast.error(`Top-up amount must be at least $${minAmountDollars}.`);
return;
}
@ -142,135 +142,168 @@ export function AutoReloadSettings() {
});
};
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<RefreshCw className="h-4 w-4 text-amber-500" />
Auto-reload
</CardTitle>
<CardDescription>
Automatically top up your credit balance when it drops below a threshold, using a saved
card. Current balance:{" "}
<span className="font-medium text-foreground">{formatUsd(balanceMicros)}</span>.
</CardDescription>
</CardHeader>
<CardContent className="space-y-5">
const addCardButton = (
<Button
className="w-full sm:w-auto"
onClick={() => setupMutation.mutate()}
disabled={setupMutation.isPending}
>
{setupMutation.isPending ? (
<>
<Spinner size="xs" />
Redirecting
</>
) : (
"Add a card"
)}
</Button>
);
if (!hasCard) {
return (
<div className="space-y-5">
{settings.failed_at && (
<Alert variant="destructive">
<AlertTriangle className="h-4 w-4" />
<AlertTitle>Last auto-reload failed</AlertTitle>
<AlertTitle>Last top-up failed</AlertTitle>
<AlertDescription>
Your saved card was declined and auto-reload was turned off. Update your card and
re-enable it below to keep topping up automatically.
Your saved card was declined and top-ups were turned off. Update your card and
re-enable top-ups below.
</AlertDescription>
</Alert>
)}
{!hasCard ? (
<div className="flex flex-col items-start gap-3 rounded-lg border bg-muted/20 p-4">
<div className="flex items-center gap-2 text-sm">
<CreditCard className="h-4 w-4 text-muted-foreground" />
<span>Add a card to enable automatic top-ups.</span>
<div className="space-y-6">
<Alert className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="flex min-w-0 items-start gap-3">
<Info className="mt-0.5 h-4 w-4 shrink-0" />
<p className="text-sm leading-relaxed text-muted-foreground">
Automatically top up your credit balance when it drops below a threshold, using a
saved card. Current balance:{" "}
<span className="font-medium text-foreground">{formatUsd(balanceMicros)}</span>.
</p>
</div>
<Button onClick={() => setupMutation.mutate()} disabled={setupMutation.isPending}>
{setupMutation.isPending ? (
<>
<Spinner size="xs" />
Redirecting
</>
) : (
"Add a card"
)}
</Button>
{addCardButton}
</Alert>
<Separator />
</div>
</div>
);
}
return (
<div className="space-y-6">
{settings.failed_at && (
<Alert variant="destructive">
<AlertTriangle className="h-4 w-4" />
<AlertTitle>Last top-up failed</AlertTitle>
<AlertDescription>
Your saved card was declined and top-ups were turned off. Update your card and
re-enable top-ups below.
</AlertDescription>
</Alert>
)}
<section className="space-y-5">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="space-y-1">
<h2 className="text-base font-semibold tracking-tight">Automatic top-ups</h2>
<p className="text-sm text-muted-foreground">
Current balance:{" "}
<span className="font-medium text-foreground">{formatUsd(balanceMicros)}</span>
</p>
</div>
) : (
<>
<div className="flex items-center justify-between gap-4">
<div className="space-y-0.5">
<Label htmlFor="auto-reload-toggle" className="text-sm font-medium">
Enable auto-reload
</Label>
<p className="text-xs text-muted-foreground">
Charge your saved card when the balance gets low.
</p>
</div>
<Switch id="auto-reload-toggle" checked={enabled} onCheckedChange={setEnabled} />
</div>
<Button
className="w-full sm:w-auto"
onClick={() => setupMutation.mutate()}
disabled={setupMutation.isPending}
>
{setupMutation.isPending ? (
<>
<Spinner size="xs" />
Redirecting
</>
) : (
"Update card"
)}
</Button>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor="auto-reload-threshold" className="text-xs">
When balance falls below
</Label>
<div className="relative">
<span className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-sm text-muted-foreground">
$
</span>
<Input
id="auto-reload-threshold"
type="number"
min="0"
step="1"
inputMode="decimal"
className="pl-6 tabular-nums"
value={thresholdInput}
onChange={(e) => setThresholdInput(e.target.value)}
disabled={!enabled}
placeholder="5"
/>
</div>
</div>
<div className="space-y-1.5">
<Label htmlFor="auto-reload-amount" className="text-xs">
Add this much credit
</Label>
<div className="relative">
<span className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-sm text-muted-foreground">
$
</span>
<Input
id="auto-reload-amount"
type="number"
min={minAmountDollars}
step="1"
inputMode="decimal"
className="pl-6 tabular-nums"
value={amountInput}
onChange={(e) => setAmountInput(e.target.value)}
disabled={!enabled}
placeholder="10"
/>
</div>
<p className="text-[11px] text-muted-foreground">Minimum ${minAmountDollars}.</p>
</div>
</div>
<div className="flex items-center justify-between gap-4">
<div className="space-y-0.5">
<Label htmlFor="top-ups-toggle" className="text-sm font-medium">
Enable top-ups
</Label>
<p className="text-xs text-muted-foreground">
Charge your saved card when the balance gets low.
</p>
</div>
<Switch id="top-ups-toggle" checked={enabled} onCheckedChange={setEnabled} />
</div>
<div className="flex items-center justify-between gap-3">
<Button
variant="ghost"
size="sm"
className="text-muted-foreground"
onClick={() => setupMutation.mutate()}
disabled={setupMutation.isPending}
>
<CreditCard className="h-3.5 w-3.5" />
Update card
</Button>
<Button onClick={handleSave} disabled={saveMutation.isPending}>
{saveMutation.isPending ? (
<>
<Spinner size="xs" />
Saving
</>
) : (
"Save"
)}
</Button>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor="top-ups-threshold" className="text-xs">
When balance falls below
</Label>
<div className="relative">
<span className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-sm text-muted-foreground">
$
</span>
<Input
id="top-ups-threshold"
type="number"
min="0"
step="1"
inputMode="decimal"
className="pl-6 tabular-nums"
value={thresholdInput}
onChange={(e) => setThresholdInput(e.target.value)}
disabled={!enabled}
placeholder="5"
/>
</div>
</>
)}
</CardContent>
</Card>
</div>
<div className="space-y-1.5">
<Label htmlFor="top-ups-amount" className="text-xs">
Add this much credit
</Label>
<div className="relative">
<span className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-sm text-muted-foreground">
$
</span>
<Input
id="top-ups-amount"
type="number"
min={minAmountDollars}
step="1"
inputMode="decimal"
className="pl-6 tabular-nums"
value={amountInput}
onChange={(e) => setAmountInput(e.target.value)}
disabled={!enabled}
placeholder="10"
/>
</div>
<p className="text-[11px] text-muted-foreground">Minimum ${minAmountDollars}.</p>
</div>
</div>
<div className="flex justify-end">
<Button className="w-full sm:w-auto" onClick={handleSave} disabled={saveMutation.isPending}>
{saveMutation.isPending ? (
<>
<Spinner size="xs" />
Saving
</>
) : (
"Save"
)}
</Button>
</div>
</section>
<Separator />
</div>
);
}

View file

@ -29,7 +29,7 @@ const TabsTrigger = React.forwardRef<
<TabsPrimitive.Trigger
ref={ref}
className={cn(
"inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm",
"inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-popover data-[state=active]:text-popover-foreground data-[state=active]:shadow-sm",
className
)}
{...props}

View file

@ -15,6 +15,7 @@ export const scraperPricingMeter = z.object({
export const scraperCapability = z.object({
name: z.string(),
description: z.string(),
docs_url: z.string().nullable().optional(),
input_schema: z.record(z.string(), z.unknown()),
// Optional so a backend that predates output schemas degrades to just not
// showing the output-schema block instead of failing the whole fetch.

View file

@ -1,7 +1,7 @@
"use client";
import { useSetAtom } from "jotai";
import { RefreshCw, TriangleAlert } from "lucide-react";
import { Boxes, RefreshCw, TriangleAlert } from "lucide-react";
import { useMemo, useState } from "react";
import { openReportPanelAtom } from "@/atoms/chat/report-panel.atom";
import { MobileReportPanel } from "@/components/report-panel/report-panel";
@ -46,8 +46,15 @@ function ErrorState({ onRetry }: { onRetry: () => void }) {
function EmptyState() {
return (
<div className="flex items-center justify-center py-20 text-center">
<p className="font-medium text-foreground">No artifacts yet</p>
<div className="rounded-lg border border-dashed border-border/60 bg-muted/20 px-6 py-12 text-center">
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-muted text-muted-foreground">
<Boxes className="h-6 w-6" aria-hidden />
</div>
<h3 className="mt-4 text-base font-semibold text-foreground">No artifacts yet</h3>
<p className="mt-1 text-sm text-muted-foreground max-w-md mx-auto">
Artifacts collect the reports, resumes, podcasts, presentations, and images SurfSense
creates for this workspace. Generated deliverables from your chats will appear here.
</p>
</div>
);
}

View file

@ -54,6 +54,7 @@ export function useInbox(
const [hasMore, setHasMore] = useState(false);
const [error, setError] = useState<Error | null>(null);
const [unreadCount, setUnreadCount] = useState(0);
const [totalCount, setTotalCount] = useState(0);
const initialLoadDoneRef = useRef(false);
const olderUnreadOffsetRef = useRef<number | null>(null);
@ -71,6 +72,7 @@ export function useInbox(
setLoading(true);
setInboxItems([]);
setHasMore(false);
setTotalCount(0);
initialLoadDoneRef.current = false;
olderUnreadOffsetRef.current = null;
apiUnreadTotalRef.current = 0;
@ -97,6 +99,7 @@ export function useInbox(
if (cancelled) return;
setInboxItems(notificationsResponse.items);
setTotalCount(notificationsResponse.total);
setHasMore(notificationsResponse.has_more);
setUnreadCount(unreadResponse.total_unread);
apiUnreadTotalRef.current = unreadResponse.total_unread;
@ -222,6 +225,7 @@ export function useInbox(
const deduped = newItems.filter((d) => !existingIds.has(d.id));
return [...prev, ...deduped];
});
setTotalCount(response.total);
setHasMore(response.has_more);
} catch (err) {
console.error(`[useInbox:${category}] Load more failed:`, err);
@ -299,6 +303,7 @@ export function useInbox(
return {
inboxItems,
unreadCount,
totalCount,
markAsRead,
markAllAsRead,
loading,

View file

@ -1 +1,57 @@
<svg role="img" viewBox="0 0 24 24" fill="#FF4500" xmlns="http://www.w3.org/2000/svg"><title>Reddit</title><path d="M12 0C5.373 0 0 5.373 0 12c0 3.314 1.343 6.314 3.515 8.485l-2.286 2.286C.775 23.225 1.097 24 1.738 24H12c6.627 0 12-5.373 12-12S18.627 0 12 0Zm4.388 3.199c1.104 0 1.999.895 1.999 1.999 0 1.105-.895 2-1.999 2-.946 0-1.739-.657-1.947-1.539v.002c-1.147.162-2.032 1.15-2.032 2.341v.007c1.776.067 3.4.567 4.686 1.363.473-.363 1.064-.58 1.707-.58 1.547 0 2.802 1.254 2.802 2.802 0 1.117-.655 2.081-1.601 2.531-.088 3.256-3.637 5.876-7.997 5.876-4.361 0-7.905-2.617-7.998-5.87-.954-.447-1.614-1.415-1.614-2.538 0-1.548 1.255-2.802 2.803-2.802.645 0 1.239.218 1.712.585 1.275-.79 2.881-1.291 4.64-1.365v-.01c0-1.663 1.263-3.034 2.88-3.207.188-.911.993-1.595 1.959-1.595Zm-8.085 8.376c-.784 0-1.459.78-1.506 1.797-.047 1.016.64 1.429 1.426 1.429.786 0 1.371-.369 1.418-1.385.047-1.017-.553-1.841-1.338-1.841Zm7.406 0c-.786 0-1.385.824-1.338 1.841.047 1.017.634 1.385 1.418 1.385.785 0 1.473-.413 1.426-1.429-.046-1.017-.721-1.797-1.506-1.797Zm-3.703 4.013c-.974 0-1.907.048-2.77.135-.147.015-.241.168-.183.305.483 1.154 1.622 1.964 2.953 1.964 1.33 0 2.47-.81 2.953-1.964.057-.137-.037-.29-.184-.305-.863-.087-1.795-.135-2.769-.135Z"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" class="_1O4jTk-dZ-VIxsCuYB6OR8" viewBox="0 0 216 216">
<defs>
<radialGradient id="snoo-radial-gragient" cx="169.75" cy="92.19" r="50.98" fx="169.75" fy="92.19" gradientTransform="matrix(1 0 0 .87 0 11.64)" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#feffff"/>
<stop offset=".4" stop-color="#feffff"/>
<stop offset=".51" stop-color="#f9fcfc"/>
<stop offset=".62" stop-color="#edf3f5"/>
<stop offset=".7" stop-color="#dee9ec"/>
<stop offset=".72" stop-color="#d8e4e8"/>
<stop offset=".76" stop-color="#ccd8df"/>
<stop offset=".8" stop-color="#c8d5dd"/>
<stop offset=".83" stop-color="#ccd6de"/>
<stop offset=".85" stop-color="#d8dbe2"/>
<stop offset=".88" stop-color="#ede3e9"/>
<stop offset=".9" stop-color="#ffebef"/>
</radialGradient>
<radialGradient xlink:href="#snoo-radial-gragient" id="snoo-radial-gragient-2" cx="47.31" r="50.98" fx="47.31"/>
<radialGradient xlink:href="#snoo-radial-gragient" id="snoo-radial-gragient-3" cx="109.61" cy="85.59" r="153.78" fx="109.61" fy="85.59" gradientTransform="matrix(1 0 0 .7 0 25.56)"/>
<radialGradient id="snoo-radial-gragient-4" cx="-6.01" cy="64.68" r="12.85" fx="-6.01" fy="64.68" gradientTransform="matrix(1.07 0 0 1.55 81.08 27.26)" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#f60"/>
<stop offset=".5" stop-color="#ff4500"/>
<stop offset=".7" stop-color="#fc4301"/>
<stop offset=".82" stop-color="#f43f07"/>
<stop offset=".92" stop-color="#e53812"/>
<stop offset="1" stop-color="#d4301f"/>
</radialGradient>
<radialGradient xlink:href="#snoo-radial-gragient-4" id="snoo-radial-gragient-5" cx="-73.55" cy="64.68" r="12.85" fx="-73.55" fy="64.68" gradientTransform="matrix(-1.07 0 0 1.55 62.87 27.26)"/>
<radialGradient id="snoo-radial-gragient-6" cx="107.93" cy="166.96" r="45.3" fx="107.93" fy="166.96" gradientTransform="matrix(1 0 0 .66 0 57.4)" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#172e35"/>
<stop offset=".29" stop-color="#0e1c21"/>
<stop offset=".73" stop-color="#030708"/>
<stop offset="1"/>
</radialGradient>
<radialGradient xlink:href="#snoo-radial-gragient" id="snoo-radial-gragient-7" cx="147.88" cy="32.94" r="39.77" fx="147.88" fy="32.94" gradientTransform="matrix(1 0 0 .98 0 .54)"/>
<radialGradient id="snoo-radial-gragient-8" cx="131.31" cy="73.08" r="32.6" fx="131.31" fy="73.08" gradientUnits="userSpaceOnUse">
<stop offset=".48" stop-color="#7a9299"/>
<stop offset=".67" stop-color="#172e35"/>
<stop offset=".75"/>
<stop offset=".82" stop-color="#172e35"/>
</radialGradient>
<style>
.snoo-cls-11{stroke-width:0;fill:#ffc49c}
</style>
</defs>
<path fill="#ff4500" stroke-width="0" d="M108 0C48.35 0 0 48.35 0 108c0 29.82 12.09 56.82 31.63 76.37l-20.57 20.57C6.98 209.02 9.87 216 15.64 216H108c59.65 0 108-48.35 108-108S167.65 0 108 0Z"/>
<circle cx="169.22" cy="106.98" r="25.22" fill="url(#snoo-radial-gragient)" stroke-width="0"/>
<circle cx="46.78" cy="106.98" r="25.22" fill="url(#snoo-radial-gragient-2)" stroke-width="0"/>
<ellipse cx="108.06" cy="128.64" fill="url(#snoo-radial-gragient-3)" stroke-width="0" rx="72" ry="54"/>
<path fill="url(#snoo-radial-gragient-4)" stroke-width="0" d="M86.78 123.48c-.42 9.08-6.49 12.38-13.56 12.38s-12.46-4.93-12.04-14.01c.42-9.08 6.49-15.02 13.56-15.02s12.46 7.58 12.04 16.66Z"/>
<path fill="url(#snoo-radial-gragient-5)" stroke-width="0" d="M129.35 123.48c.42 9.08 6.49 12.38 13.56 12.38s12.46-4.93 12.04-14.01c-.42-9.08-6.49-15.02-13.56-15.02s-12.46 7.58-12.04 16.66Z"/>
<ellipse cx="79.63" cy="116.37" class="snoo-cls-11" rx="2.8" ry="3.05"/>
<ellipse cx="146.21" cy="116.37" class="snoo-cls-11" rx="2.8" ry="3.05"/>
<path fill="url(#snoo-radial-gragient-6)" stroke-width="0" d="M108.06 142.92c-8.76 0-17.16.43-24.92 1.22-1.33.13-2.17 1.51-1.65 2.74 4.35 10.39 14.61 17.69 26.57 17.69s22.23-7.3 26.57-17.69c.52-1.23-.33-2.61-1.65-2.74-7.77-.79-16.16-1.22-24.92-1.22Z"/>
<circle cx="147.49" cy="49.43" r="17.87" fill="url(#snoo-radial-gragient-7)" stroke-width="0"/>
<path fill="url(#snoo-radial-gragient-8)" stroke-width="0" d="M107.8 76.92c-2.14 0-3.87-.89-3.87-2.27 0-16.01 13.03-29.04 29.04-29.04 2.14 0 3.87 1.73 3.87 3.87s-1.73 3.87-3.87 3.87c-11.74 0-21.29 9.55-21.29 21.29 0 1.38-1.73 2.27-3.87 2.27Z"/>
<path fill="#842123" stroke-width="0" d="M62.82 122.65c.39-8.56 6.08-14.16 12.69-14.16 6.26 0 11.1 6.39 11.28 14.33.17-8.88-5.13-15.99-12.05-15.99s-13.14 6.05-13.56 15.2c-.42 9.15 4.97 13.83 12.04 13.83h.52c-6.44-.16-11.3-4.79-10.91-13.2Zm90.48 0c-.39-8.56-6.08-14.16-12.69-14.16-6.26 0-11.1 6.39-11.28 14.33-.17-8.88 5.13-15.99 12.05-15.99 7.07 0 13.14 6.05 13.56 15.2.42 9.15-4.97 13.83-12.04 13.83h-.52c6.44-.16 11.3-4.79 10.91-13.2Z"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 5.1 KiB

Before After
Before After

View file

@ -1 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#1a73e8" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9.25"/><path d="M2.75 12h18.5"/><ellipse cx="12" cy="12" rx="4.1" ry="9.25"/><path d="M5 6.4h14M5 17.6h14"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48">
<g fill="none" stroke-width="3">
<path fill="#8fbffa" d="M3 24a21 21 0 1 0 42 0a21 21 0 1 0-42 0" />
<path stroke="#2859c5" stroke-linejoin="round" d="M3 24a21 21 0 1 0 42 0a21 21 0 1 0-42 0" />
<path stroke="#2859c5" stroke-linejoin="round" d="M15 24a9 21 0 1 1 18 0a9 21 0 1 1-18 0" />
<path stroke="#2859c5" stroke-linecap="round" stroke-linejoin="round" d="M4.5 31h39m-39-14h39" />
</g>
</svg>

Before

Width:  |  Height:  |  Size: 296 B

After

Width:  |  Height:  |  Size: 469 B

Before After
Before After