From a9a47e5a28c527850e884a63dfc0f85d2e1fc89c Mon Sep 17 00:00:00 2001 From: Gagan Date: Tue, 7 Jul 2026 13:42:29 +0530 Subject: [PATCH 1/7] fix(apps): make agent bundling mandatory in the copilot apps skill The skill treated mirroring the bg-task into agents/.yaml as optional ('if the app should ship the agent'), so the copilot built agent-backed apps (e.g. pr-dashboard) with the refresher as a loose personal bg-task only. Publishing such an app ships a dead UI: data/ is user state and never packaged, and installers get no agent. Bundling is now a required step with the failure mode spelled out. --- .../src/application/assistant/skills/apps/skill.ts | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/apps/x/packages/core/src/application/assistant/skills/apps/skill.ts b/apps/x/packages/core/src/application/assistant/skills/apps/skill.ts index 593bf88f..11b75583 100644 --- a/apps/x/packages/core/src/application/assistant/skills/apps/skill.ts +++ b/apps/x/packages/core/src/application/assistant/skills/apps/skill.ts @@ -143,9 +143,16 @@ shell**; never generate a refresh script) and store it via the **\`app-set-data\`** builtin: \`{ appFolder, file: "data.json", data: }\` — pass the object directly, never \`JSON.stringify\` it. The write is atomic and contract-checked; on a failed fetch keep the last good data (never overwrite -good series with empties). If the app should ship the agent, mirror the -definition into \`agents/.yaml\` with ONLY \`name\`, \`instructions\`, -\`triggers\`, and list the filename in \`manifest.agents\`. +good series with empties). + +**ALWAYS bundle the agent into the app as well** (required, not optional): +mirror the definition into \`agents/.yaml\` with ONLY \`name\`, +\`instructions\`, \`triggers\` (no \`active\`, no \`model\` — the schema rejects +them) and list the filename in \`manifest.agents\`. The app package ships only +what's inside the app folder: without this mirror, a published copy of the app +is dead on arrival — installers get a UI whose data never refreshes. The +bundled copy materializes as a disabled bg-task for installers (they opt in); +the \`create-background-task\` task you made stays the author's live agent. ## 6. Prohibitions From 715d92e4ba5c17ebcdcbd6ba3bad4e4925660a39 Mon Sep 17 00:00:00 2001 From: Gagan Date: Tue, 7 Jul 2026 13:48:13 +0530 Subject: [PATCH 2/7] fix(apps): route 'Publish update' through apps:publishUpdate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The detail panel's Publish update button opened the same dialog as first publish, which hardcoded apps:publish — so updating an already published app always failed with name_taken from the registry check. The dialog now takes a published flag: it runs apps:publishUpdate with a version-bump picker (patch/minor/major), shows a simple progress state (the update path emits no step events), and links the new release on success. --- .../src/components/apps/app-detail.tsx | 1 + .../src/components/apps/publish-dialog.tsx | 59 ++++++++++++++++--- 2 files changed, 52 insertions(+), 8 deletions(-) diff --git a/apps/x/apps/renderer/src/components/apps/app-detail.tsx b/apps/x/apps/renderer/src/components/apps/app-detail.tsx index 39d294f0..6e8aa554 100644 --- a/apps/x/apps/renderer/src/components/apps/app-detail.tsx +++ b/apps/x/apps/renderer/src/components/apps/app-detail.tsx @@ -272,6 +272,7 @@ export function AppDetail({ folder, onClose }: { folder: string; onClose: () => setShowPublish(false)} onPublished={() => setReloadNonce((n) => n + 1)} /> diff --git a/apps/x/apps/renderer/src/components/apps/publish-dialog.tsx b/apps/x/apps/renderer/src/components/apps/publish-dialog.tsx index f14dd7e0..ed798a8e 100644 --- a/apps/x/apps/renderer/src/components/apps/publish-dialog.tsx +++ b/apps/x/apps/renderer/src/components/apps/publish-dialog.tsx @@ -3,13 +3,18 @@ import { CheckCircle2, Github, Loader2, XCircle } from 'lucide-react' // Publish dialog (spec §14): device-flow sign-in → confirmation → step // progress (§11.2 step names) → success links / typed error (name_taken gets -// an inline hint to rename and retry). +// an inline hint to rename and retry). With `published` the dialog runs the +// UPDATE path instead (§11.3: version bump + new release, no registry) — +// re-running first-publish on a published app always fails with name_taken. type Phase = 'auth' | 'device' | 'confirm' | 'publishing' | 'done' | 'error' +type Increment = 'patch' | 'minor' | 'major' -export function PublishDialog({ folder, appName, onClose, onPublished }: { +export function PublishDialog({ folder, appName, published, onClose, onPublished }: { folder: string appName: string + /** App already has a publish record — run publish-update instead. */ + published?: boolean onClose: () => void onPublished: () => void }) { @@ -18,7 +23,8 @@ export function PublishDialog({ folder, appName, onClose, onPublished }: { const [userCode, setUserCode] = useState('') const [steps, setSteps] = useState([]) const [error, setError] = useState(null) - const [result, setResult] = useState<{ repoUrl: string; releaseUrl: string; prUrl?: string; status: string } | null>(null) + const [increment, setIncrement] = useState('patch') + const [result, setResult] = useState<{ repoUrl?: string; releaseUrl: string; prUrl?: string; status: string; version?: string } | null>(null) const pollTimer = useRef(null) useEffect(() => { @@ -75,8 +81,13 @@ export function PublishDialog({ folder, appName, onClose, onPublished }: { setError(null) setSteps([]) try { - const r = await window.ipc.invoke('apps:publish', { folder }) - setResult(r) + if (published) { + const r = await window.ipc.invoke('apps:publishUpdate', { folder, increment }) + setResult({ releaseUrl: r.releaseUrl, status: 'published', version: r.version }) + } else { + const r = await window.ipc.invoke('apps:publish', { folder }) + setResult(r) + } setPhase('done') onPublished() } catch (e) { @@ -128,15 +139,47 @@ export function PublishDialog({ folder, appName, onClose, onPublished }: { {phase === 'confirm' && (
-

Signed in as @{login}. This will publish {appName} publicly.

+

+ Signed in as @{login}.{' '} + {published + ? <>This will publish a new version of {appName} (a new release on the existing repo). + : <>This will publish {appName} publicly.} +

+ {published && ( + + )}
)} - {(phase === 'publishing' || phase === 'done' || phase === 'error') && ( + {published && (phase === 'publishing' || phase === 'done' || phase === 'error') && ( +
+ {phase === 'publishing' && ( +

+ Publishing update… +

+ )} + {phase === 'done' && result && ( +
+

Published v{result.version}

+ +
+ )} +
+ )} + + {!published && (phase === 'publishing' || phase === 'done' || phase === 'error') && (
{Object.entries(STEP_LABELS).map(([key, label]) => { const doneStep = steps.includes(key) || phase === 'done' From 78defccb2908b03e968721a2942317c2f075232b Mon Sep 17 00:00:00 2001 From: Gagan Date: Tue, 7 Jul 2026 13:58:59 +0530 Subject: [PATCH 3/7] feat(apps): post-install prompt to enable bundled agents MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bundled agents install disabled (§8.3), but nothing told the installer they exist — the app opened empty with the refresher buried in bg-tasks. After an install that materialized agents, offer to turn them on: 'Turn on & run now' activates each task and fires a first run so the app opens with data; 'Not now' keeps them off. --- .../renderer/src/components/apps/catalog.tsx | 88 ++++++++++++++++++- 1 file changed, 86 insertions(+), 2 deletions(-) diff --git a/apps/x/apps/renderer/src/components/apps/catalog.tsx b/apps/x/apps/renderer/src/components/apps/catalog.tsx index 3820c6ab..899d5224 100644 --- a/apps/x/apps/renderer/src/components/apps/catalog.tsx +++ b/apps/x/apps/renderer/src/components/apps/catalog.tsx @@ -1,5 +1,5 @@ import { useEffect, useState } from 'react' -import { Download, Link2, RefreshCw, Search, ShieldAlert } from 'lucide-react' +import { Bot, Download, Link2, RefreshCw, Search, ShieldAlert } from 'lucide-react' import type { rowboatApp } from '@x/shared' // Catalog tab (spec §14): search the registry, install with the D18 capability @@ -74,6 +74,42 @@ function InstallConfirmDialog({ preview, busy, onConfirm, onCancel }: { ) } +/** Post-install opt-in (§8.3): bundled agents land disabled; without this + * prompt a fresh installer opens an empty app with no hint that the refresher + * exists, buried in bg-tasks. */ +function EnableAgentsDialog({ appName, names, busy, onEnable, onSkip }: { + appName: string + names: string[] + busy: boolean + onEnable: () => void + onSkip: () => void +}) { + return ( +
+
+
+ Turn on {names.length === 1 ? 'its background agent' : 'its background agents'}? +
+

+ {appName} ships {names.length === 1 ? 'an agent' : 'agents'} that keep{names.length === 1 ? 's' : ''} its + data fresh on a schedule, using your connected accounts and AI models. {names.length === 1 ? 'It is' : 'They are'} currently off. +

+
    + {names.map((n) =>
  • {n}
  • )} +
+
+ + +
+
+
+ ) +} + export function CatalogTab({ onInstalled }: { onInstalled: (folder: string) => void }) { const [records, setRecords] = useState([]) const [stale, setStale] = useState(false) @@ -83,6 +119,8 @@ export function CatalogTab({ onInstalled }: { onInstalled: (folder: string) => v const [busy, setBusy] = useState(false) const [urlDialog, setUrlDialog] = useState(false) const [url, setUrl] = useState('') + const [agentPrompt, setAgentPrompt] = useState<{ folder: string; appName: string; slugs: string[]; names: string[] } | null>(null) + const [enabling, setEnabling] = useState(false) const load = async (force = false) => { setError(null) @@ -127,6 +165,23 @@ export function CatalogTab({ onInstalled }: { onInstalled: (folder: string) => v } } + const enableAgents = async () => { + if (!agentPrompt) return + setEnabling(true) + try { + for (const slug of agentPrompt.slugs) { + await window.ipc.invoke('bg-task:patch', { slug, partial: { active: true } }) + // First run so the app opens with data; resolves when the run ends, so + // fire-and-forget. + void window.ipc.invoke('bg-task:run', { slug }).catch(() => { /* surfaced in bg-tasks */ }) + } + } catch { /* patch failures land the user in the same place as "Not now" */ } + const folder = agentPrompt.folder + setAgentPrompt(null) + setEnabling(false) + onInstalled(folder) + } + const confirmInstall = async () => { if (!preview) return setBusy(true) @@ -136,7 +191,22 @@ export function CatalogTab({ onInstalled }: { onInstalled: (folder: string) => v ? await window.ipc.invoke('apps:installFromUrl', { url: preview.url, confirmed: true }) : await window.ipc.invoke('apps:install', { name: preview.name ?? '', confirmed: true }) setPreview(null) - if (r.status === 'installed' && r.app) onInstalled(r.app.folder) + if (r.status === 'installed' && r.app) { + if (r.app.agentSlugs.length > 0) { + // Offer to switch the bundled agents on (they install disabled). + const names = await Promise.all(r.app.agentSlugs.map(async (slug) => { + try { + const g = await window.ipc.invoke('bg-task:get', { slug }) + return g.task?.name ?? slug + } catch { + return slug + } + })) + setAgentPrompt({ folder: r.app.folder, appName: r.app.manifest?.name ?? r.app.folder, slugs: r.app.agentSlugs, names }) + } else { + onInstalled(r.app.folder) + } + } } catch (e) { setError(e instanceof Error ? e.message : String(e)) } finally { @@ -226,6 +296,20 @@ export function CatalogTab({ onInstalled }: { onInstalled: (folder: string) => v onCancel={() => setPreview(null)} /> )} + + {agentPrompt && ( + void enableAgents()} + onSkip={() => { + const folder = agentPrompt.folder + setAgentPrompt(null) + onInstalled(folder) + }} + /> + )}
) } From 3fc4621dc149d2d6bba52e738c3178a8aab2b881 Mon Sep 17 00:00:00 2001 From: Gagan Date: Tue, 7 Jul 2026 14:14:05 +0530 Subject: [PATCH 4/7] fix(apps): pin the host default model on materialized bundled agents MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bundled agents can't ship a model override (the author's providers are not the installer's), so materialized tasks ran on the bg-task category default — gemini flash lite — which reliably mangles large app-set-data payloads (invalid JSON string → contract rejection → the installed app never gets data; observed with pr-dashboard's refresh agent). Materialization now resolves getDefaultModelAndProvider() on the host and pins that on the new task — the installer's machine picks the strong model, the package still pins nothing. Existing tasks keep their user-set model (update path unchanged). --- apps/x/packages/core/src/apps/agents.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/apps/x/packages/core/src/apps/agents.ts b/apps/x/packages/core/src/apps/agents.ts index de27918f..ff20b8ee 100644 --- a/apps/x/packages/core/src/apps/agents.ts +++ b/apps/x/packages/core/src/apps/agents.ts @@ -5,6 +5,7 @@ import { parse as parseYaml, stringify as stringifyYaml } from 'yaml'; import { TriggersSchema, type BackgroundTask } from '@x/shared/dist/background-task.js'; import type { AppSummary } from '@x/shared/dist/rowboat-app.js'; import { WorkDir } from '../config/config.js'; +import { getDefaultModelAndProvider } from '../models/defaults.js'; import { appDir, agentTaskSlug } from './indexer.js'; // Bundled background agents (spec §8). Definitions ship in the app package at @@ -94,6 +95,17 @@ async function materializeAgent(folder: string, agentFile: string): Promise undefined); From ec4ae4d5eb8d674cd10de345a1a85f307e1d1a32 Mon Sep 17 00:00:00 2001 From: Gagan Date: Tue, 7 Jul 2026 14:49:58 +0530 Subject: [PATCH 5/7] feat(apps): model picker in the post-install agent prompt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The enable dialog now lists the user's available models (models:list), preselecting the host-pinned default from materialization. Turning the agents on patches the chosen model/provider onto the task along with active — so installers can route a bundled agent to a specific model at the moment they opt in, without digging into bg-tasks. --- .../renderer/src/components/apps/catalog.tsx | 63 ++++++++++++++++--- 1 file changed, 54 insertions(+), 9 deletions(-) diff --git a/apps/x/apps/renderer/src/components/apps/catalog.tsx b/apps/x/apps/renderer/src/components/apps/catalog.tsx index 899d5224..a84169ff 100644 --- a/apps/x/apps/renderer/src/components/apps/catalog.tsx +++ b/apps/x/apps/renderer/src/components/apps/catalog.tsx @@ -74,16 +74,41 @@ function InstallConfirmDialog({ preview, busy, onConfirm, onCancel }: { ) } +type ModelChoice = { provider: string; model: string } + /** Post-install opt-in (§8.3): bundled agents land disabled; without this * prompt a fresh installer opens an empty app with no hint that the refresher - * exists, buried in bg-tasks. */ -function EnableAgentsDialog({ appName, names, busy, onEnable, onSkip }: { + * exists, buried in bg-tasks. The model picker defaults to the host-pinned + * model but lets the user override before the first run. */ +function EnableAgentsDialog({ appName, names, defaultModel, busy, onEnable, onSkip }: { appName: string names: string[] + defaultModel?: ModelChoice busy: boolean - onEnable: () => void + onEnable: (model: ModelChoice | null) => void onSkip: () => void }) { + const [options, setOptions] = useState>([]) + const [selected, setSelected] = useState(defaultModel ? `${defaultModel.provider}::${defaultModel.model}` : '') + + useEffect(() => { + void (async () => { + try { + const r = await window.ipc.invoke('models:list', null) + setOptions(r.providers.flatMap((p) => p.models.map((m) => ({ + provider: p.id, + model: m.id, + label: `${m.name ?? m.id} (${p.name})`, + })))) + } catch { /* no picker — enable keeps the pinned model */ } + })() + }, []) + + const choice = (): ModelChoice | null => { + const [provider, model] = selected.split('::') + return provider && model ? { provider, model } : null + } + return (
@@ -97,10 +122,24 @@ function EnableAgentsDialog({ appName, names, busy, onEnable, onSkip }: {
    {names.map((n) =>
  • {n}
  • )}
+ {options.length > 0 && ( + + )}
- @@ -119,7 +158,7 @@ export function CatalogTab({ onInstalled }: { onInstalled: (folder: string) => v const [busy, setBusy] = useState(false) const [urlDialog, setUrlDialog] = useState(false) const [url, setUrl] = useState('') - const [agentPrompt, setAgentPrompt] = useState<{ folder: string; appName: string; slugs: string[]; names: string[] } | null>(null) + const [agentPrompt, setAgentPrompt] = useState<{ folder: string; appName: string; slugs: string[]; names: string[]; defaultModel?: ModelChoice } | null>(null) const [enabling, setEnabling] = useState(false) const load = async (force = false) => { @@ -165,12 +204,15 @@ export function CatalogTab({ onInstalled }: { onInstalled: (folder: string) => v } } - const enableAgents = async () => { + const enableAgents = async (model: ModelChoice | null) => { if (!agentPrompt) return setEnabling(true) try { for (const slug of agentPrompt.slugs) { - await window.ipc.invoke('bg-task:patch', { slug, partial: { active: true } }) + await window.ipc.invoke('bg-task:patch', { + slug, + partial: { active: true, ...(model ? { model: model.model, provider: model.provider } : {}) }, + }) // First run so the app opens with data; resolves when the run ends, so // fire-and-forget. void window.ipc.invoke('bg-task:run', { slug }).catch(() => { /* surfaced in bg-tasks */ }) @@ -194,15 +236,17 @@ export function CatalogTab({ onInstalled }: { onInstalled: (folder: string) => v if (r.status === 'installed' && r.app) { if (r.app.agentSlugs.length > 0) { // Offer to switch the bundled agents on (they install disabled). + let defaultModel: ModelChoice | undefined const names = await Promise.all(r.app.agentSlugs.map(async (slug) => { try { const g = await window.ipc.invoke('bg-task:get', { slug }) + if (g.task?.model && g.task.provider) defaultModel = { model: g.task.model, provider: g.task.provider } return g.task?.name ?? slug } catch { return slug } })) - setAgentPrompt({ folder: r.app.folder, appName: r.app.manifest?.name ?? r.app.folder, slugs: r.app.agentSlugs, names }) + setAgentPrompt({ folder: r.app.folder, appName: r.app.manifest?.name ?? r.app.folder, slugs: r.app.agentSlugs, names, defaultModel }) } else { onInstalled(r.app.folder) } @@ -301,8 +345,9 @@ export function CatalogTab({ onInstalled }: { onInstalled: (folder: string) => v void enableAgents()} + onEnable={(model) => void enableAgents(model)} onSkip={() => { const folder = agentPrompt.folder setAgentPrompt(null) From 043cdc7cb24a62ac1da427b47520b453fff51116 Mon Sep 17 00:00:00 2001 From: Gagan Date: Tue, 7 Jul 2026 14:58:47 +0530 Subject: [PATCH 6/7] fix(apps): stop reinstall from yanking the user out of the agent dialog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two races around the post-install prompt: - AppsView kept a stale selectedFolder after the open app was uninstalled; on reinstall the 4s apps:list poll matched it again and instantly swapped in the app frame, unmounting the catalog and the enable-agents dialog mid-choice. Clear the selection when its app disappears from the list. - Bundled agents materialized on the NEXT apps:list poll, not at install — so the dialog's bg-task:get/patch hit a task that didn't exist yet if the user acted within ~4s. Install handlers now call syncAppAgents synchronously. --- apps/x/apps/main/src/ipc.ts | 4 ++++ apps/x/apps/renderer/src/components/apps/apps-view.tsx | 5 +++++ 2 files changed, 9 insertions(+) diff --git a/apps/x/apps/main/src/ipc.ts b/apps/x/apps/main/src/ipc.ts index 607dc213..6c60ea63 100644 --- a/apps/x/apps/main/src/ipc.ts +++ b/apps/x/apps/main/src/ipc.ts @@ -1646,6 +1646,9 @@ export function setupIpcHandlers() { const preview = appInstallPreviews.get(args.name) ?? await appsInstaller.previewInstall(record); const result = await appsInstaller.installFromRegistry(record, preview); appInstallPreviews.delete(args.name); + // Materialize bundled agents NOW, not on the next apps:list poll — the + // renderer's post-install enable dialog patches these tasks immediately. + if (result.app) await appsAgents.syncAppAgents(result.app); capture('app_installed', { name: args.name }); return result; }, @@ -1654,6 +1657,7 @@ export function setupIpcHandlers() { return appsInstaller.previewUrlInstall(args.url); } const result = await appsInstaller.confirmUrlInstall(args.url); + if (result.app) await appsAgents.syncAppAgents(result.app); capture('app_installed', { name: result.app.manifest?.name ?? result.app.folder }); return result; }, diff --git a/apps/x/apps/renderer/src/components/apps/apps-view.tsx b/apps/x/apps/renderer/src/components/apps/apps-view.tsx index 44db99fa..9fdb89de 100644 --- a/apps/x/apps/renderer/src/components/apps/apps-view.tsx +++ b/apps/x/apps/renderer/src/components/apps/apps-view.tsx @@ -186,6 +186,11 @@ export function AppsView({ initialAppFolder, initialVersion, onNewApp }: { const r = await window.ipc.invoke('apps:list', {}) if (cancelled) return setApps(r.apps) + // Drop a selection whose app no longer exists (uninstalled while + // open). Left stale, a later reinstall makes this view yank the user + // into the app frame mid-flow — e.g. while they're in the catalog's + // post-install agent dialog. + setSelectedFolder((cur) => (cur && !r.apps.some((a) => a.folder === cur) ? null : cur)) setServerError(r.serverRunning ? null : (r.serverError ?? 'Apps server is not running.')) } catch (e) { if (!cancelled) setServerError(e instanceof Error ? e.message : String(e)) From 3f7e915c4d0e5f8777e42c230264b2f62bedc72b Mon Sep 17 00:00:00 2001 From: Gagan Date: Tue, 7 Jul 2026 15:22:27 +0530 Subject: [PATCH 7/7] feat(apps): show installed state on catalog cards Cards for apps already installed from the catalog show a green Installed badge (matched via the install record's registry name) that opens the app, instead of a second Install button. --- .../renderer/src/components/apps/catalog.tsx | 31 ++++++++++++++++--- 1 file changed, 26 insertions(+), 5 deletions(-) diff --git a/apps/x/apps/renderer/src/components/apps/catalog.tsx b/apps/x/apps/renderer/src/components/apps/catalog.tsx index a84169ff..99bdda2c 100644 --- a/apps/x/apps/renderer/src/components/apps/catalog.tsx +++ b/apps/x/apps/renderer/src/components/apps/catalog.tsx @@ -1,5 +1,5 @@ import { useEffect, useState } from 'react' -import { Bot, Download, Link2, RefreshCw, Search, ShieldAlert } from 'lucide-react' +import { BadgeCheck, Bot, Download, Link2, RefreshCw, Search, ShieldAlert } from 'lucide-react' import type { rowboatApp } from '@x/shared' // Catalog tab (spec §14): search the registry, install with the D18 capability @@ -160,6 +160,18 @@ export function CatalogTab({ onInstalled }: { onInstalled: (folder: string) => v const [url, setUrl] = useState('') const [agentPrompt, setAgentPrompt] = useState<{ folder: string; appName: string; slugs: string[]; names: string[]; defaultModel?: ModelChoice } | null>(null) const [enabling, setEnabling] = useState(false) + // Registry name → local folder, for apps already installed from the catalog. + const [installedByName, setInstalledByName] = useState>(new Map()) + + const loadInstalled = async () => { + try { + const r = await window.ipc.invoke('apps:list', {}) + setInstalledByName(new Map( + r.apps.filter((a) => a.kind === 'installed' && a.install).map((a) => [a.install!.name, a.folder]), + )) + } catch { /* cards just show Install */ } + } + useEffect(() => { void loadInstalled() }, []) const load = async (force = false) => { setError(null) @@ -233,6 +245,7 @@ export function CatalogTab({ onInstalled }: { onInstalled: (folder: string) => v ? await window.ipc.invoke('apps:installFromUrl', { url: preview.url, confirmed: true }) : await window.ipc.invoke('apps:install', { name: preview.name ?? '', confirmed: true }) setPreview(null) + void loadInstalled() if (r.status === 'installed' && r.app) { if (r.app.agentSlugs.length > 0) { // Offer to switch the bundled agents on (they install disabled). @@ -300,10 +313,18 @@ export function CatalogTab({ onInstalled }: { onInstalled: (folder: string) => v

{r.description || 'No description.'}

- + {installedByName.has(r.name) ? ( + + ) : ( + + )}
))}