diff --git a/packages/app/e2e/browser/metadata-generation-settings.spec.ts b/packages/app/e2e/browser/metadata-generation-settings.spec.ts
new file mode 100644
index 000000000..5cdc507e8
--- /dev/null
+++ b/packages/app/e2e/browser/metadata-generation-settings.spec.ts
@@ -0,0 +1,110 @@
+import { expect, test, type Page } from "../support/fixtures";
+import { gotoAppShell, openSettings } from "../support/helpers/app";
+import { getServerId } from "../support/helpers/server-id";
+import { connectNewWorkspaceDaemonClient } from "../support/helpers/new-workspace";
+import {
+ expectSettingsHeader,
+ openHostSection,
+ openSettingsHost,
+} from "../support/helpers/settings";
+
+async function openMetadataGenerationSettings(page: Page) {
+ const serverId = getServerId();
+ await gotoAppShell(page);
+ await openSettings(page);
+ await openSettingsHost(page, serverId);
+ await openHostSection(page, serverId, "metadata");
+ await expectSettingsHeader(page, "Metadata");
+}
+
+async function openManualMetadataModelPicker(page: Page) {
+ await page.getByRole("button", { name: "Manual", exact: true }).click();
+ await page.getByRole("button", { name: /Select model/ }).click();
+}
+
+test("chooses a metadata model and can return to automatic selection", async ({
+ page,
+}, testInfo) => {
+ await openMetadataGenerationSettings(page);
+
+ await expect(page.getByText("Workspace titles", { exact: false })).toBeVisible();
+ await expect(page.getByRole("button", { name: "Automatic", exact: true })).toHaveAttribute(
+ "aria-selected",
+ "true",
+ );
+ await page.screenshot({
+ path: testInfo.outputPath("metadata-automatic.png"),
+ fullPage: true,
+ });
+
+ await openManualMetadataModelPicker(page);
+ await page.getByText("Mock Load Test", { exact: true }).click();
+ await expect(page.getByText("Ten second stream", { exact: true })).toBeVisible();
+ await page.screenshot({
+ path: testInfo.outputPath("metadata-model-picker.png"),
+ fullPage: true,
+ });
+ await page.getByText("Ten second stream", { exact: true }).click();
+
+ await expect(page.getByRole("button", { name: /Ten second stream/ })).toBeVisible();
+ await page.reload();
+ await expect(page.getByRole("button", { name: "Manual", exact: true })).toHaveAttribute(
+ "aria-selected",
+ "true",
+ );
+ await expect(page.getByRole("button", { name: /Ten second stream/ })).toBeVisible();
+ await page.screenshot({
+ path: testInfo.outputPath("metadata-manual-persisted.png"),
+ fullPage: true,
+ });
+
+ await page.getByRole("button", { name: "Automatic", exact: true }).click();
+ await page.reload();
+
+ await expect(page.getByRole("button", { name: "Automatic", exact: true })).toHaveAttribute(
+ "aria-selected",
+ "true",
+ );
+ await expect(page.getByRole("button", { name: /Ten second stream/ })).toHaveCount(0);
+});
+
+test("replaces only the first configured metadata model", async ({ page }) => {
+ const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
+ const previousConfig = await client.getDaemonConfig();
+
+ try {
+ await client.patchDaemonConfig({
+ metadataGeneration: {
+ providers: [
+ { provider: "mock", model: "five-minute-stream" },
+ { provider: "mock", model: "thirty-minute-stream" },
+ ],
+ },
+ });
+ await openMetadataGenerationSettings(page);
+
+ await expect(page.getByRole("button", { name: "Manual", exact: true })).toHaveAttribute(
+ "aria-selected",
+ "true",
+ );
+ await openManualMetadataModelPicker(page);
+ await page.getByText("Ten second stream", { exact: true }).click();
+
+ await expect
+ .poll(async () => (await client.getDaemonConfig()).config.metadataGeneration.providers)
+ .toEqual([
+ { provider: "mock", model: "ten-second-stream" },
+ { provider: "mock", model: "thirty-minute-stream" },
+ ]);
+ } finally {
+ try {
+ await client.patchDaemonConfig({
+ metadataGeneration: {
+ providers: previousConfig.config.metadataGeneration.providers,
+ },
+ });
+ } finally {
+ await client.close().catch(() => undefined);
+ }
+ }
+});
diff --git a/packages/app/e2e/support/helpers/settings.ts b/packages/app/e2e/support/helpers/settings.ts
index 9a3a12512..6f03dfbdc 100644
--- a/packages/app/e2e/support/helpers/settings.ts
+++ b/packages/app/e2e/support/helpers/settings.ts
@@ -36,6 +36,7 @@ type HostSection =
| "connections"
| "pair-device"
| "agents"
+ | "metadata"
| "workspaces"
| "providers"
| "usage"
diff --git a/packages/app/src/components/schedules/schedule-row.tsx b/packages/app/src/components/schedules/schedule-row.tsx
index faf491a35..8c2e50ba8 100644
--- a/packages/app/src/components/schedules/schedule-row.tsx
+++ b/packages/app/src/components/schedules/schedule-row.tsx
@@ -384,12 +384,12 @@ const styles = StyleSheet.create((theme) => ({
flex: 1,
minWidth: 0,
flexDirection: "row",
- alignItems: "center",
+ alignItems: "flex-start",
gap: theme.spacing[3],
},
leading: {
width: PROVIDER_ICON_SIZE,
- height: PROVIDER_ICON_SIZE,
+ height: 20,
alignItems: "center",
justifyContent: "center",
},
diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts
index 810c8d3a1..c5c5b0431 100644
--- a/packages/app/src/i18n/resources/ar.ts
+++ b/packages/app/src/i18n/resources/ar.ts
@@ -1762,12 +1762,27 @@ export const ar: TranslationResources = {
projects: "المشاريع",
connections: "اتصالات",
agents: "Agents",
+ metadata: "البيانات الوصفية",
workspaces: "Workspaces",
providers: "مقدمي الخدمات",
usage: "الاستخدام",
terminals: "Terminals",
host: "نظرة عامة",
},
+ metadataGeneration: {
+ title: "إنشاء البيانات الوصفية",
+ description:
+ "اختر النموذج الذي يستخدمه Paseo لعناوين مساحات العمل وأسماء الفروع ورسائل الالتزام ومسودات طلبات السحب",
+ selection: "اختيار النموذج",
+ automatic: "تلقائي",
+ preferred: "يدوي",
+ automaticHint: "يختار Paseo نموذجًا سريعًا متاحًا",
+ preferredHint: "اختر النموذج الذي يستخدمه Paseo",
+ model: "النموذج",
+ fallbackHint: "إذا لم يكن متاحًا، يستخدم Paseo نموذجًا آخر متاحًا",
+ docs: "الوثائق",
+ saveError: "تعذر تحديث إنشاء البيانات الوصفية",
+ },
general: {
title: "عام",
browserData: {
diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts
index 9caf7292b..dde57b1ce 100644
--- a/packages/app/src/i18n/resources/en.ts
+++ b/packages/app/src/i18n/resources/en.ts
@@ -1773,12 +1773,27 @@ export const en = {
projects: "Projects",
connections: "Connections",
agents: "Agents",
+ metadata: "Metadata",
workspaces: "Workspaces",
providers: "Providers",
usage: "Usage",
terminals: "Terminals",
host: "Overview",
},
+ metadataGeneration: {
+ title: "Metadata generation",
+ description:
+ "Choose the model Paseo uses for workspace titles, branch names, commit messages, and pull request drafts",
+ selection: "Model selection",
+ automatic: "Automatic",
+ preferred: "Manual",
+ automaticHint: "Paseo picks a fast available model",
+ preferredHint: "Choose the model Paseo uses",
+ model: "Model",
+ fallbackHint: "If it is unavailable, Paseo falls back to another available model",
+ docs: "Docs",
+ saveError: "Unable to update metadata generation",
+ },
general: {
title: "General",
browserData: {
diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts
index c5d4a2d27..b286bf630 100644
--- a/packages/app/src/i18n/resources/es.ts
+++ b/packages/app/src/i18n/resources/es.ts
@@ -1805,12 +1805,27 @@ export const es: TranslationResources = {
projects: "Proyectos",
connections: "Conexiones",
agents: "Agents",
+ metadata: "Metadatos",
workspaces: "Workspaces",
providers: "Proveedores",
usage: "Uso",
terminals: "Terminals",
host: "Resumen",
},
+ metadataGeneration: {
+ title: "Generación de metadatos",
+ description:
+ "Elige el modelo que Paseo usa para títulos de espacios de trabajo, nombres de ramas, mensajes de commit y borradores de pull requests",
+ selection: "Selección de modelo",
+ automatic: "Automática",
+ preferred: "Manual",
+ automaticHint: "Paseo elige un modelo rápido disponible",
+ preferredHint: "Elige el modelo que usa Paseo",
+ model: "Modelo",
+ fallbackHint: "Si no está disponible, Paseo usa otro modelo disponible",
+ docs: "Documentación",
+ saveError: "No se pudo actualizar la generación de metadatos",
+ },
general: {
title: "General",
browserData: {
diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts
index bc22d3afe..d7e05cd9c 100644
--- a/packages/app/src/i18n/resources/fr.ts
+++ b/packages/app/src/i18n/resources/fr.ts
@@ -1809,12 +1809,27 @@ export const fr: TranslationResources = {
projects: "Projets",
connections: "Relations",
agents: "Agents",
+ metadata: "Métadonnées",
workspaces: "Workspaces",
providers: "Fournisseurs",
usage: "Utilisation",
terminals: "Terminals",
host: "Aperçu",
},
+ metadataGeneration: {
+ title: "Génération de métadonnées",
+ description:
+ "Choisissez le modèle utilisé par Paseo pour les titres d’espaces de travail, les noms de branches, les messages de commit et les brouillons de pull request",
+ selection: "Sélection du modèle",
+ automatic: "Automatique",
+ preferred: "Manuel",
+ automaticHint: "Paseo choisit un modèle rapide disponible",
+ preferredHint: "Choisissez le modèle utilisé par Paseo",
+ model: "Modèle",
+ fallbackHint: "S’il est indisponible, Paseo utilise un autre modèle disponible",
+ docs: "Documentation",
+ saveError: "Impossible de mettre à jour la génération de métadonnées",
+ },
general: {
title: "Général",
browserData: {
diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts
index 07308bb8f..529d6e157 100644
--- a/packages/app/src/i18n/resources/ja.ts
+++ b/packages/app/src/i18n/resources/ja.ts
@@ -1778,12 +1778,27 @@ export const ja: TranslationResources = {
projects: "プロジェクト",
connections: "接続",
agents: "エージェント",
+ metadata: "メタデータ",
workspaces: "ワークスペース",
providers: "プロバイダー",
usage: "使用状況",
terminals: "ターミナル",
host: "概要",
},
+ metadataGeneration: {
+ title: "メタデータ生成",
+ description:
+ "ワークスペースのタイトル、ブランチ名、コミットメッセージ、プルリクエストの下書きに使用するモデルを選択します",
+ selection: "モデル選択",
+ automatic: "自動",
+ preferred: "手動",
+ automaticHint: "Paseo が利用可能な高速モデルを選択します",
+ preferredHint: "Paseo が使用するモデルを選択します",
+ model: "モデル",
+ fallbackHint: "利用できない場合、Paseo は別の利用可能なモデルを使用します",
+ docs: "ドキュメント",
+ saveError: "メタデータ生成を更新できません",
+ },
general: {
title: "一般",
browserData: {
diff --git a/packages/app/src/i18n/resources/ko.ts b/packages/app/src/i18n/resources/ko.ts
index 2762f0174..f242d311a 100644
--- a/packages/app/src/i18n/resources/ko.ts
+++ b/packages/app/src/i18n/resources/ko.ts
@@ -1772,12 +1772,27 @@ export const ko: TranslationResources = {
projects: "프로젝트",
connections: "연결",
agents: "에이전트",
+ metadata: "메타데이터",
workspaces: "워크스페이스",
providers: "프로바이더",
usage: "사용량",
terminals: "터미널",
host: "개요",
},
+ metadataGeneration: {
+ title: "메타데이터 생성",
+ description:
+ "워크스페이스 제목, 브랜치 이름, 커밋 메시지 및 풀 리퀘스트 초안에 사용할 모델을 선택하세요",
+ selection: "모델 선택",
+ automatic: "자동",
+ preferred: "수동",
+ automaticHint: "Paseo가 사용 가능한 빠른 모델을 선택합니다",
+ preferredHint: "Paseo에서 사용할 모델을 선택하세요",
+ model: "모델",
+ fallbackHint: "사용할 수 없으면 Paseo가 다른 사용 가능한 모델을 사용합니다",
+ docs: "문서",
+ saveError: "메타데이터 생성을 업데이트할 수 없습니다",
+ },
general: {
title: "일반",
browserData: {
diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts
index 4aeba0209..f307377b7 100644
--- a/packages/app/src/i18n/resources/pt-BR.ts
+++ b/packages/app/src/i18n/resources/pt-BR.ts
@@ -1791,12 +1791,27 @@ export const ptBR: TranslationResources = {
projects: "Projetos",
connections: "Conexões",
agents: "Agentes",
+ metadata: "Metadados",
workspaces: "Workspaces",
providers: "Provedores",
usage: "Uso",
terminals: "Terminais",
host: "Visão geral",
},
+ metadataGeneration: {
+ title: "Geração de metadados",
+ description:
+ "Escolha o modelo usado pelo Paseo para títulos de espaços de trabalho, nomes de branches, mensagens de commit e rascunhos de pull request",
+ selection: "Seleção de modelo",
+ automatic: "Automática",
+ preferred: "Manual",
+ automaticHint: "O Paseo escolhe um modelo rápido disponível",
+ preferredHint: "Escolha o modelo que o Paseo usa",
+ model: "Modelo",
+ fallbackHint: "Se ele não estiver disponível, o Paseo usa outro modelo disponível",
+ docs: "Documentação",
+ saveError: "Não foi possível atualizar a geração de metadados",
+ },
general: {
title: "Geral",
browserData: {
diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts
index 37ab6b1cf..1aacec974 100644
--- a/packages/app/src/i18n/resources/ru.ts
+++ b/packages/app/src/i18n/resources/ru.ts
@@ -1796,12 +1796,27 @@ export const ru: TranslationResources = {
projects: "Проекты",
connections: "Соединения",
agents: "Agents",
+ metadata: "Метаданные",
workspaces: "Workspaces",
providers: "Провайдеры",
usage: "Использование",
terminals: "Terminals",
host: "Обзор",
},
+ metadataGeneration: {
+ title: "Создание метаданных",
+ description:
+ "Выберите модель, которую Paseo использует для названий рабочих пространств, веток, сообщений коммитов и черновиков pull request",
+ selection: "Выбор модели",
+ automatic: "Автоматически",
+ preferred: "Вручную",
+ automaticHint: "Paseo выбирает быструю доступную модель",
+ preferredHint: "Выберите модель, которую использует Paseo",
+ model: "Модель",
+ fallbackHint: "Если она недоступна, Paseo использует другую доступную модель",
+ docs: "Документация",
+ saveError: "Не удалось обновить создание метаданных",
+ },
general: {
title: "Общий",
browserData: {
diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts
index 1d2bed925..1d53b78de 100644
--- a/packages/app/src/i18n/resources/zh-CN.ts
+++ b/packages/app/src/i18n/resources/zh-CN.ts
@@ -1742,12 +1742,26 @@ export const zhCN: TranslationResources = {
projects: "项目",
connections: "连接",
agents: "Agents",
+ metadata: "元数据",
workspaces: "Workspaces",
providers: "Providers",
usage: "使用情况",
terminals: "Terminals",
host: "概览",
},
+ metadataGeneration: {
+ title: "元数据生成",
+ description: "选择 Paseo 用于工作区标题、分支名称、提交消息和拉取请求草稿的模型",
+ selection: "模型选择",
+ automatic: "自动",
+ preferred: "手动",
+ automaticHint: "Paseo 会选择一个可用的快速模型",
+ preferredHint: "选择 Paseo 使用的模型",
+ model: "模型",
+ fallbackHint: "如果不可用,Paseo 会改用其他可用模型",
+ docs: "文档",
+ saveError: "无法更新元数据生成设置",
+ },
general: {
title: "通用",
browserData: {
diff --git a/packages/app/src/screens/settings-screen.tsx b/packages/app/src/screens/settings-screen.tsx
index af52bb240..c7e6df8e5 100644
--- a/packages/app/src/screens/settings-screen.tsx
+++ b/packages/app/src/screens/settings-screen.tsx
@@ -36,6 +36,7 @@ import {
SquareTerminal,
Code2,
Smartphone,
+ Sparkles,
} from "lucide-react-native";
import { DropdownTrigger } from "@/components/ui/dropdown-trigger";
import { ComboboxTrigger } from "@/components/ui/combobox-trigger";
@@ -107,6 +108,7 @@ import {
HostWorkspacesPage,
HostTerminalsPage,
} from "@/screens/settings/host-page";
+import { MetadataGenerationPage } from "@/screens/settings/metadata-generation-page";
import ProjectsScreen from "@/screens/projects-screen";
import ProjectSettingsScreen from "@/screens/project-settings-screen";
import { SETTINGS_DESKTOP_SIDEBAR_WIDTH, useIsCompactFormFactor } from "@/constants/layout";
@@ -184,6 +186,7 @@ const HOST_SECTION_ITEMS: HostSectionItem[] = [
{ id: "connections", labelKey: "settings.hostSections.connections", icon: Network },
{ id: "pair-device", labelKey: "openProject.tiles.pairDevice.title", icon: Smartphone },
{ id: "agents", labelKey: "settings.hostSections.agents", icon: Bot },
+ { id: "metadata", labelKey: "settings.hostSections.metadata", icon: Sparkles },
{ id: "workspaces", labelKey: "settings.hostSections.workspaces", icon: FolderGit2 },
{ id: "providers", labelKey: "settings.hostSections.providers", icon: Boxes },
{ id: "usage", labelKey: "settings.hostSections.usage", icon: Gauge },
@@ -203,6 +206,8 @@ function renderHostSettingsContent(
return ;
case "agents":
return ;
+ case "metadata":
+ return ;
case "workspaces":
return ;
case "providers":
diff --git a/packages/app/src/screens/settings/metadata-generation-page.tsx b/packages/app/src/screens/settings/metadata-generation-page.tsx
new file mode 100644
index 000000000..3576c2383
--- /dev/null
+++ b/packages/app/src/screens/settings/metadata-generation-page.tsx
@@ -0,0 +1,181 @@
+import { useCallback, useEffect, useMemo, useState } from "react";
+import { Alert, Text, View } from "react-native";
+import { useTranslation } from "react-i18next";
+import { StyleSheet } from "react-native-unistyles";
+import type { AgentProvider } from "@getpaseo/protocol/agent-types";
+import { CombinedModelSelector } from "@/components/combined-model-selector";
+import { ExternalLink } from "@/components/ui/external-link";
+import { LoadingSpinner } from "@/components/ui/loading-spinner";
+import { SegmentedControl } from "@/components/ui/segmented-control";
+import { useDaemonConfig } from "@/hooks/use-daemon-config";
+import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
+import { buildSelectableProviderSelectorProviders } from "@/provider-selection/provider-selection";
+import { SettingsSection } from "@/screens/settings/settings-section";
+import { settingsStyles } from "@/styles/settings";
+
+const METADATA_GENERATION_DOCS_URL = "https://paseo.sh/docs/metadata-generation";
+type SelectionMode = "automatic" | "preferred";
+
+export function MetadataGenerationPage({ serverId }: { serverId: string }) {
+ const { t } = useTranslation();
+ const { config, isLoading: isConfigLoading, patchConfig } = useDaemonConfig(serverId);
+ const snapshot = useProvidersSnapshot(serverId);
+ const providers = useMemo(
+ () => buildSelectableProviderSelectorProviders(snapshot.entries),
+ [snapshot.entries],
+ );
+ const configuredProviders = config?.metadataGeneration.providers;
+ const configuredProvider = configuredProviders?.[0] ?? null;
+ const savedMode: SelectionMode = configuredProvider ? "preferred" : "automatic";
+ const [draftMode, setDraftMode] = useState(null);
+ const [isSaving, setIsSaving] = useState(false);
+ const mode = draftMode ?? savedMode;
+
+ useEffect(() => {
+ setDraftMode(null);
+ }, [configuredProvider?.model, configuredProvider?.provider]);
+
+ const modeOptions = useMemo(
+ () => [
+ { value: "automatic" as const, label: t("settings.metadataGeneration.automatic") },
+ { value: "preferred" as const, label: t("settings.metadataGeneration.preferred") },
+ ],
+ [t],
+ );
+
+ const saveProviders = useCallback(
+ async (providersPatch: { provider: string; model?: string }[]) => {
+ setIsSaving(true);
+ try {
+ await patchConfig({ metadataGeneration: { providers: providersPatch } });
+ } catch (error) {
+ setDraftMode(null);
+ Alert.alert(
+ t("settings.metadataGeneration.saveError"),
+ error instanceof Error ? error.message : String(error),
+ );
+ } finally {
+ setIsSaving(false);
+ }
+ },
+ [patchConfig, t],
+ );
+
+ const handleModeChange = useCallback(
+ (next: SelectionMode) => {
+ setDraftMode(next);
+ if (next === "automatic") {
+ void saveProviders([]);
+ }
+ },
+ [saveProviders],
+ );
+
+ const handleModelSelect = useCallback(
+ (provider: AgentProvider, model: string) => {
+ setDraftMode("preferred");
+ void saveProviders([
+ { provider, ...(model ? { model } : {}) },
+ ...(configuredProviders?.slice(1) ?? []),
+ ]);
+ },
+ [configuredProviders, saveProviders],
+ );
+
+ const handleSelectorOpen = useCallback(() => {
+ snapshot.refetchIfStale(configuredProvider?.provider);
+ }, [configuredProvider?.provider, snapshot]);
+ const handleRetryProvider = useCallback(
+ (provider: AgentProvider) => snapshot.refresh([provider]),
+ [snapshot],
+ );
+ const docsLink = useMemo(
+ () => (
+
+ ),
+ [t],
+ );
+
+ if (isConfigLoading || !config) {
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+ {t("settings.metadataGeneration.description")}
+
+
+
+
+ {t("settings.metadataGeneration.selection")}
+
+
+ {mode === "automatic"
+ ? t("settings.metadataGeneration.automaticHint")
+ : t("settings.metadataGeneration.preferredHint")}
+
+
+
+
+ {mode === "preferred" ? (
+
+
+ {t("settings.metadataGeneration.model")}
+
+ {t("settings.metadataGeneration.fallbackHint")}
+
+
+
+
+ ) : null}
+
+
+ );
+}
+
+const styles = StyleSheet.create((theme) => ({
+ description: {
+ color: theme.colors.foregroundMuted,
+ fontSize: theme.fontSize.xs,
+ lineHeight: theme.fontSize.xs * 1.45,
+ marginHorizontal: theme.spacing[1],
+ },
+ loading: {
+ alignItems: "center",
+ justifyContent: "center",
+ minHeight: 180,
+ },
+ spinnerColor: {
+ color: theme.colors.foregroundMuted,
+ },
+}));
diff --git a/packages/app/src/utils/host-routes.test.ts b/packages/app/src/utils/host-routes.test.ts
index 29ac067c9..644139e92 100644
--- a/packages/app/src/utils/host-routes.test.ts
+++ b/packages/app/src/utils/host-routes.test.ts
@@ -248,6 +248,7 @@ describe("host settings section slugs", () => {
expect(normalizeHostSectionSlug("connections")).toBe("connections");
expect(normalizeHostSectionSlug("pair-device")).toBe("pair-device");
expect(normalizeHostSectionSlug("agents")).toBe("agents");
+ expect(normalizeHostSectionSlug("metadata")).toBe("metadata");
expect(normalizeHostSectionSlug("workspaces")).toBe("workspaces");
expect(normalizeHostSectionSlug("projects")).toBe("projects");
expect(normalizeHostSectionSlug("providers")).toBe("providers");
diff --git a/packages/app/src/utils/host-routes.ts b/packages/app/src/utils/host-routes.ts
index 8c53ef0b4..87e151788 100644
--- a/packages/app/src/utils/host-routes.ts
+++ b/packages/app/src/utils/host-routes.ts
@@ -512,6 +512,7 @@ export const HOST_SECTION_SLUGS = [
"connections",
"pair-device",
"agents",
+ "metadata",
"workspaces",
"providers",
"usage",
diff --git a/packages/server/src/server/agent/structured-generation-providers.test.ts b/packages/server/src/server/agent/structured-generation-providers.test.ts
index 06c8bed6b..d34aff964 100644
--- a/packages/server/src/server/agent/structured-generation-providers.test.ts
+++ b/packages/server/src/server/agent/structured-generation-providers.test.ts
@@ -18,8 +18,17 @@ class ProviderSnapshots {
}
describe("resolveStructuredGenerationProviders", () => {
- test("uses explicit configured provider models without refreshing provider snapshots", async () => {
- const snapshots = new ProviderSnapshots([]);
+ test("tries the configured model before dynamically discovered fallbacks", async () => {
+ const snapshots = new ProviderSnapshots([
+ {
+ provider: "work-claude",
+ status: READY,
+ enabled: true,
+ models: [
+ { provider: "work-claude", id: "claude-haiku-2026", label: "Haiku", isDefault: true },
+ ],
+ },
+ ]);
const providers = await resolveStructuredGenerationProviders({
cwd: "/tmp/repo",
@@ -31,8 +40,11 @@ describe("resolveStructuredGenerationProviders", () => {
},
});
- expect(providers).toEqual([{ provider: "mock", model: "ten-second-stream" }]);
- expect(snapshots.calls).toEqual([]);
+ expect(providers).toEqual([
+ { provider: "mock", model: "ten-second-stream" },
+ { provider: "work-claude", model: "claude-haiku-2026" },
+ ]);
+ expect(snapshots.calls).toEqual([{ cwd: "/tmp/repo", wait: true }]);
});
test("falls back to dynamic defaults and current selection when no provider is configured", async () => {
@@ -155,7 +167,7 @@ describe("resolveStructuredGenerationProviders", () => {
]);
});
- test("uses explicit configured provider models as-is instead of waiting to normalize aliases", async () => {
+ test("normalizes configured nested-provider aliases from the provider snapshot", async () => {
const snapshots = new ProviderSnapshots([
{
provider: "opencode",
@@ -186,8 +198,8 @@ describe("resolveStructuredGenerationProviders", () => {
},
});
- expect(providers).toEqual([{ provider: "plexus", model: "small-fast" }]);
- expect(snapshots.calls).toEqual([]);
+ expect(providers).toEqual([{ provider: "opencode", model: "plexus/small-fast" }]);
+ expect(snapshots.calls).toEqual([{ cwd: "/tmp/repo", wait: true }]);
});
test("keeps explicit candidates when provider snapshots are in error state", async () => {
@@ -215,7 +227,10 @@ describe("resolveStructuredGenerationProviders", () => {
},
});
- expect(providers).toEqual([{ provider: "current-provider", model: "configured-model" }]);
- expect(snapshots.calls).toEqual([]);
+ expect(providers).toEqual([
+ { provider: "current-provider", model: "configured-model" },
+ { provider: "current-provider", model: "selected-model", thinkingOptionId: "medium" },
+ ]);
+ expect(snapshots.calls).toEqual([{ cwd: "/tmp/repo", wait: true }]);
});
});
diff --git a/packages/server/src/server/agent/structured-generation-providers.ts b/packages/server/src/server/agent/structured-generation-providers.ts
index 89a331b66..2a09ee184 100644
--- a/packages/server/src/server/agent/structured-generation-providers.ts
+++ b/packages/server/src/server/agent/structured-generation-providers.ts
@@ -44,22 +44,6 @@ export async function resolveStructuredGenerationProviders(
options: ResolveStructuredGenerationProvidersOptions,
): Promise {
const configuredProviders = readConfiguredProviders(options.daemonConfig);
- if (configuredProviders.length > 0) {
- const explicitProviders = resolveExplicitConfiguredProviders(configuredProviders);
- if (explicitProviders.length === configuredProviders.length) {
- return dedupeProviders(explicitProviders);
- }
-
- const providerEntries = await options.providerSnapshotManager.listProviders({
- cwd: options.cwd,
- wait: false,
- });
- const providers = resolveConfiguredProviders(configuredProviders, providerEntries);
- if (providers.length > 0) {
- return dedupeProviders(providers);
- }
- }
-
const providerEntries = await options.providerSnapshotManager.listProviders({
cwd: options.cwd,
wait: true,
@@ -100,42 +84,6 @@ export async function resolveStructuredGenerationProviders(
return dedupeProviders(providers);
}
-function resolveConfiguredProviders(
- configuredProviders: readonly { provider: string; model?: string; thinkingOptionId?: string }[],
- providerEntries: readonly ProviderSnapshotEntry[],
-): StructuredGenerationProvider[] {
- const enabledEntries = providerEntries.filter((entry) => entry.enabled);
- const modelEntries = enabledEntries.filter((entry) => (entry.models?.length ?? 0) > 0);
- const entriesByProvider = new Map(enabledEntries.map((entry) => [entry.provider, entry]));
- const providers: StructuredGenerationProvider[] = [];
- for (const configured of configuredProviders) {
- const resolved = resolveConfiguredCandidate(configured, modelEntries, entriesByProvider);
- if (resolved) {
- providers.push(resolved);
- }
- }
- return providers;
-}
-
-function resolveExplicitConfiguredProviders(
- configuredProviders: readonly { provider: string; model?: string; thinkingOptionId?: string }[],
-): StructuredGenerationProvider[] {
- const providers: StructuredGenerationProvider[] = [];
- for (const configured of configuredProviders) {
- const provider = configured.provider.trim();
- const model = configured.model?.trim();
- if (!provider || !model) {
- continue;
- }
- providers.push({
- provider,
- model,
- ...(configured.thinkingOptionId ? { thinkingOptionId: configured.thinkingOptionId } : {}),
- });
- }
- return providers;
-}
-
function resolveCurrentSelection(
selection: ResolveStructuredGenerationProvidersOptions["currentSelection"],
readyEntries: readonly ProviderSnapshotEntry[],
diff --git a/public-docs/metadata-generation.md b/public-docs/metadata-generation.md
index f9e7a7c21..1d23ec661 100644
--- a/public-docs/metadata-generation.md
+++ b/public-docs/metadata-generation.md
@@ -37,9 +37,13 @@ Each default is a match on the model id or name, so the first enabled provider t
The intent of the default order is to prefer small, fast, cheap models for these short tasks before falling back to whatever you have selected.
-## Configuring the providers
+## Choose a model
-To control which models Paseo uses — for example to keep all metadata generation on one provider, or to prefer a local model — set `agents.metadataGeneration.providers` in `~/.paseo/config.json`. Your entries are tried before the built-in defaults.
+Open **Settings → Host → Metadata** and select **Automatic** or **Manual**. In Manual mode, Paseo tries the model you choose first, then falls back to the remaining configured models and built-in candidates. Choosing a model replaces the first entry in `agents.metadataGeneration.providers` and preserves the rest of the list.
+
+## Configure a custom fallback order
+
+To configure more than one preferred model or control the exact order, set `agents.metadataGeneration.providers` in `~/.paseo/config.json`. Your entries are tried before the built-in defaults.
```json
{
@@ -60,7 +64,7 @@ Each entry accepts:
- `model` (optional) — a specific model id. Omit it to use that provider's default model.
- `thinkingOptionId` (optional) — a reasoning/thinking level for models that support one. Falls back to the model's default if the value isn't valid for that model.
-Restart the daemon after editing the file.
+The Settings screen replaces only the first entry and preserves the rest of a custom list. Restart the daemon after editing the file directly.
## Per-project instructions