Merge remote-tracking branch 'upstream/dev' into feat/ui-revamp

This commit is contained in:
Anish Sarkar 2026-05-16 19:26:36 +05:30
commit f65bc81509
603 changed files with 45035 additions and 4652 deletions

View file

@ -5,12 +5,12 @@ export const metadata: Metadata = {
title: "What's New | SurfSense",
description: "Latest product updates, feature releases, and news from SurfSense.",
alternates: {
canonical: "https://surfsense.com/announcements",
canonical: "https://www.surfsense.com/announcements",
},
openGraph: {
title: "What's New | SurfSense",
description: "Latest product updates, feature releases, and news from SurfSense.",
url: "https://surfsense.com/announcements",
url: "https://www.surfsense.com/announcements",
type: "website",
},
twitter: {

View file

@ -4,7 +4,8 @@ import Image from "next/image";
import { notFound } from "next/navigation";
import { blog } from "@/.source/server";
import { BreadcrumbNav } from "@/components/seo/breadcrumb-nav";
import { ArticleJsonLd } from "@/components/seo/json-ld";
import { ArticleJsonLd, FAQJsonLd } from "@/components/seo/json-ld";
import { extractFaqFromBlogPost } from "@/lib/blog-faq";
import { formatDate } from "@/lib/utils";
import { getMDXComponents } from "@/mdx-components";
@ -21,6 +22,8 @@ interface BlogData {
author?: string;
authorAvatar?: string;
tags?: string[];
// Populated by Fumadocs when `lastModifiedTime: "git"` is set in source.config.ts.
lastModified?: Date;
body: React.ComponentType<{
components?: Record<string, React.ComponentType>;
}>;
@ -50,7 +53,7 @@ export async function generateMetadata(props: {
title: `${page.data.title} | SurfSense Blog`,
description: page.data.description,
alternates: {
canonical: `https://surfsense.com/blog/${slug}`,
canonical: `https://www.surfsense.com/blog/${slug}`,
},
openGraph: {
title: page.data.title,
@ -78,17 +81,23 @@ export default async function BlogPostPage(props: { params: Promise<{ slug: stri
const MDX = page.data.body;
const date = new Date(page.data.date);
const dateModified = page.data.lastModified
? new Date(page.data.lastModified).toISOString()
: undefined;
const faqEntries = await extractFaqFromBlogPost(slug);
return (
<div className="min-h-screen relative pt-20">
<ArticleJsonLd
title={page.data.title}
description={page.data.description}
url={`https://surfsense.com/blog/${slug}`}
url={`https://www.surfsense.com/blog/${slug}`}
datePublished={page.data.date}
dateModified={dateModified}
author={page.data.author ?? "SurfSense Team"}
image={page.data.image ? `https://surfsense.com${page.data.image}` : undefined}
image={page.data.image ? `https://www.surfsense.com${page.data.image}` : undefined}
/>
{faqEntries.length > 0 && <FAQJsonLd questions={faqEntries} />}
<div className="max-w-3xl mx-auto px-6 lg:px-10 pt-10 pb-20">
<BreadcrumbNav
items={[

View file

@ -35,9 +35,7 @@ function SearchIcon({ className }: { className?: string }) {
}
export function BlogWithSearchMagazine({ blogs }: { blogs: BlogEntry[] }) {
const featured = blogs[0];
if (!featured) {
if (blogs.length === 0) {
return (
<div className="relative overflow-hidden bg-neutral-50 px-4 md:px-8 dark:bg-neutral-950">
<Container className="relative pt-12 pb-24 md:pt-20">
@ -47,6 +45,17 @@ export function BlogWithSearchMagazine({ blogs }: { blogs: BlogEntry[] }) {
);
}
// `blogs` arrives pre-sorted from the server: explicitly featured posts
// first (ordered by `featured_order` asc, then date desc), then the rest
// by date desc. If nothing is explicitly featured, fall back to treating
// the newest post as the cover so the layout never feels empty up top.
// `MagazineSearchGrid` re-filters using `heroSlugs` so the hero/featured
// posts never duplicate into the archive grid.
const explicitlyFeatured = blogs.filter((b) => b.featured);
const heroBlogs = explicitlyFeatured.length > 0 ? explicitlyFeatured : blogs.slice(0, 1);
const heroSlugs = new Set(heroBlogs.map((b) => b.slug));
const [coverStory, ...secondaryFeatured] = heroBlogs;
return (
<div className="relative overflow-hidden bg-neutral-50 px-4 pt-20 md:px-8 dark:bg-neutral-950">
<div className="pointer-events-none absolute inset-0 bg-[radial-gradient(ellipse_80%_50%_at_50%_-20%,rgba(120,119,198,0.15),transparent)] dark:bg-[radial-gradient(ellipse_80%_50%_at_50%_-20%,rgba(120,119,198,0.12),transparent)]" />
@ -57,14 +66,36 @@ export function BlogWithSearchMagazine({ blogs }: { blogs: BlogEntry[] }) {
</h1>
</header>
<MagazineFeatured blog={featured} />
<MagazineFeatured blog={coverStory} />
<MagazineSearchGrid blogs={blogs} featuredSlug={featured.slug} />
{secondaryFeatured.length > 0 ? <MoreFeatured blogs={secondaryFeatured} /> : null}
<MagazineSearchGrid blogs={blogs} excludedSlugs={heroSlugs} />
</Container>
</div>
);
}
function MoreFeatured({ blogs }: { blogs: BlogEntry[] }) {
return (
<section aria-labelledby="more-featured-heading" className="mb-14">
<h2
id="more-featured-heading"
className="mb-6 font-serif text-2xl font-medium text-neutral-900 dark:text-neutral-100"
>
More featured
</h2>
<ul className="grid gap-6 sm:grid-cols-2">
{blogs.map((blog) => (
<li key={blog.slug}>
<MagazineCard blog={blog} />
</li>
))}
</ul>
</section>
);
}
function MagazineFeatured({ blog }: { blog: BlogEntry }) {
return (
<Link
@ -112,10 +143,11 @@ function MagazineFeatured({ blog }: { blog: BlogEntry }) {
function MagazineSearchGrid({
blogs: allBlogs,
featuredSlug,
excludedSlugs,
}: {
blogs: BlogEntry[];
featuredSlug: string;
/** Slugs already shown above the archive (cover story + "More featured"). */
excludedSlugs: Set<string>;
}) {
const [search, setSearch] = useState("");
@ -128,12 +160,15 @@ function MagazineSearchGrid({
);
const gridItems = useMemo(() => {
// When the reader is searching, surface every match (including
// featured posts they may be looking for); otherwise hide the posts
// that are already rendered as featured above the archive.
const results = search.trim() ? searcher.search(search) : allBlogs;
if (search.trim()) {
return results;
}
return results.filter((b) => b.slug !== featuredSlug);
}, [search, searcher, allBlogs, featuredSlug]);
return results.filter((b) => !excludedSlugs.has(b.slug));
}, [search, searcher, allBlogs, excludedSlugs]);
return (
<section aria-labelledby="archive-heading">

View file

@ -7,7 +7,7 @@ export const metadata: Metadata = {
title: "Blog | SurfSense - AI Search & Knowledge Management",
description: "Product updates, tutorials, and tips from the SurfSense team.",
alternates: {
canonical: "https://surfsense.com/blog",
canonical: "https://www.surfsense.com/blog",
},
};
@ -25,6 +25,8 @@ export interface BlogEntry {
image: string;
author: string;
authorAvatar: string;
featured: boolean;
featuredOrder?: number;
}
export default async function BlogPage() {
@ -38,6 +40,8 @@ export default async function BlogPage() {
image?: string;
author?: string;
authorAvatar?: string;
featured?: boolean;
featured_order?: number;
};
}>;
@ -51,8 +55,20 @@ export default async function BlogPage() {
image: page.data.image ?? "/og-image.png",
author: page.data.author ?? "SurfSense Team",
authorAvatar: page.data.authorAvatar ?? "/logo.png",
featured: page.data.featured ?? false,
featuredOrder: page.data.featured_order,
}))
.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
.sort((a, b) => {
// Featured first; then by `featured_order` asc within featured;
// then by `date` desc as the universal tie-breaker.
if (a.featured !== b.featured) return a.featured ? -1 : 1;
if (a.featured && b.featured) {
const aOrder = a.featuredOrder ?? Number.POSITIVE_INFINITY;
const bOrder = b.featuredOrder ?? Number.POSITIVE_INFINITY;
if (aOrder !== bOrder) return aOrder - bOrder;
}
return new Date(b.date).getTime() - new Date(a.date).getTime();
});
return <BlogWithSearchMagazine blogs={blogs} />;
}

View file

@ -9,7 +9,7 @@ export const metadata: Metadata = {
title: "Changelog | SurfSense",
description: "See what's new in SurfSense. Latest updates, features, and improvements.",
alternates: {
canonical: "https://surfsense.com/changelog",
canonical: "https://www.surfsense.com/changelog",
},
};

View file

@ -6,7 +6,7 @@ export const metadata: Metadata = {
description:
"Get in touch with the SurfSense team for enterprise AI search, knowledge management, or partnership inquiries.",
alternates: {
canonical: "https://surfsense.com/contact",
canonical: "https://www.surfsense.com/contact",
},
};

View file

@ -81,7 +81,7 @@ export async function generateMetadata({ params }: PageProps): Promise<Metadata>
const title = buildSeoTitle(model);
const description = buildSeoDescription(model);
const canonicalUrl = `https://surfsense.com/free/${model.seo_slug}`;
const canonicalUrl = `https://www.surfsense.com/free/${model.seo_slug}`;
const modelNameLower = model.name.toLowerCase();
return {
@ -161,7 +161,7 @@ export default async function FreeModelPage({ params }: PageProps) {
"@type": "WebApplication",
name: `${model.name} Free Chat Without Login - SurfSense`,
description,
url: `https://surfsense.com/free/${model.seo_slug}`,
url: `https://www.surfsense.com/free/${model.seo_slug}`,
applicationCategory: "ChatApplication",
operatingSystem: "Web",
offers: {
@ -173,12 +173,12 @@ export default async function FreeModelPage({ params }: PageProps) {
provider: {
"@type": "Organization",
name: "SurfSense",
url: "https://surfsense.com",
url: "https://www.surfsense.com",
},
isPartOf: {
"@type": "WebSite",
name: "SurfSense",
url: "https://surfsense.com",
url: "https://www.surfsense.com",
},
}}
/>

View file

@ -64,13 +64,13 @@ export const metadata: Metadata = {
"notebooklm alternative",
],
alternates: {
canonical: "https://surfsense.com/free",
canonical: "https://www.surfsense.com/free",
},
openGraph: {
title: "Free AI Chat, No Login Required | SurfSense",
description:
"Use ChatGPT free online without login. Chat with GPT-4, Claude AI, Gemini and 100+ AI models. Open source NotebookLM alternative.",
url: "https://surfsense.com/free",
url: "https://www.surfsense.com/free",
siteName: "SurfSense",
type: "website",
images: [
@ -164,8 +164,8 @@ export default async function FreeHubPage() {
name: "ChatGPT Free Online Without Login - SurfSense",
description:
"Use ChatGPT, Claude AI, Gemini and more AI models free online without login or sign-up. Open source NotebookLM alternative with no login required.",
url: "https://surfsense.com/free",
isPartOf: { "@type": "WebSite", name: "SurfSense", url: "https://surfsense.com" },
url: "https://www.surfsense.com/free",
isPartOf: { "@type": "WebSite", name: "SurfSense", url: "https://www.surfsense.com" },
mainEntity: {
"@type": "ItemList",
numberOfItems: seoModels.length,
@ -173,7 +173,7 @@ export default async function FreeHubPage() {
"@type": "ListItem",
position: i + 1,
name: m.name,
url: `https://surfsense.com/free/${m.seo_slug}`,
url: `https://www.surfsense.com/free/${m.seo_slug}`,
})),
},
}}

View file

@ -7,7 +7,7 @@ export const metadata: Metadata = {
description:
"Explore SurfSense plans and pricing. Start free with 500 pages & $5 in premium credits. Use ChatGPT, Claude AI, and premium AI models. Pay as you go at provider cost.",
alternates: {
canonical: "https://surfsense.com/pricing",
canonical: "https://www.surfsense.com/pricing",
},
};

View file

@ -4,7 +4,7 @@ export const metadata: Metadata = {
title: "Privacy Policy | SurfSense",
description: "Privacy Policy for SurfSense application",
alternates: {
canonical: "https://surfsense.com/privacy",
canonical: "https://www.surfsense.com/privacy",
},
};

View file

@ -4,7 +4,7 @@ export const metadata: Metadata = {
title: "Terms of Service | SurfSense",
description: "Terms of Service for SurfSense application",
alternates: {
canonical: "https://surfsense.com/terms",
canonical: "https://www.surfsense.com/terms",
},
};

View file

@ -50,7 +50,11 @@ import {
type TokenUsageData,
TokenUsageProvider,
} from "@/components/assistant-ui/token-usage-context";
import { type HitlDecision, PendingInterruptProvider } from "@/features/chat-messages/hitl";
import {
type HitlDecision,
PendingInterruptProvider,
type PendingInterruptState,
} from "@/features/chat-messages/hitl";
import { TimelineDataUI } from "@/features/chat-messages/timeline";
import {
applyActionLogSse,
@ -206,7 +210,10 @@ const MentionedDocumentInfoSchema = z.object({
id: z.number(),
title: z.string(),
document_type: z.string(),
kind: z.union([z.literal("doc"), z.literal("folder")]).optional().default("doc"),
kind: z
.union([z.literal("doc"), z.literal("folder")])
.optional()
.default("doc"),
});
const MentionedDocumentsPartSchema = z.object({
@ -267,12 +274,16 @@ export default function NewChatPage() {
const [tokenUsageStore] = useState(() => createTokenUsageStore());
const abortControllerRef = useRef<AbortController | null>(null);
const recentCancelRequestedAtRef = useRef(0);
const [pendingInterrupt, setPendingInterrupt] = useState<{
threadId: number;
assistantMsgId: string;
interruptData: Record<string, unknown>;
bundleToolCallIds: string[];
} | null>(null);
// One entry per paused subagent, in receipt order (which matches the
// backend's ``state.interrupts`` traversal — and therefore the order
// ``slice_decisions_by_tool_call`` consumes on resume). Cleared on submit
// or on a fresh user turn.
const [pendingInterrupts, setPendingInterrupts] = useState<PendingInterruptState[]>([]);
// Per-card staged decisions held until every pending card has submitted,
// at which point we batch them into one ``hitl-decision`` event in the
// same order as ``pendingInterrupts``. Using a ref because partial
// progress should not re-render the page.
const stagedDecisionsByInterruptIdRef = useRef<Map<string, HitlDecision[]>>(new Map());
const toolsWithUI = TOOLS_WITH_UI_ALL;
const setMessageDocumentsMap = useSetAtom(messageDocumentsMapAtom);
@ -1027,9 +1038,7 @@ export default function NewChatPage() {
mentioned_surfsense_doc_ids: hasSurfsenseDocIds
? mentionedDocumentIds.surfsense_doc_ids
: undefined,
mentioned_folder_ids: hasFolderIds
? mentionedDocumentIds.folder_ids
: undefined,
mentioned_folder_ids: hasFolderIds ? mentionedDocumentIds.folder_ids : undefined,
// Full mention metadata (docs + folders, with
// ``kind`` discriminator) so the BE can embed a
// ``mentioned-documents`` ContentPart on the
@ -1191,12 +1200,24 @@ export default function NewChatPage() {
)
);
if (currentThreadId) {
setPendingInterrupt({
threadId: currentThreadId,
assistantMsgId,
interruptData,
bundleToolCallIds,
});
// ``tool_call_id`` is stamped on the backend by
// ``checkpointed_subagent_middleware``. Without it we
// can't address the paused subagent on resume — skip
// rather than fabricate a synthetic key.
const interruptId = String(interruptData.tool_call_id ?? "");
if (interruptId) {
const incoming: PendingInterruptState = {
interruptId,
threadId: currentThreadId,
assistantMsgId,
interruptData,
bundleToolCallIds,
};
setPendingInterrupts((prev) => {
const without = prev.filter((p) => p.interruptId !== interruptId);
return [...without, incoming];
});
}
}
break;
}
@ -1272,7 +1293,7 @@ export default function NewChatPage() {
// by ``persist_assistant_shell``. Rename the optimistic
// id, migrate ``tokenUsageStore`` so any pending
// ``data-token-usage`` payload binds to the new id,
// remap any in-flight ``pendingInterrupt`` reference,
// remap any in-flight ``pendingInterrupts`` entries,
// and reassign the closure variable so the in-stream
// flush callback (line ~1074) keeps writing to the
// renamed message.
@ -1288,10 +1309,12 @@ export default function NewChatPage() {
: m
)
);
setPendingInterrupt((prev) =>
prev && prev.assistantMsgId === oldAssistantMsgId
? { ...prev, assistantMsgId: newAssistantMsgId }
: prev
setPendingInterrupts((prev) =>
prev.map((p) =>
p.assistantMsgId === oldAssistantMsgId
? { ...p, assistantMsgId: newAssistantMsgId }
: p
)
);
assistantMsgId = newAssistantMsgId;
break;
@ -1378,14 +1401,23 @@ export default function NewChatPage() {
edited_action?: { name: string; args: Record<string, unknown> };
}>
) => {
if (!pendingInterrupt) return;
const { threadId: resumeThreadId } = pendingInterrupt;
if (pendingInterrupts.length === 0) return;
// All cards in this turn share the same threadId/assistantMsgId
// (they're siblings of one parent agent step), so reading from
// the first entry is safe.
const resumeThreadId = pendingInterrupts[0].threadId;
// Destructured separately as ``let`` so the SSE
// ``data-assistant-message-id`` handler (resume always
// allocates a fresh server-side row) can rename it to
// the canonical ``msg-{db_id}`` mid-stream.
let assistantMsgId = pendingInterrupt.assistantMsgId;
setPendingInterrupt(null);
let assistantMsgId = pendingInterrupts[0].assistantMsgId;
// Concatenate every card's tool-call ids in pendingInterrupts order;
// this matches the ``decisions`` ordering produced by
// ``handleApprovalSubmit`` and the backend slicer's traversal of
// ``state.interrupts``.
const allBundleToolCallIds = pendingInterrupts.flatMap((p) => p.bundleToolCallIds);
setPendingInterrupts([]);
stagedDecisionsByInterruptIdRef.current.clear();
setIsRunning(true);
const token = getBearerToken();
@ -1462,7 +1494,7 @@ export default function NewChatPage() {
// collapse onto ``decisions[0]``. Cards outside the bundle are
// untouched. Mirrors the host ``hitl-decision`` handler.
const decisionByTcId = new Map<string, (typeof decisions)[number]>();
const tcIds = pendingInterrupt.bundleToolCallIds;
const tcIds = allBundleToolCallIds;
if (decisions.length === tcIds.length) {
for (let i = 0; i < tcIds.length; i++) decisionByTcId.set(tcIds[i], decisions[i]);
}
@ -1474,7 +1506,7 @@ export default function NewChatPage() {
if (!d) continue;
if (typeof part.result !== "object" || part.result === null) continue;
if (!("__interrupt__" in (part.result as Record<string, unknown>))) continue;
const decided = d.type as "approve" | "reject" | "edit";
const decided = d.type;
if (decided === "edit" && d.edited_action) {
const mergedArgs = { ...part.args, ...d.edited_action.args };
part.args = mergedArgs;
@ -1594,12 +1626,22 @@ export default function NewChatPage() {
: m
)
);
setPendingInterrupt({
threadId: resumeThreadId,
assistantMsgId,
interruptData,
bundleToolCallIds,
});
{
const interruptId = String(interruptData.tool_call_id ?? "");
if (interruptId) {
const incoming: PendingInterruptState = {
interruptId,
threadId: resumeThreadId,
assistantMsgId,
interruptData,
bundleToolCallIds,
};
setPendingInterrupts((prev) => {
const without = prev.filter((p) => p.interruptId !== interruptId);
return [...without, incoming];
});
}
}
break;
}
@ -1677,7 +1719,7 @@ export default function NewChatPage() {
}
},
[
pendingInterrupt,
pendingInterrupts,
messages,
searchSpaceId,
localFilesystemEnabled,
@ -1698,17 +1740,19 @@ export default function NewChatPage() {
edited_action?: { name: string; args: Record<string, unknown> };
}>;
};
if (!detail?.decisions || !pendingInterrupt) return;
if (!detail?.decisions || pendingInterrupts.length === 0) return;
const incoming = detail.decisions;
if (incoming.length === 0) return;
const tcIds = pendingInterrupt.bundleToolCallIds;
// Concatenated tool-call ids across every pending card, in the
// order ``handleApprovalSubmit`` produced ``incoming``.
const tcIds = pendingInterrupts.flatMap((p) => p.bundleToolCallIds);
const N = tcIds.length;
// Bundles must submit exactly one decision per action_request.
// Refuse rather than silently broadcast a single decision across
// the bundle (would mis-apply rejects/edits and diverge from
// what handleResume sends to /resume).
if (N > 1 && incoming.length !== N) {
// Refuse rather than silently broadcast or drop. The orchestrator
// only fires ``hitl-decision`` once every pending card has
// submitted, so a count mismatch indicates a contract drift
// (and would later make the backend slicer raise).
if (incoming.length !== N) {
toast.error(
`Cannot resume: ${incoming.length} decision(s) submitted for ${N} pending actions.`
);
@ -1730,9 +1774,12 @@ export default function NewChatPage() {
submittedDecisions.push(decision);
}
// All pending cards belong to the same assistant message, so a
// single content-update pass suffices.
const targetAssistantMsgId = pendingInterrupts[0].assistantMsgId;
setMessages((prev) =>
prev.map((m) => {
if (m.id !== pendingInterrupt.assistantMsgId) return m;
if (m.id !== targetAssistantMsgId) return m;
const parts = m.content as unknown as Array<Record<string, unknown>>;
const newContent = parts.map((part) => {
const tcId = part.toolCallId as string | undefined;
@ -1740,7 +1787,7 @@ export default function NewChatPage() {
if (!d || part.type !== "tool-call") return part;
if (typeof part.result !== "object" || part.result === null) return part;
if (!("__interrupt__" in (part.result as Record<string, unknown>))) return part;
const decided = d.type as "approve" | "reject" | "edit";
const decided = d.type;
if (decided === "edit" && d.edited_action) {
return {
...part,
@ -1769,7 +1816,7 @@ export default function NewChatPage() {
};
window.addEventListener("hitl-decision", handler);
return () => window.removeEventListener("hitl-decision", handler);
}, [handleResume, pendingInterrupt]);
}, [handleResume, pendingInterrupts]);
// Convert message (pass through since already in correct format)
const convertMessage = useCallback(
@ -1909,12 +1956,10 @@ export default function NewChatPage() {
filesystem_mode: selection.filesystem_mode,
client_platform: selection.client_platform,
local_filesystem_mounts: selection.local_filesystem_mounts,
mentioned_document_ids:
regenerateDocIds.length > 0 ? regenerateDocIds : undefined,
mentioned_document_ids: regenerateDocIds.length > 0 ? regenerateDocIds : undefined,
mentioned_surfsense_doc_ids:
regenerateSurfsenseDocIds.length > 0 ? regenerateSurfsenseDocIds : undefined,
mentioned_folder_ids:
regenerateFolderIds.length > 0 ? regenerateFolderIds : undefined,
mentioned_folder_ids: regenerateFolderIds.length > 0 ? regenerateFolderIds : undefined,
// Full mention metadata for the regenerate-specific
// source list. Only meaningful for edit (the BE only
// re-persists a user row when ``user_query`` is set);
@ -2293,11 +2338,32 @@ export default function NewChatPage() {
[handleRegenerate, messages, agentActionItems]
);
const handleApprovalSubmit = useCallback((orderedDecisions: HitlDecision[]) => {
window.dispatchEvent(
new CustomEvent("hitl-decision", { detail: { decisions: orderedDecisions } })
);
}, []);
const handleApprovalSubmit = useCallback(
(interruptId: string, decisions: HitlDecision[]) => {
// Stage this card's decisions; only fire the resume once every
// pending card in the current turn has submitted, so the
// backend slicer sees a single concatenated decisions list
// whose total matches the parent state's pending action count.
stagedDecisionsByInterruptIdRef.current.set(interruptId, decisions);
if (stagedDecisionsByInterruptIdRef.current.size < pendingInterrupts.length) {
return;
}
const ordered: HitlDecision[] = [];
for (const pi of pendingInterrupts) {
const staged = stagedDecisionsByInterruptIdRef.current.get(pi.interruptId);
if (!staged) {
// Defensive: a missing entry means the staging map and
// the pending list disagreed for one cycle. Bail rather
// than dispatch a count-mismatched batch.
return;
}
ordered.push(...staged);
}
stagedDecisionsByInterruptIdRef.current.clear();
window.dispatchEvent(new CustomEvent("hitl-decision", { detail: { decisions: ordered } }));
},
[pendingInterrupts]
);
const handleEditDialogChoice = useCallback(
async (choice: EditMessageDialogChoice) => {
@ -2369,7 +2435,7 @@ export default function NewChatPage() {
<TimelineDataUI />
<StepSeparatorDataUI />
<PendingInterruptProvider
pendingInterrupt={pendingInterrupt}
pendingInterrupts={pendingInterrupts}
onSubmit={handleApprovalSubmit}
>
<div key={searchSpaceId} className="flex h-full overflow-hidden">

View file

@ -50,7 +50,7 @@ export async function generateMetadata(props: { params: Promise<{ slug?: string[
title: `${page.data.title} | SurfSense Docs`,
description: page.data.description,
alternates: {
canonical: `https://surfsense.com/docs${slugPath ? `/${slugPath}` : ""}`,
canonical: `https://www.surfsense.com/docs${slugPath ? `/${slugPath}` : ""}`,
},
openGraph: {
title: `${page.data.title} | SurfSense Docs`,

View file

@ -41,9 +41,9 @@ export const viewport: Viewport = {
};
export const metadata: Metadata = {
metadataBase: new URL("https://surfsense.com"),
metadataBase: new URL("https://www.surfsense.com"),
alternates: {
canonical: "https://surfsense.com",
canonical: "https://www.surfsense.com",
},
title: "SurfSense Open Source, Privacy-Focused NotebookLM Alternative for Teams",
description:
@ -90,7 +90,7 @@ export const metadata: Metadata = {
title: "SurfSense Open Source, Privacy-Focused NotebookLM Alternative for Teams",
description:
"Open source NotebookLM alternative for teams with no data limits. Use ChatGPT, Claude, and any AI model for free.",
url: "https://surfsense.com",
url: "https://www.surfsense.com",
siteName: "SurfSense",
type: "website",
images: [

View file

@ -17,6 +17,6 @@ export default function robots(): MetadataRoute.Robots {
],
},
],
sitemap: "https://surfsense.com/sitemap.xml",
sitemap: "https://www.surfsense.com/sitemap.xml",
};
}