Merge remote-tracking branch 'upstream/dev' into feat/unified-model-connections

This commit is contained in:
Anish Sarkar 2026-06-13 19:04:49 +05:30
commit ab5423d2d2
45 changed files with 775 additions and 272 deletions

View file

@ -48,8 +48,8 @@ import {
isCommentReplyMetadata,
isConnectorIndexingMetadata,
isDocumentProcessingMetadata,
isNewMentionMetadata,
isInsufficientCreditsMetadata,
isNewMentionMetadata,
} from "@/contracts/types/inbox.types";
import { useDebouncedValue } from "@/hooks/use-debounced-value";
import type { InboxItem } from "@/hooks/use-inbox";

View file

@ -1,9 +1,9 @@
export { AllChatsSidebar, AllChatsSidebarContent } 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 { CreditBalanceDisplay } from "./CreditBalanceDisplay";
export { NavSection } from "./NavSection";
export { Sidebar } from "./Sidebar";
export { SidebarCollapseButton } from "./SidebarCollapseButton";

View file

@ -3,9 +3,9 @@
import {
AlarmClock,
FilePlus2,
type LucideIcon,
Search,
Settings2,
type LucideIcon,
WandSparkles,
X,
} from "lucide-react";

View file

@ -286,8 +286,8 @@ function PricingFAQ() {
Frequently Asked Questions
</h2>
<p className="mx-auto mt-4 max-w-2xl text-lg text-muted-foreground">
Everything you need to know about SurfSense credits and billing.
Can&apos;t find what you need? Reach out at{" "}
Everything you need to know about SurfSense credits and billing. Can&apos;t find what you
need? Reach out at{" "}
<a href="mailto:rohan@surfsense.com" className="text-blue-500 underline">
rohan@surfsense.com
</a>

View file

@ -77,9 +77,7 @@ export function EarnCreditsContent() {
<div className="w-full space-y-5">
<div className="text-center">
<h2 className="text-xl font-bold tracking-tight">Earn Credits</h2>
<p className="mt-1 text-sm text-muted-foreground">
Earn bonus credits by completing tasks
</p>
<p className="mt-1 text-sm text-muted-foreground">Earn bonus credits by completing tasks</p>
</div>
<div className="space-y-2">

View file

@ -1,11 +1,20 @@
"use client";
import { Loader2, Plus, Trash2 } from "lucide-react";
import { Check, ChevronDown, Loader2, Plus, Trash2 } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import {
Select,
SelectContent,
@ -15,6 +24,7 @@ import {
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import {
type LanguageOptions,
MAX_SPEAKERS,
type PodcastSpec,
type PodcastStyle,
@ -56,6 +66,7 @@ interface BriefReviewProps {
export function BriefReview({ podcast, spec }: BriefReviewProps) {
const [draft, setDraft] = useState<PodcastSpec>(spec);
const [voices, setVoices] = useState<VoiceOption[] | null>(null);
const [offering, setOffering] = useState<LanguageOptions | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
// A pushed spec change (saved edit or concurrent editor) resets the form to
@ -75,19 +86,26 @@ export function BriefReview({ podcast, spec }: BriefReviewProps) {
.catch(() => {
if (!cancelled) setVoices([]);
});
podcastsApiService
.listLanguages()
.then((options) => {
if (!cancelled) setOffering(options);
})
.catch(() => {
if (!cancelled) setOffering({ languages: [], allows_custom: false });
});
return () => {
cancelled = true;
};
}, []);
// The backend owns the offering; the draft's language stays listed even
// when it falls outside it (e.g. a custom tag entered earlier).
const languages = useMemo(() => {
const tags = new Set<string>();
for (const voice of voices ?? []) {
if (voice.language !== ANY_LANGUAGE) tags.add(voice.language);
}
const tags = new Set(offering?.languages ?? []);
tags.add(draft.language);
return [...tags].sort();
}, [voices, draft.language]);
}, [offering, draft.language]);
const voicesForLanguage = useMemo(
() => (voices ?? []).filter((voice) => speaks(voice, draft.language)),
@ -193,18 +211,22 @@ export function BriefReview({ podcast, spec }: BriefReviewProps) {
<div className="grid grid-cols-2 gap-4">
<div className="flex flex-col gap-2">
<Label htmlFor="podcast-language">Language</Label>
<Select value={draft.language} onValueChange={setLanguage}>
<SelectTrigger id="podcast-language">
<SelectValue placeholder="Language" />
</SelectTrigger>
<SelectContent>
{languages.map((tag) => (
<SelectItem key={tag} value={tag}>
{languageLabel(tag)}
</SelectItem>
))}
</SelectContent>
</Select>
{offering?.allows_custom ? (
<LanguageCombobox value={draft.language} languages={languages} onSelect={setLanguage} />
) : (
<Select value={draft.language} onValueChange={setLanguage}>
<SelectTrigger id="podcast-language">
<SelectValue placeholder="Language" />
</SelectTrigger>
<SelectContent>
{languages.map((tag) => (
<SelectItem key={tag} value={tag}>
{languageLabel(tag)}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="podcast-style">Style</Label>
@ -375,6 +397,80 @@ export function BriefReview({ podcast, spec }: BriefReviewProps) {
);
}
/** A searchable language picker for providers whose voices speak anything:
* the offered list comes from the backend, and any BCP-47 tag may be typed
* when none of them fits. */
function LanguageCombobox({
value,
languages,
onSelect,
}: {
value: string;
languages: string[];
onSelect: (language: string) => void;
}) {
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const pick = (tag: string) => {
onSelect(tag);
setOpen(false);
setQuery("");
};
const customTag = query.trim();
const isNewTag =
customTag.length > 0 && !languages.some((tag) => tag.toLowerCase() === customTag.toLowerCase());
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button
type="button"
role="combobox"
aria-expanded={open}
id="podcast-language"
className="border-popover-border flex h-9 w-full items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs outline-none transition-[color,box-shadow] disabled:cursor-not-allowed disabled:opacity-50"
>
<span className="line-clamp-1 text-left">{languageLabel(value)}</span>
<ChevronDown className="size-4 shrink-0 opacity-50" />
</button>
</PopoverTrigger>
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-0" align="start">
<Command>
<CommandInput
placeholder="Search or type a language tag…"
value={query}
onValueChange={setQuery}
/>
<CommandList>
<CommandEmpty>No matching language.</CommandEmpty>
<CommandGroup>
{languages.map((tag) => (
<CommandItem
key={tag}
value={tag}
keywords={[languageLabel(tag)]}
onSelect={() => pick(tag)}
>
<Check className={tag === value ? "size-4" : "size-4 opacity-0"} />
{languageLabel(tag)}
</CommandItem>
))}
{isNewTag ? (
<CommandItem value={customTag} onSelect={() => pick(customTag)}>
<Plus className="size-4" />
Use {customTag}
</CommandItem>
) : null}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}
/** The current selection stays listed even when it no longer matches the
* language filter, so the Select never renders an orphaned value. */
function voiceItems(candidates: VoiceOption[], selectedId: string): VoiceOption[] {