diff --git a/apps/x/apps/main/src/ipc.ts b/apps/x/apps/main/src/ipc.ts index d60385ef..c559bf53 100644 --- a/apps/x/apps/main/src/ipc.ts +++ b/apps/x/apps/main/src/ipc.ts @@ -59,6 +59,7 @@ import * as composioHandler from './composio-handler.js'; import * as appsIndexer from '@x/core/dist/apps/indexer.js'; import * as appsServer from '@x/core/dist/apps/server.js'; import * as appsAgents from '@x/core/dist/apps/agents.js'; +import { capture } from '@x/core/dist/analytics/posthog.js'; import { consumePendingDeepLink } from './deeplink.js'; import { qualifyAndDisconnectComposioGoogle } from '@x/core/dist/migrations/composio-google-migration.js'; import { IAgentScheduleRepo } from '@x/core/dist/agent-schedule/repo.js'; @@ -1333,17 +1334,24 @@ export function setupIpcHandlers() { 'apps:get': async (_event, args) => { const app = await appsIndexer.getApp(args.folder); if (!app) throw new Error(`no such app: ${args.folder}`); - // readme + rollback are M3 concerns; shapes are stable now. - return { app, rollbackAvailable: false }; + const readme = await appsIndexer.readAppReadme(args.folder); + return { + app, + ...(readme ? { readme } : {}), + rollbackAvailable: await appsIndexer.rollbackAvailable(args.folder), + }; }, 'apps:create': async (_event, args) => { - return { app: await appsIndexer.createApp(args) }; + const app = await appsIndexer.createApp(args); + capture('app_created', { folder: app.folder }); + return { app }; }, 'apps:delete': async (_event, args) => { await appsIndexer.deleteApp(args.folder); // Remove app-owned bg-tasks too — orphaned app---- tasks firing // against a deleted app was a painful prototype failure mode. await appsAgents.deleteAppAgents(args.folder); + capture('app_deleted', { folder: args.folder }); return { ok: true as const }; }, 'apps:setTheme': async (_event, args) => { diff --git a/apps/x/apps/renderer/src/components/apps/app-detail.tsx b/apps/x/apps/renderer/src/components/apps/app-detail.tsx new file mode 100644 index 00000000..3b2e12d3 --- /dev/null +++ b/apps/x/apps/renderer/src/components/apps/app-detail.tsx @@ -0,0 +1,186 @@ +import { useEffect, useState } from 'react' +import { X, RotateCcw, Play } from 'lucide-react' +import type { rowboatApp } from '@x/shared' + +// App detail panel (spec §14): manifest info, provenance/publish state, +// bundled agents with enable toggles, rollback when available. Update/publish +// actions land with M3. + +type AgentRow = { + slug: string + name: string + active: boolean + lastRunAt?: string + lastRunError?: string +} + +export function AppDetail({ folder, onClose }: { folder: string; onClose: () => void }) { + const [app, setApp] = useState(null) + const [readme, setReadme] = useState(undefined) + const [rollback, setRollback] = useState(false) + const [agents, setAgents] = useState([]) + const [error, setError] = useState(null) + const [reloadNonce, setReloadNonce] = useState(0) + + useEffect(() => { + let cancelled = false + ;(async () => { + try { + const r = await window.ipc.invoke('apps:get', { folder }) + if (cancelled) return + setApp(r.app) + setReadme(r.readme) + setRollback(r.rollbackAvailable) + const rows: AgentRow[] = [] + for (const slug of r.app.agentSlugs) { + try { + const t = await window.ipc.invoke('bg-task:get', { slug }) + if (t.task) { + rows.push({ + slug, + name: t.task.name, + active: t.task.active, + lastRunAt: t.task.lastRunAt, + lastRunError: t.task.lastRunError, + }) + } + } catch { /* not materialized yet */ } + } + if (!cancelled) setAgents(rows) + } catch (e) { + if (!cancelled) setError(e instanceof Error ? e.message : String(e)) + } + })() + return () => { cancelled = true } + }, [folder, reloadNonce]) + + const toggleAgent = async (slug: string, active: boolean) => { + setAgents((prev) => prev.map((a) => (a.slug === slug ? { ...a, active } : a))) + try { + await window.ipc.invoke('bg-task:patch', { slug, partial: { active } }) + } catch { + setReloadNonce((n) => n + 1) // revert to truth on failure + } + } + + const runAgent = async (slug: string) => { + try { + await window.ipc.invoke('bg-task:run', { slug }) + } catch { /* surfaced via bg-task UI */ } + } + + const manifest = app?.manifest + + return ( +
+
+ {manifest?.name ?? folder} + +
+ +
+ {error &&
{error}
} + {!app ? ( +
Loading…
+ ) : ( +
+
+
App
+ {app.status === 'invalid' && ( +
+ Invalid manifest: {app.manifestError} +
+ )} + + + + {manifest?.description ?

{manifest.description}

: null} +
+ +
+
Capabilities
+ {manifest && manifest.capabilities.length > 0 ? ( +
+ {manifest.capabilities.map((c) => ( + {c} + ))} +
+ ) : ( +

None declared — this app can’t use tools, LLM, or the copilot.

+ )} +
+ +
+
Source
+ {app.kind === 'installed' && app.install ? ( + <> + + + {app.install.updatedAt && } + + ) : ( +

Local app — created on this machine{app.publish ? `, published as ${app.publish.repo}` : ''}.

+ )} + {rollback && ( + + )} +
+ +
+
Background agents
+ {agents.length === 0 ? ( +

No bundled agents.

+ ) : agents.map((a) => ( +
+
+
{a.name}
+
+ {a.lastRunError ? `Failed: ${a.lastRunError}` : a.lastRunAt ? `Last run ${new Date(a.lastRunAt).toLocaleString()}` : 'Never run'} +
+
+ + +
+ ))} +
+ + {readme && ( +
+
README
+
{readme}
+
+ )} +
+ )} +
+
+ ) +} + +function InfoRow({ k, v, mono }: { k: string; v: string; mono?: boolean }) { + return ( +
+ {k} + {v} +
+ ) +} diff --git a/apps/x/apps/renderer/src/components/apps/app-frame.tsx b/apps/x/apps/renderer/src/components/apps/app-frame.tsx index 5c28fb39..3492e67d 100644 --- a/apps/x/apps/renderer/src/components/apps/app-frame.tsx +++ b/apps/x/apps/renderer/src/components/apps/app-frame.tsx @@ -1,15 +1,22 @@ -import { useState } from 'react' -import { ArrowLeft, ExternalLink, RotateCw } from 'lucide-react' +import { useEffect, useState } from 'react' +import { ArrowLeft, ExternalLink, Info, RotateCw } from 'lucide-react' import type { rowboatApp } from '@x/shared' +import { appOpened } from '@/lib/analytics' +import { AppDetail } from '@/components/apps/app-detail' // Full-height iframe on the app's own origin (spec §6.6). No sandbox attr — // per-app browser origins are the isolation boundary. Toolbar: back, reload, -// open-in-browser. +// open-in-browser, detail panel. export function AppFrame({ app, onBack }: { app: rowboatApp.AppSummary; onBack: () => void }) { const [reloadNonce, setReloadNonce] = useState(0) + const [showDetail, setShowDetail] = useState(false) const title = app.manifest?.name ?? app.folder + useEffect(() => { + appOpened(app.folder) + }, [app.folder]) + return (
@@ -38,14 +45,29 @@ export function AppFrame({ app, onBack }: { app: rowboatApp.AppSummary; onBack: > +
-
-