feat(apps): M3 UI — catalog, D18 install dialog, publish dialog, detail actions

- Catalog tab: registry search/list, stale-cache refresh, install flow with
  the D18 capability-disclosure dialog (plain-language capability lines +
  bundled agents, explicit confirm), install-from-URL with preview and
  'updates unavailable' notice for non-GitHub sources
- Publish dialog: GitHub device-flow sign-in (user code + polling), §11.2
  step progress via apps:progress pushes, success links, name_taken
  rename-and-retry hint
- App detail actions: check-for-update / update (new_capabilities and
  modified_files confirmation flows), rollback, uninstall (names data/ and
  bundled agents), publish / publish update
- docs/publishing-apps.md (§11.5): bundle format, two-asset requirement, tag
  convention, registry record, monorepo latest-release constraint
This commit is contained in:
Gagan 2026-07-06 15:34:54 +05:30
parent 35c781b8e4
commit 815eac43db
5 changed files with 575 additions and 7 deletions

View file

@ -1,6 +1,7 @@
import { useEffect, useState } from 'react'
import { X, RotateCcw, Play } from 'lucide-react'
import { X, RotateCcw, Play, UploadCloud, ArrowUpCircle, Trash2 } from 'lucide-react'
import type { rowboatApp } from '@x/shared'
import { PublishDialog } from '@/components/apps/publish-dialog'
// App detail panel (spec §14): manifest info, provenance/publish state,
// bundled agents with enable toggles, rollback when available. Update/publish
@ -21,6 +22,64 @@ export function AppDetail({ folder, onClose }: { folder: string; onClose: () =>
const [agents, setAgents] = useState<AgentRow[]>([])
const [error, setError] = useState<string | null>(null)
const [reloadNonce, setReloadNonce] = useState(0)
const [notice, setNotice] = useState<string | null>(null)
const [updateInfo, setUpdateInfo] = useState<{ current: string; latest: string; updateAvailable: boolean } | null>(null)
const [showPublish, setShowPublish] = useState(false)
const [busyAction, setBusyAction] = useState<string | null>(null)
const runAction = async (name: string, fn: () => Promise<string | void>) => {
setBusyAction(name)
setError(null)
setNotice(null)
try {
const msg = await fn()
if (msg) setNotice(msg)
setReloadNonce((n) => n + 1)
} catch (e) {
setError(e instanceof Error ? e.message : String(e))
} finally {
setBusyAction(null)
}
}
const checkForUpdate = () => runAction('check', async () => {
const r = await window.ipc.invoke('apps:checkUpdate', { folder })
setUpdateInfo(r)
return r.updateAvailable ? `v${r.latest} is available (you have v${r.current}).` : `Up to date (v${r.current}).`
})
const doUpdate = () => runAction('update', async () => {
try {
await window.ipc.invoke('apps:update', { folder })
return 'Updated.'
} catch (e) {
const msg = e instanceof Error ? e.message : String(e)
// D18 / modified-files confirmation flows (§12.3)
if (msg.includes('new_capabilities')) {
if (!window.confirm(`This update widens the app's access:\n${msg}\n\nProceed?`)) return
await window.ipc.invoke('apps:update', { folder, confirmNewCapabilities: true, confirmOverwriteModified: true })
return 'Updated (new capabilities confirmed).'
}
if (msg.includes('modified_files')) {
if (!window.confirm(`You modified files this update will overwrite:\n${msg}\n\nProceed?`)) return
await window.ipc.invoke('apps:update', { folder, confirmOverwriteModified: true })
return 'Updated (local modifications overwritten).'
}
throw e
}
})
const doRollback = () => runAction('rollback', async () => {
await window.ipc.invoke('apps:rollback', { folder })
return 'Rolled back to the previous version.'
})
const doUninstall = () => runAction('uninstall', async () => {
const agentNote = agents.length ? `\n\nThis also deletes its background agents: ${agents.map((a) => a.name).join(', ')}.` : ''
if (!window.confirm(`Uninstall this app? Its data/ folder will be deleted.${agentNote}`)) return
await window.ipc.invoke('apps:uninstall', { folder })
onClose()
})
useEffect(() => {
let cancelled = false
@ -82,6 +141,7 @@ export function AppDetail({ folder, onClose }: { folder: string; onClose: () =>
<div className="min-h-0 flex-1 overflow-y-auto px-4 py-4 text-sm">
{error && <div className="mb-3 rounded-md border border-destructive/40 bg-destructive/10 px-3 py-2 text-destructive">{error}</div>}
{notice && <div className="mb-3 rounded-md border border-border bg-muted/40 px-3 py-2 text-muted-foreground">{notice}</div>}
{!app ? (
<div className="text-muted-foreground">Loading</div>
) : (
@ -123,11 +183,35 @@ export function AppDetail({ folder, onClose }: { folder: string; onClose: () =>
) : (
<p className="text-muted-foreground">Local app created on this machine{app.publish ? `, published as ${app.publish.repo}` : ''}.</p>
)}
{rollback && (
<button type="button" className="mt-1 flex items-center gap-1.5 rounded-md border border-border px-2.5 py-1 text-xs font-medium hover:bg-accent">
<RotateCcw className="size-3.5" /> Roll back to previous version
</button>
)}
<div className="flex flex-wrap gap-2 pt-1">
{app.kind === 'installed' && app.install?.repo && (
<button type="button" disabled={busyAction !== null}
onClick={() => void (updateInfo?.updateAvailable ? doUpdate() : checkForUpdate())}
className="flex items-center gap-1.5 rounded-md border border-border px-2.5 py-1 text-xs font-medium hover:bg-accent disabled:opacity-50">
<ArrowUpCircle className="size-3.5" />
{busyAction === 'check' || busyAction === 'update' ? 'Working…'
: updateInfo?.updateAvailable ? `Update to v${updateInfo.latest}` : 'Check for update'}
</button>
)}
{rollback && (
<button type="button" disabled={busyAction !== null} onClick={() => void doRollback()}
className="flex items-center gap-1.5 rounded-md border border-border px-2.5 py-1 text-xs font-medium hover:bg-accent disabled:opacity-50">
<RotateCcw className="size-3.5" /> Roll back
</button>
)}
{app.kind === 'local' && (
<button type="button" disabled={busyAction !== null} onClick={() => setShowPublish(true)}
className="flex items-center gap-1.5 rounded-md border border-border px-2.5 py-1 text-xs font-medium hover:bg-accent disabled:opacity-50">
<UploadCloud className="size-3.5" /> {app.publish ? 'Publish update' : 'Publish'}
</button>
)}
{app.kind === 'installed' && (
<button type="button" disabled={busyAction !== null} onClick={() => void doUninstall()}
className="flex items-center gap-1.5 rounded-md border border-destructive/40 px-2.5 py-1 text-xs font-medium text-destructive hover:bg-destructive/10 disabled:opacity-50">
<Trash2 className="size-3.5" /> Uninstall
</button>
)}
</div>
</section>
<section className="space-y-2">
@ -172,6 +256,14 @@ export function AppDetail({ folder, onClose }: { folder: string; onClose: () =>
</div>
)}
</div>
{showPublish && app && (
<PublishDialog
folder={folder}
appName={manifest?.name ?? folder}
onClose={() => setShowPublish(false)}
onPublished={() => setReloadNonce((n) => n + 1)}
/>
)}
</div>
)
}

View file

@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'
import { Plus, RefreshCw } from 'lucide-react'
import type { rowboatApp } from '@x/shared'
import { AppFrame } from '@/components/apps/app-frame'
import { CatalogTab } from '@/components/apps/catalog'
// Apps home (spec §14): "My apps" grid + Catalog placeholder (M3). Cards are
// AppSummary-driven; click opens the app full-height on its own origin.
@ -219,7 +220,7 @@ export function AppsView({ initialAppFolder, initialVersion, onNewApp }: {
)}
{tab === 'catalog' ? (
<div className="ma-empty">The community catalog is coming soon.</div>
<CatalogTab onInstalled={(folder) => { setSelectedFolder(folder); setTab('mine') }} />
) : (
<div className="ma-grid">
{apps.map((app, i) => (

View file

@ -0,0 +1,231 @@
import { useEffect, useState } from 'react'
import { 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
// disclosure, install from a direct bundle URL.
type Preview = {
name?: string
version?: string
description?: string
capabilities?: string[]
agents?: string[]
updateSource?: 'github' | 'none'
url?: string // set for URL installs
}
function capabilityDescription(cap: string): string {
if (cap === 'llm') return 'use your AI models (spends your tokens)'
if (cap === 'copilot') return 'run the copilot agent on your behalf (tools + your knowledge)'
return `read and act on your ${cap} through your connected account`
}
/** D18 disclosure dialog: every declared capability + bundled agent, explicit confirm. */
function InstallConfirmDialog({ preview, busy, onConfirm, onCancel }: {
preview: Preview
busy: boolean
onConfirm: () => void
onCancel: () => void
}) {
const caps = preview.capabilities ?? []
const agents = preview.agents ?? []
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
<div className="w-full max-w-md rounded-xl border border-border bg-background p-5 shadow-xl">
<div className="mb-1 text-base font-semibold">Install {preview.name} v{preview.version}?</div>
{preview.description && <p className="mb-3 text-sm text-muted-foreground">{preview.description}</p>}
<div className="mb-3 rounded-lg border border-border bg-muted/30 p-3 text-sm">
<div className="mb-1.5 flex items-center gap-1.5 font-medium">
<ShieldAlert className="size-4 text-amber-500" /> This app will be able to:
</div>
{caps.length === 0 ? (
<p className="text-muted-foreground">Nothing it declares no capabilities (no tools, LLM, or copilot access).</p>
) : (
<ul className="list-inside list-disc space-y-0.5 text-muted-foreground">
{caps.map((c) => <li key={c}><span className="font-medium text-foreground">{c}</span>: {capabilityDescription(c)}</li>)}
</ul>
)}
{agents.length > 0 && (
<div className="mt-2">
<div className="font-medium">Bundled background agents (installed disabled):</div>
<ul className="list-inside list-disc text-muted-foreground">
{agents.map((a) => <li key={a}>{a}</li>)}
</ul>
</div>
)}
</div>
{preview.updateSource === 'none' && (
<p className="mb-3 text-xs text-amber-600 dark:text-amber-400">Installed from a direct URL updates will be unavailable.</p>
)}
<div className="flex justify-end gap-2">
<button type="button" onClick={onCancel} disabled={busy}
className="rounded-md border border-border px-3 py-1.5 text-sm font-medium hover:bg-accent">Cancel</button>
<button type="button" onClick={onConfirm} disabled={busy}
className="rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground hover:opacity-90 disabled:opacity-50">
{busy ? 'Installing…' : 'Install'}
</button>
</div>
</div>
</div>
)
}
export function CatalogTab({ onInstalled }: { onInstalled: (folder: string) => void }) {
const [records, setRecords] = useState<rowboatApp.RegistryRecord[]>([])
const [stale, setStale] = useState(false)
const [query, setQuery] = useState('')
const [error, setError] = useState<string | null>(null)
const [preview, setPreview] = useState<Preview | null>(null)
const [busy, setBusy] = useState(false)
const [urlDialog, setUrlDialog] = useState(false)
const [url, setUrl] = useState('')
const load = async (force = false) => {
setError(null)
try {
const r = await window.ipc.invoke('apps:catalogIndex', { force })
setRecords(r.records)
setStale(r.stale)
} catch (e) {
setError(e instanceof Error ? e.message : String(e))
}
}
useEffect(() => { void load() }, [])
const search = async (q: string) => {
setQuery(q)
try {
const r = q.trim()
? await window.ipc.invoke('apps:catalogSearch', { query: q })
: await window.ipc.invoke('apps:catalogIndex', {})
setRecords(r.records)
} catch { /* keep current list */ }
}
const startInstall = async (name: string) => {
setError(null)
try {
const r = await window.ipc.invoke('apps:install', { name })
if (r.status === 'preview') setPreview({ ...r })
} catch (e) {
setError(e instanceof Error ? e.message : String(e))
}
}
const startUrlPreview = async () => {
setError(null)
try {
const r = await window.ipc.invoke('apps:installFromUrl', { url: url.trim(), confirmed: false })
if (r.status === 'preview') setPreview({ ...r, url: url.trim() })
setUrlDialog(false)
} catch (e) {
setError(e instanceof Error ? e.message : String(e))
}
}
const confirmInstall = async () => {
if (!preview) return
setBusy(true)
setError(null)
try {
const r = preview.url
? 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)
} catch (e) {
setError(e instanceof Error ? e.message : String(e))
} finally {
setBusy(false)
}
}
return (
<div>
<div className="mb-4 flex items-center gap-2">
<div className="relative flex-1">
<Search className="absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<input
value={query}
onChange={(e) => void search(e.target.value)}
placeholder="Search the catalog…"
className="w-full rounded-lg border border-border bg-background py-2 pl-8 pr-3 text-sm outline-none focus:border-foreground/30"
/>
</div>
<button type="button" title="Install from URL"
onClick={() => setUrlDialog(true)}
className="flex items-center gap-1.5 rounded-lg border border-border px-3 py-2 text-sm font-medium hover:bg-accent">
<Link2 className="size-4" /> From URL
</button>
{stale && (
<button type="button" onClick={() => void load(true)}
className="flex items-center gap-1.5 rounded-lg border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-sm font-medium">
<RefreshCw className="size-4" /> Stale refresh
</button>
)}
</div>
{error && <div className="mb-3 rounded-md border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive">{error}</div>}
{records.length === 0 ? (
<div className="py-16 text-center text-sm text-muted-foreground">
{query ? 'No apps match your search.' : 'No apps in the catalog yet — be the first to publish one.'}
</div>
) : (
<div className="space-y-2">
{records.map((r) => (
<div key={r.name} className="flex items-center gap-3 rounded-xl border border-border bg-card px-4 py-3">
<div className="min-w-0 flex-1">
<div className="flex items-baseline gap-2">
<span className="truncate text-sm font-semibold">{r.name}</span>
<span className="text-xs text-muted-foreground">by {r.owner}</span>
</div>
<p className="truncate text-xs text-muted-foreground">{r.description || 'No description.'}</p>
</div>
<button type="button" onClick={() => void startInstall(r.name)}
className="flex items-center gap-1.5 rounded-md border border-border px-3 py-1.5 text-sm font-medium hover:bg-accent">
<Download className="size-4" /> Install
</button>
</div>
))}
</div>
)}
{urlDialog && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
<div className="w-full max-w-md rounded-xl border border-border bg-background p-5 shadow-xl">
<div className="mb-2 text-base font-semibold">Install from URL</div>
<p className="mb-3 text-sm text-muted-foreground">Paste a direct https link to a <code>.rowboat-app</code> bundle (e.g. a GitHub release asset).</p>
<input
value={url}
onChange={(e) => setUrl(e.target.value)}
placeholder="https://github.com/owner/repo/releases/download/v1.0.0/name.rowboat-app"
className="mb-3 w-full rounded-lg border border-border bg-background px-3 py-2 font-mono text-xs outline-none focus:border-foreground/30"
/>
<div className="flex justify-end gap-2">
<button type="button" onClick={() => setUrlDialog(false)}
className="rounded-md border border-border px-3 py-1.5 text-sm font-medium hover:bg-accent">Cancel</button>
<button type="button" onClick={() => void startUrlPreview()} disabled={!url.trim().startsWith('https://')}
className="rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground hover:opacity-90 disabled:opacity-50">
Preview
</button>
</div>
</div>
</div>
)}
{preview && (
<InstallConfirmDialog
preview={preview}
busy={busy}
onConfirm={() => void confirmInstall()}
onCancel={() => setPreview(null)}
/>
)}
</div>
)
}

View file

@ -0,0 +1,170 @@
import { useEffect, useRef, useState } from 'react'
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).
type Phase = 'auth' | 'device' | 'confirm' | 'publishing' | 'done' | 'error'
export function PublishDialog({ folder, appName, onClose, onPublished }: {
folder: string
appName: string
onClose: () => void
onPublished: () => void
}) {
const [phase, setPhase] = useState<Phase>('auth')
const [login, setLogin] = useState<string | undefined>()
const [userCode, setUserCode] = useState('')
const [steps, setSteps] = useState<string[]>([])
const [error, setError] = useState<string | null>(null)
const [result, setResult] = useState<{ repoUrl: string; releaseUrl: string; prUrl?: string; status: string } | null>(null)
const pollTimer = useRef<number | null>(null)
useEffect(() => {
void (async () => {
const s = await window.ipc.invoke('githubAuth:status', {})
if (s.signedIn) {
setLogin(s.login)
setPhase('confirm')
}
})()
return () => { if (pollTimer.current) window.clearInterval(pollTimer.current) }
}, [])
useEffect(() => {
return window.ipc.on('apps:progress', ({ folder: f, step }) => {
if (f === folder) setSteps((prev) => (prev.includes(step) ? prev : [...prev, step]))
})
}, [folder])
const startSignIn = async () => {
setError(null)
try {
const r = await window.ipc.invoke('githubAuth:start', {})
setUserCode(r.userCode)
setPhase('device')
pollTimer.current = window.setInterval(() => {
void (async () => {
const p = await window.ipc.invoke('githubAuth:poll', {})
if (p.status === 'authorized') {
if (pollTimer.current) window.clearInterval(pollTimer.current)
setLogin(p.login)
setPhase('confirm')
} else if (p.status === 'expired' || p.status === 'denied') {
if (pollTimer.current) window.clearInterval(pollTimer.current)
setError(p.status === 'expired' ? 'The code expired — start again.' : 'Authorization was denied.')
setPhase('auth')
}
})()
}, 5000)
} catch (e) {
setError(e instanceof Error ? e.message : String(e))
}
}
const publish = async () => {
setPhase('publishing')
setError(null)
setSteps([])
try {
const r = await window.ipc.invoke('apps:publish', { folder })
setResult(r)
setPhase('done')
onPublished()
} catch (e) {
setError(e instanceof Error ? e.message : String(e))
setPhase('error')
}
}
const STEP_LABELS: Record<string, string> = {
packaged: 'Packaged bundle',
repo_created: 'Created GitHub repo',
source_pushed: 'Pushed source',
release_created: 'Created release',
assets_uploaded: 'Uploaded assets',
registered: 'Opened registry PR',
polling: 'Waiting for registry validation…',
published: 'Published',
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
<div className="w-full max-w-md rounded-xl border border-border bg-background p-5 shadow-xl">
<div className="mb-3 flex items-center gap-2 text-base font-semibold">
<Github className="size-5" /> Publish {appName}
</div>
{error && <div className="mb-3 rounded-md border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{error}
{error.includes('name_taken') && <div className="mt-1 text-xs">That name is taken rename the app in <code>rowboat-app.json</code> and retry.</div>}
</div>}
{phase === 'auth' && (
<div className="space-y-3 text-sm">
<p className="text-muted-foreground">Publishing creates a public GitHub repo under your account, uploads the app as a release, and lists it in the Rowboat catalog. A generated MIT LICENSE is added if your app has none.</p>
<button type="button" onClick={() => void startSignIn()}
className="w-full rounded-md bg-primary py-2 text-sm font-medium text-primary-foreground hover:opacity-90">
Sign in with GitHub
</button>
</div>
)}
{phase === 'device' && (
<div className="space-y-3 text-center text-sm">
<p className="text-muted-foreground">Enter this code on the GitHub page that just opened:</p>
<div className="select-all rounded-lg border border-border bg-muted/40 py-3 font-mono text-2xl font-bold tracking-widest">{userCode}</div>
<p className="flex items-center justify-center gap-2 text-muted-foreground"><Loader2 className="size-4 animate-spin" /> Waiting for authorization</p>
</div>
)}
{phase === 'confirm' && (
<div className="space-y-3 text-sm">
<p className="text-muted-foreground">Signed in as <span className="font-medium text-foreground">@{login}</span>. This will publish <span className="font-medium text-foreground">{appName}</span> publicly.</p>
<button type="button" onClick={() => void publish()}
className="w-full rounded-md bg-primary py-2 text-sm font-medium text-primary-foreground hover:opacity-90">
Publish
</button>
</div>
)}
{(phase === 'publishing' || phase === 'done' || phase === 'error') && (
<div className="space-y-1.5 text-sm">
{Object.entries(STEP_LABELS).map(([key, label]) => {
const doneStep = steps.includes(key) || phase === 'done'
const active = phase === 'publishing' && !doneStep && steps[steps.length - 1] !== key
if (key === 'published' && phase !== 'done') return null
return (
<div key={key} className="flex items-center gap-2">
{doneStep
? <CheckCircle2 className="size-4 text-green-600" />
: phase === 'error'
? <XCircle className="size-4 text-muted-foreground/40" />
: <Loader2 className={`size-4 ${active ? 'text-muted-foreground/40' : 'animate-spin text-muted-foreground'}`} />}
<span className={doneStep ? '' : 'text-muted-foreground'}>{label}</span>
</div>
)
})}
{phase === 'done' && result && (
<div className="mt-3 space-y-1 text-xs">
<div>Repo: <a className="text-primary underline" href={result.repoUrl} target="_blank" rel="noreferrer">{result.repoUrl}</a></div>
<div>Release: <a className="text-primary underline" href={result.releaseUrl} target="_blank" rel="noreferrer">{result.releaseUrl}</a></div>
{result.status === 'pending' && result.prUrl && (
<div className="text-amber-600 dark:text-amber-400">Registry validation still pending track it at <a className="underline" href={result.prUrl} target="_blank" rel="noreferrer">the PR</a>.</div>
)}
</div>
)}
</div>
)}
<div className="mt-4 flex justify-end">
<button type="button" onClick={onClose}
className="rounded-md border border-border px-3 py-1.5 text-sm font-medium hover:bg-accent">
{phase === 'done' ? 'Done' : 'Close'}
</button>
</div>
</div>
</div>
)
}