From f82fb8d43cfb9942b75a213d557a09cfc8831528 Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Fri, 18 Sep 2026 03:10:39 +0530 Subject: [PATCH 1/8] refactor(studio): lift document selection into shared workspace state Move Composer's local selected-documents Set into the workspace sources hook, passed down as selectedDocumentIds/onSelectionChange/onToggleAll, so selection state can be shared with the rest of the dashboard. Co-Authored-By: Claude Sonnet 5 --- .../src/features/dashboard/dashboard-page.tsx | 3 ++ .../src/features/studio/studio-panel.tsx | 38 +++++++++---------- 2 files changed, 21 insertions(+), 20 deletions(-) diff --git a/surfsense_local/frontend/src/features/dashboard/dashboard-page.tsx b/surfsense_local/frontend/src/features/dashboard/dashboard-page.tsx index a4fd6e1bb8..839709f0c0 100644 --- a/surfsense_local/frontend/src/features/dashboard/dashboard-page.tsx +++ b/surfsense_local/frontend/src/features/dashboard/dashboard-page.tsx @@ -253,6 +253,9 @@ function WorkspaceDashboard({ void + onToggleAll: () => void isCreating: boolean onGenerate: (job: StudioJobCreate) => void }) { const ready = documents.filter((document) => document.status === "ready") - const [selected, setSelected] = useState( - () => new Set(ready.map((document) => document.id)) - ) + const selected = new Set(selectedDocumentIds) const [prompt, setPrompt] = useState("") const podcast = usePodcastBrief(format === "podcast" ? workspaceId : null) const allSelected = ready.length > 0 && selected.size === ready.length - const toggle = (id: number) => - setSelected((current) => { - const next = new Set(current) - if (next.has(id)) { - next.delete(id) - } else { - next.add(id) - } - return next - }) + const toggle = (id: number) => onSelectionChange(id, !selected.has(id)) // A podcast is generated from its reviewed brief, so it waits for the brief. const briefReady = format !== "podcast" || podcast.brief != null @@ -126,13 +121,7 @@ function Composer({ variant="ghost" size="xs" className="text-muted-foreground" - onClick={() => - setSelected( - allSelected - ? new Set() - : new Set(ready.map((document) => document.id)) - ) - } + onClick={onToggleAll} > {allSelected ? "Deselect all" : "Select all"} @@ -249,6 +238,9 @@ function FormatCard({ export function StudioPanel({ workspaceId, documents, + selectedDocumentIds, + onSelectionChange, + onToggleAll, formats, isCreating, error, @@ -256,6 +248,9 @@ export function StudioPanel({ }: { workspaceId: number documents: WorkspaceDocument[] + selectedDocumentIds: number[] + onSelectionChange: (documentId: number, included: boolean) => void + onToggleAll: () => void formats: StudioFormat[] isCreating: boolean error: string | null @@ -307,6 +302,9 @@ export function StudioPanel({ workspaceId={workspaceId} format={selectedFormat.key} documents={documents} + selectedDocumentIds={selectedDocumentIds} + onSelectionChange={onSelectionChange} + onToggleAll={onToggleAll} isCreating={isCreating} onGenerate={(job) => { void onGenerate(job).then((created) => { From 435f409df46059323568a370cc08ff18053f5fc9 Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Fri, 18 Sep 2026 03:21:12 +0530 Subject: [PATCH 2/8] feat(studio): replace inline source checklist with a drill-down sub-view The Composer dialog listed every ready document inline, pushing the prompt field and Generate button down as sources grew. Replace it with a compact "N sources" trigger that opens a dedicated sub-view (back button, select-all, full checklist), with a soft opacity/blur crossfade between the two and the Generate button pinned to the bottom of the main view. Co-Authored-By: Claude Sonnet 5 --- .../src/features/studio/studio-panel.tsx | 218 +++++++++++------- 1 file changed, 137 insertions(+), 81 deletions(-) diff --git a/surfsense_local/frontend/src/features/studio/studio-panel.tsx b/surfsense_local/frontend/src/features/studio/studio-panel.tsx index 24675cebd1..f219e798ce 100644 --- a/surfsense_local/frontend/src/features/studio/studio-panel.tsx +++ b/surfsense_local/frontend/src/features/studio/studio-panel.tsx @@ -1,5 +1,11 @@ import { useState } from "react" -import { CheckIcon, FileIcon, SparklesIcon } from "@/components/ui/icons" +import { + ArrowLeftIcon, + CheckIcon, + ChevronRightIcon, + FileIcon, + SparklesIcon, +} from "@/components/ui/icons" import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert" import { Button } from "@/components/ui/button" @@ -85,6 +91,7 @@ function Composer({ const ready = documents.filter((document) => document.status === "ready") const selected = new Set(selectedDocumentIds) const [prompt, setPrompt] = useState("") + const [view, setView] = useState<"main" | "sources">("main") const podcast = usePodcastBrief(format === "podcast" ? workspaceId : null) const allSelected = ready.length > 0 && selected.size === ready.length @@ -95,26 +102,109 @@ function Composer({ const canGenerate = selected.size > 0 && !isCreating && briefReady return ( -
- {format === "podcast" ? ( - podcast.brief ? ( - - ) : ( -

- {podcast.error ?? "Preparing the brief…"} -

- ) - ) : null} +
+
+
+ {format === "podcast" ? ( + podcast.brief ? ( + + ) : ( +

+ {podcast.error ?? "Preparing the brief…"} +

+ ) + ) : null} + + {ready.length === 0 ? ( +
+

+ Sources +

+

+ Add and index a source first — only ready documents can be + used. +

+
+ ) : ( + + )} + +
+

+ Prompt (optional) +

+ setPrompt(event.target.value)} + /> +
+
+ +
+ +
+
-
+
-

+ {ready.length > 0 ? (

- {ready.length === 0 ? ( -

- Add and index a source first — only ready documents can be used. -

- ) : ( -
-
- {ready.map((document) => { - const on = selected.has(document.id) - return ( - - ) - })} -
+ {on ? : null} + + + {document.title} + + + ) + })}
- )} -
- -
-

- Prompt (optional) -

- setPrompt(event.target.value)} - /> +
- -
) } @@ -288,7 +344,7 @@ export function StudioPanel({ if (!open) setFormat(null) }} > - + {selectedFormat ? ( <> From 48f68ad62c19ece9f30656a96e4175807e7a8625 Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Fri, 18 Sep 2026 03:53:29 +0530 Subject: [PATCH 3/8] fix(egress): gate the catalog's own model download, not just the manual pull MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Download button in the model catalog hits POST /llm/install, which never called egress.require() — only the unused /providers/{provider}/pull route did. Chat models installed through the catalog UI left silently, with no consent popup and no destination logged. Also derive the actual host from the resolved pull target, since Ollama's hf.co/ fallback installs go straight to huggingface.co, not registry.ollama.ai. Co-Authored-By: Claude Sonnet 5 --- .../backend/modules/egress/service.py | 17 ++++- .../modules/llm/recommendations/router.py | 9 +++ surfsense_local/backend/modules/llm/router.py | 7 +- .../tests/integration/llm/test_egress.py | 73 +++++++++++++++++++ .../tests/integration/llm/test_routes.py | 1 + .../backend/tests/unit/egress/test_service.py | 15 ++++ 6 files changed, 117 insertions(+), 5 deletions(-) create mode 100644 surfsense_local/backend/tests/unit/egress/test_service.py diff --git a/surfsense_local/backend/modules/egress/service.py b/surfsense_local/backend/modules/egress/service.py index 1def318554..9852291e19 100644 --- a/surfsense_local/backend/modules/egress/service.py +++ b/surfsense_local/backend/modules/egress/service.py @@ -18,9 +18,9 @@ class EgressDeniedError(Exception): - def __init__(self, destination: str) -> None: + def __init__(self, destination: str, host: str | None = None) -> None: self.destination = destination - self.host = host_of(destination) + self.host = host or host_of(destination) super().__init__(f"sending data to {self.host} is off in Settings > Network") @@ -28,6 +28,13 @@ def host_of(destination: str) -> str: return HOSTS.get(destination) or destination.removeprefix(HOST_PREFIX) +def ollama_pull_host(model_name: str) -> str: + """Ollama fetches an `hf.co/` pull straight from Hugging Face, not its own registry.""" + if model_name.startswith("hf.co/"): + return "huggingface.co" + return HOSTS[OLLAMA_PULL] + + def is_destination(value: str) -> bool: return value in HOSTS or ( value.startswith(HOST_PREFIX) and len(value) > len(HOST_PREFIX) @@ -49,12 +56,14 @@ def _is_loopback(host: str) -> bool: return False -def require(session: Session, destination: str | None) -> None: +def require( + session: Session, destination: str | None, host: str | None = None +) -> None: if destination is None: return row = session.get(EgressDestination, destination) if row is None or not row.enabled: - raise EgressDeniedError(destination) + raise EgressDeniedError(destination, host) row.last_call_at = datetime.now(UTC) diff --git a/surfsense_local/backend/modules/llm/recommendations/router.py b/surfsense_local/backend/modules/llm/recommendations/router.py index 60fd38f67a..5927e7d843 100644 --- a/surfsense_local/backend/modules/llm/recommendations/router.py +++ b/surfsense_local/backend/modules/llm/recommendations/router.py @@ -8,6 +8,7 @@ from sqlalchemy.orm import Session from api.dependencies import SessionDep, transact +from modules.egress import service as egress from modules.llm.models import ModelRole, SelectedModel from modules.llm.recommendations.catalog import ( InsufficientDiskError, @@ -61,6 +62,7 @@ async def install_model( payload: InstallRequest, request: Request, service: CatalogServiceDep, + session: SessionDep, ) -> StreamingResponse: try: runtime, _model, plan = await service.preflight(payload.catalog_id) @@ -81,6 +83,13 @@ async def install_model( except RuntimeError as error: raise HTTPException(status.HTTP_409_CONFLICT, str(error)) from error + await transact( + session, + egress.require, + egress.OLLAMA_PULL, + egress.ollama_pull_host(plan.model_name), + ) + lock = service.install_lock(runtime.name) if lock.locked(): raise HTTPException( diff --git a/surfsense_local/backend/modules/llm/router.py b/surfsense_local/backend/modules/llm/router.py index d6d2e5adc6..f27d5b89ed 100644 --- a/surfsense_local/backend/modules/llm/router.py +++ b/surfsense_local/backend/modules/llm/router.py @@ -328,7 +328,12 @@ async def pull_model( service: CatalogServiceDep, session: SessionDep, ) -> StreamingResponse: - await transact(session, egress.require, egress.OLLAMA_PULL) + await transact( + session, + egress.require, + egress.OLLAMA_PULL, + egress.ollama_pull_host(payload.name), + ) lock = service.install_lock(store.name) if lock.locked(): raise HTTPException( diff --git a/surfsense_local/backend/tests/integration/llm/test_egress.py b/surfsense_local/backend/tests/integration/llm/test_egress.py index eb95d10250..be89e2aa1d 100644 --- a/surfsense_local/backend/tests/integration/llm/test_egress.py +++ b/surfsense_local/backend/tests/integration/llm/test_egress.py @@ -1,10 +1,15 @@ """Nothing leaves the machine until the user allows that destination.""" +import json +from pathlib import Path + import pytest from httpx import AsyncClient from sqlalchemy import Engine from modules.llm.models import ProviderConnection +from modules.llm.recommendations.dependencies import get_catalog_service +from shared.config import get_llm_settings from shared.db import create_session_factory pytestmark = pytest.mark.integration @@ -95,3 +100,71 @@ async def test_unknown_destination_is_rejected(client: AsyncClient) -> None: """Only destinations the app contacts can be toggled.""" reply = await client.put("/egress/keygen", json={"enabled": True}) assert reply.status_code == 422 + + +def _configure_llmfit(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + system = {"system": {"available_ram_gb": 16, "total_ram_gb": 16}} + fit = { + "models": [ + { + "name": "Qwen/Qwen3-8B", + "provider": "Qwen", + "parameter_count": "8B", + "use_case": "chat", + "fit_level": "good", + "score": 85, + "runtime": "llamacpp", + "run_mode": "gpu", + "best_quant": "Q4_K_M", + "memory_required_gb": 6, + "memory_available_gb": 16, + "disk_size_gb": 5.2, + "effective_context_length": 8192, + "capability_ids": ["tool_use"], + "ollama_name": "qwen3:8b", + "gguf_sources": [], + } + ] + } + executable = tmp_path / "llmfit" + executable.write_text( + "#!/usr/bin/env python3\n" + "import sys\n" + f"system = {json.dumps(system)!r}\n" + f"fit = {json.dumps(fit)!r}\n" + 'print("llmfit 1.1.11" if "--version" in sys.argv ' + 'else system if "system" in sys.argv else fit)\n' + ) + executable.chmod(0o755) + monkeypatch.setattr(get_llm_settings(), "llmfit_path", executable) + get_catalog_service.cache_clear() + + +async def test_catalog_install_is_refused_until_allowed( + client: AsyncClient, + ollama_server: str, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The catalog's own install route is gated the same as a manual pull.""" + _configure_llmfit(tmp_path, monkeypatch) + catalog = (await client.get("/llm/catalog?refresh=true")).json() + catalog_id = next( + row["catalog_id"] for row in catalog["curated"] if row["fit"] != "unknown" + ) + + refused = await client.post( + "/llm/install", json={"catalog_id": catalog_id, "select": False} + ) + assert refused.status_code == 403 + assert refused.json()["detail"]["destination"] == "ollama_pull" + assert (await _destinations(client))["ollama_pull"]["last_call_at"] is None + + await client.put("/egress/ollama_pull", json={"enabled": True}) + async with client.stream( + "POST", "/llm/install", json={"catalog_id": catalog_id, "select": False} + ) as reply: + assert reply.status_code == 200 + await reply.aread() + assert (await _destinations(client))["ollama_pull"]["last_call_at"] is not None + get_catalog_service.cache_clear() diff --git a/surfsense_local/backend/tests/integration/llm/test_routes.py b/surfsense_local/backend/tests/integration/llm/test_routes.py index 758fbee4e7..50a80d08a9 100644 --- a/surfsense_local/backend/tests/integration/llm/test_routes.py +++ b/surfsense_local/backend/tests/integration/llm/test_routes.py @@ -485,6 +485,7 @@ async def test_ranked_catalog_installs_and_selects_in_one_stream( assert [row["canonical_id"] for row in scanned_curated] == ["Qwen/Qwen3-8B"] catalog_id = scanned_curated[0]["catalog_id"] + await client.put("/egress/ollama_pull", json={"enabled": True}) events = [] async with client.stream( "POST", diff --git a/surfsense_local/backend/tests/unit/egress/test_service.py b/surfsense_local/backend/tests/unit/egress/test_service.py new file mode 100644 index 0000000000..cc5c3292b1 --- /dev/null +++ b/surfsense_local/backend/tests/unit/egress/test_service.py @@ -0,0 +1,15 @@ +import pytest + +from modules.egress import service as egress + +pytestmark = pytest.mark.unit + + +def test_a_library_pull_reports_the_ollama_registry() -> None: + """Ollama's own model library is served from its registry.""" + assert egress.ollama_pull_host("qwen3:8b") == "registry.ollama.ai" + + +def test_an_hf_co_fallback_pull_reports_hugging_face() -> None: + """Ollama fetches `hf.co/` pulls straight from Hugging Face, not its own registry.""" + assert egress.ollama_pull_host("hf.co/Qwen/Qwen3-8B-GGUF:Q4_K_M") == "huggingface.co" From 1c5dd8fc0d72df45e80d80b3d750931e0ae36e6a Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Fri, 18 Sep 2026 04:20:16 +0530 Subject: [PATCH 4/8] fix(local): scroll long source lists in dialogs, label Updates section clearly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The main/sources views shared one CSS grid cell so the row's height included both children's content size — a long source list could grow past its intended bound and spill out of the dialog instead of scrolling. Overlay sources with position:absolute instead: main alone (in normal flow) sets the dialog's height per format, and sources fills that box exactly via inset-0, so its list scrolls within whatever height that format's dialog has, however long the list gets. Also renames the Updates settings heading to "App updates", matching how it's already referred to in its own description and in Settings > Network. Co-Authored-By: Claude Sonnet 5 --- .../src/features/egress/network-settings.tsx | 2 +- .../src/features/license/license-settings.tsx | 2 +- .../src/features/settings/models-settings.tsx | 2 +- .../frontend/src/features/studio/studio-panel.tsx | 15 ++++++++------- .../src/features/updates/update-settings.tsx | 6 ++---- 5 files changed, 13 insertions(+), 14 deletions(-) diff --git a/surfsense_local/frontend/src/features/egress/network-settings.tsx b/surfsense_local/frontend/src/features/egress/network-settings.tsx index ddafe5bf25..00d6331468 100644 --- a/surfsense_local/frontend/src/features/egress/network-settings.tsx +++ b/surfsense_local/frontend/src/features/egress/network-settings.tsx @@ -88,7 +88,7 @@ export function NetworkSettings() { return ( {destinations.isLoading ? ( diff --git a/surfsense_local/frontend/src/features/license/license-settings.tsx b/surfsense_local/frontend/src/features/license/license-settings.tsx index ecd13161d4..c65b95d926 100644 --- a/surfsense_local/frontend/src/features/license/license-settings.tsx +++ b/surfsense_local/frontend/src/features/license/license-settings.tsx @@ -217,7 +217,7 @@ export function LicenseSettings() { return ( {loading ? (
diff --git a/surfsense_local/frontend/src/features/settings/models-settings.tsx b/surfsense_local/frontend/src/features/settings/models-settings.tsx index c3ca354516..7d7122c86d 100644 --- a/surfsense_local/frontend/src/features/settings/models-settings.tsx +++ b/surfsense_local/frontend/src/features/settings/models-settings.tsx @@ -8,7 +8,7 @@ import { useModelSelection } from "@/features/model-selection/use-model-selectio import { SettingsSection } from "./settings-section" const DESCRIPTION = - "Download local models or choose the model SurfSense uses for new messages." + "Add models locally or via an OpenAI compatible endpoint." export function ModelsSettings({ onModelUnavailable, diff --git a/surfsense_local/frontend/src/features/studio/studio-panel.tsx b/surfsense_local/frontend/src/features/studio/studio-panel.tsx index f219e798ce..f7dbfd01eb 100644 --- a/surfsense_local/frontend/src/features/studio/studio-panel.tsx +++ b/surfsense_local/frontend/src/features/studio/studio-panel.tsx @@ -17,6 +17,7 @@ import { DialogTitle, } from "@/components/ui/dialog" import { Input } from "@/components/ui/input" +import { ScrollShadow } from "@/components/ui/scroll-shadow" import { Spinner } from "@/components/ui/spinner" import { Tooltip, @@ -102,10 +103,10 @@ function Composer({ const canGenerate = selected.size > 0 && !isCreating && briefReady return ( -
+
-
+
) : null}
-
-
+ +
{ready.map((document) => { const on = selected.has(document.id) return ( @@ -248,7 +249,7 @@ function Composer({ ) })}
-
+
) diff --git a/surfsense_local/frontend/src/features/updates/update-settings.tsx b/surfsense_local/frontend/src/features/updates/update-settings.tsx index ce91c1c715..58d721f6fb 100644 --- a/surfsense_local/frontend/src/features/updates/update-settings.tsx +++ b/surfsense_local/frontend/src/features/updates/update-settings.tsx @@ -41,11 +41,9 @@ export function UpdateSettings() { return (
-

Updates

+

App updates

- Updates come from GitHub Releases and are free for everyone. SurfSense - never contacts them until you allow App updates under Network, which - is also what turns on the check at launch. + Free updates from GitHub Releases. SurfSense stays silent until you allow App updates under Network, which also enables the launch check.

{state.status === "error" ? (

From 104b8b4c4445d7b55816755ebdb6ff73f8bfaf24 Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Fri, 18 Sep 2026 04:24:31 +0530 Subject: [PATCH 5/8] feat(local): gate manual update checks behind egress consent, mark remove-speaker destructive "Check now" now asks for App updates consent via the same egress prompt the sidebar's automatic check already uses, instead of being disabled until the automatic-updates switch is turned on elsewhere in the dialog. Also gives the remove-speaker button in the podcast brief form a destructive variant so a removal reads as such. Co-Authored-By: Claude Sonnet 5 --- .../features/studio/podcast-brief-form.tsx | 2 +- .../src/features/updates/update-settings.tsx | 27 +++++++++++++------ 2 files changed, 20 insertions(+), 9 deletions(-) diff --git a/surfsense_local/frontend/src/features/studio/podcast-brief-form.tsx b/surfsense_local/frontend/src/features/studio/podcast-brief-form.tsx index 0e5200dbe5..c2f6b47011 100644 --- a/surfsense_local/frontend/src/features/studio/podcast-brief-form.tsx +++ b/surfsense_local/frontend/src/features/studio/podcast-brief-form.tsx @@ -197,7 +197,7 @@ export function PodcastBriefForm({ {brief.speakers.length > 1 ? ( From 66b6cab780bbce74db3cfa07381caba0c41434c2 Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Fri, 18 Sep 2026 04:30:59 +0530 Subject: [PATCH 6/8] feat(ui): extract podcast form's native select into a shared component, bump list header text size Select now lives in components/ui so other forms can reuse the OS-native picker instead of a Radix/Base UI popup; Sources and Artifacts headers move from text-xs to text-sm to match the rest of the panel. --- .../frontend/src/components/ui/select.tsx | 26 +++++++++++++++++++ .../src/features/sources/sources-panel.tsx | 2 +- .../src/features/studio/artifact-list.tsx | 2 +- .../features/studio/podcast-brief-form.tsx | 16 ++---------- 4 files changed, 30 insertions(+), 16 deletions(-) create mode 100644 surfsense_local/frontend/src/components/ui/select.tsx diff --git a/surfsense_local/frontend/src/components/ui/select.tsx b/surfsense_local/frontend/src/components/ui/select.tsx new file mode 100644 index 0000000000..adf3a48572 --- /dev/null +++ b/surfsense_local/frontend/src/components/ui/select.tsx @@ -0,0 +1,26 @@ +import type { ComponentProps } from "react" + +import { ChevronDownIcon } from "@/components/ui/icons" +import { cn } from "@/lib/utils" + +// A plain ` + {children} + + +

+ ) +} + +export { Select } diff --git a/surfsense_local/frontend/src/features/sources/sources-panel.tsx b/surfsense_local/frontend/src/features/sources/sources-panel.tsx index 5d7706bb54..e27ff4b212 100644 --- a/surfsense_local/frontend/src/features/sources/sources-panel.tsx +++ b/surfsense_local/frontend/src/features/sources/sources-panel.tsx @@ -342,7 +342,7 @@ export function SourcesPanel({

Sources

diff --git a/surfsense_local/frontend/src/features/studio/artifact-list.tsx b/surfsense_local/frontend/src/features/studio/artifact-list.tsx index adae8e5b76..d3366eb990 100644 --- a/surfsense_local/frontend/src/features/studio/artifact-list.tsx +++ b/surfsense_local/frontend/src/features/studio/artifact-list.tsx @@ -390,7 +390,7 @@ export function ArtifactList({

Artifacts

diff --git a/surfsense_local/frontend/src/features/studio/podcast-brief-form.tsx b/surfsense_local/frontend/src/features/studio/podcast-brief-form.tsx index c2f6b47011..127ffbfd2c 100644 --- a/surfsense_local/frontend/src/features/studio/podcast-brief-form.tsx +++ b/surfsense_local/frontend/src/features/studio/podcast-brief-form.tsx @@ -1,9 +1,9 @@ -import { useId, type ComponentProps } from "react" +import { useId } from "react" import { Button } from "@/components/ui/button" import { PlusIcon, Trash2Icon } from "@/components/ui/icons" import { Input } from "@/components/ui/input" -import { cn } from "@/lib/utils" +import { Select } from "@/components/ui/select" import { MAX_SPEAKERS, @@ -235,15 +235,3 @@ function Field({
) } - -function Select({ className, ...props }: ComponentProps<"select">) { - return ( -