mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-07-12 21:02:17 +02:00
feat(apps): detail panel, skill trigger, analytics, real apps:get
- app detail panel (toolbar Info toggle): manifest info, capabilities, provenance/publish state, bundled agents with enable toggles + run-now, rollback affordance, README - copilot instructions: apps-skill trigger line (build/make/create an app → load the apps skill first) - analytics: app_created / app_deleted (main), app_opened (renderer) - apps:get returns real readme + rollbackAvailable (.previous/ check)
This commit is contained in:
parent
3e34cbe1af
commit
810fd2da1a
6 changed files with 254 additions and 13 deletions
|
|
@ -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--<folder>-- 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) => {
|
||||
|
|
|
|||
186
apps/x/apps/renderer/src/components/apps/app-detail.tsx
Normal file
186
apps/x/apps/renderer/src/components/apps/app-detail.tsx
Normal file
|
|
@ -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<rowboatApp.AppSummary | null>(null)
|
||||
const [readme, setReadme] = useState<string | undefined>(undefined)
|
||||
const [rollback, setRollback] = useState(false)
|
||||
const [agents, setAgents] = useState<AgentRow[]>([])
|
||||
const [error, setError] = useState<string | null>(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 (
|
||||
<div className="flex h-full flex-col overflow-hidden">
|
||||
<div className="flex items-center gap-2 border-b border-border px-4 py-2.5">
|
||||
<span className="flex-1 truncate text-sm font-semibold">{manifest?.name ?? folder}</span>
|
||||
<button type="button" onClick={onClose} className="rounded-md p-1.5 text-muted-foreground hover:bg-accent hover:text-foreground">
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<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>}
|
||||
{!app ? (
|
||||
<div className="text-muted-foreground">Loading…</div>
|
||||
) : (
|
||||
<div className="space-y-5">
|
||||
<section className="space-y-1.5">
|
||||
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">App</div>
|
||||
{app.status === 'invalid' && (
|
||||
<div className="rounded-md border border-destructive/40 bg-destructive/10 px-3 py-2 text-xs text-destructive">
|
||||
Invalid manifest: {app.manifestError}
|
||||
</div>
|
||||
)}
|
||||
<InfoRow k="Version" v={manifest ? `v${manifest.version}` : '—'} />
|
||||
<InfoRow k="Folder" v={app.folder} />
|
||||
<InfoRow k="Origin" v={app.origin} mono />
|
||||
{manifest?.description ? <p className="pt-1 text-muted-foreground">{manifest.description}</p> : null}
|
||||
</section>
|
||||
|
||||
<section className="space-y-1.5">
|
||||
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Capabilities</div>
|
||||
{manifest && manifest.capabilities.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{manifest.capabilities.map((c) => (
|
||||
<span key={c} className="rounded-full bg-muted px-2.5 py-0.5 text-xs font-medium text-muted-foreground">{c}</span>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-muted-foreground">None declared — this app can’t use tools, LLM, or the copilot.</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="space-y-1.5">
|
||||
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Source</div>
|
||||
{app.kind === 'installed' && app.install ? (
|
||||
<>
|
||||
<InfoRow k="Installed from" v={app.install.repo ?? app.install.sourceUrl ?? 'unknown'} mono />
|
||||
<InfoRow k="Installed" v={new Date(app.install.installedAt).toLocaleString()} />
|
||||
{app.install.updatedAt && <InfoRow k="Updated" v={new Date(app.install.updatedAt).toLocaleString()} />}
|
||||
</>
|
||||
) : (
|
||||
<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>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="space-y-2">
|
||||
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Background agents</div>
|
||||
{agents.length === 0 ? (
|
||||
<p className="text-muted-foreground">No bundled agents.</p>
|
||||
) : agents.map((a) => (
|
||||
<div key={a.slug} className="flex items-center gap-2 rounded-lg border border-border px-3 py-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm font-medium">{a.name}</div>
|
||||
<div className="truncate text-xs text-muted-foreground">
|
||||
{a.lastRunError ? `Failed: ${a.lastRunError}` : a.lastRunAt ? `Last run ${new Date(a.lastRunAt).toLocaleString()}` : 'Never run'}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
title="Run now"
|
||||
onClick={() => void runAgent(a.slug)}
|
||||
className="rounded-md p-1.5 text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
<Play className="size-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={a.active}
|
||||
onClick={() => void toggleAgent(a.slug, !a.active)}
|
||||
className={`relative h-5 w-9 rounded-full transition ${a.active ? 'bg-primary' : 'bg-muted'}`}
|
||||
>
|
||||
<span className={`absolute top-0.5 size-4 rounded-full bg-background shadow transition-all ${a.active ? 'left-[18px]' : 'left-0.5'}`} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
|
||||
{readme && (
|
||||
<section className="space-y-1.5">
|
||||
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">README</div>
|
||||
<pre className="whitespace-pre-wrap rounded-lg border border-border bg-muted/40 p-3 text-xs leading-relaxed">{readme}</pre>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function InfoRow({ k, v, mono }: { k: string; v: string; mono?: boolean }) {
|
||||
return (
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="w-28 shrink-0 text-xs text-muted-foreground">{k}</span>
|
||||
<span className={`min-w-0 truncate ${mono ? 'font-mono text-xs' : ''}`}>{v}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -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 (
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="flex items-center gap-2 border-b border-border px-3 py-2">
|
||||
|
|
@ -38,14 +45,29 @@ export function AppFrame({ app, onBack }: { app: rowboatApp.AppSummary; onBack:
|
|||
>
|
||||
<ExternalLink className="size-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
title="App details"
|
||||
onClick={() => setShowDetail((v) => !v)}
|
||||
className={`rounded-md p-1.5 hover:bg-accent hover:text-foreground ${showDetail ? 'text-foreground' : 'text-muted-foreground'}`}
|
||||
>
|
||||
<Info className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1">
|
||||
<iframe
|
||||
key={reloadNonce}
|
||||
title={title}
|
||||
src={`${app.origin}/`}
|
||||
className="h-full w-full border-0 bg-background"
|
||||
/>
|
||||
<div className="flex min-h-0 flex-1">
|
||||
<div className="min-w-0 flex-1">
|
||||
<iframe
|
||||
key={reloadNonce}
|
||||
title={title}
|
||||
src={`${app.origin}/`}
|
||||
className="h-full w-full border-0 bg-background"
|
||||
/>
|
||||
</div>
|
||||
{showDetail && (
|
||||
<div className="w-80 shrink-0 border-l border-border">
|
||||
<AppDetail folder={app.folder} onClose={() => setShowDetail(false)} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -53,6 +53,10 @@ export function chatMessageSent(props: {
|
|||
})
|
||||
}
|
||||
|
||||
export function appOpened(folder: string) {
|
||||
posthog.capture('app_opened', { folder })
|
||||
}
|
||||
|
||||
export function oauthConnected(provider: string) {
|
||||
posthog.capture('oauth_connected', { provider })
|
||||
}
|
||||
|
|
|
|||
|
|
@ -122,6 +122,8 @@ ${codeModeEnabled
|
|||
|
||||
*Medium signals (load the skill, answer the one-off, then offer):* one-off questions about decaying info ("what's the weather?", "top HN stories?"), "what's the latest on X / catch me up on X / any updates on X" about a person, company, project, or topic, recurring artifacts ("morning briefing", "weekly review", "Acme deal dashboard"). **Heuristic:** if you reach for \`web-search\` or a news tool to answer a recurring question, the answer is the kind of thing a bg-task would refresh on a schedule.
|
||||
|
||||
**Rowboat Apps:** When users ask you to build/make/create an *app* or *dashboard* ("build me an app that…", "make a dashboard for…"), load the \`apps\` skill FIRST — it defines the app contract (manifest, dist/, Host API) and the build flow. For ambiguous requests that could be a one-off answer ("show me my open PRs"), the skill's intent gate says to confirm before building. Do not hand-roll app folders without the skill.
|
||||
|
||||
**Live Notes:** If the user explicitly says "live note" or "live-note", load the \`live-note\` skill. Otherwise, do not propose live notes — prefer the \`background-task\` skill for anything recurring.
|
||||
**Browser Control:** When users ask you to open a website, browse in-app, search the web in the embedded browser, or interact with a live webpage inside Rowboat, load the \`browser-control\` skill first. It explains the \`read-page -> indexed action -> refreshed page\` workflow for the browser pane.
|
||||
|
||||
|
|
|
|||
|
|
@ -174,6 +174,25 @@ export async function createApp(input: { folder: string; name: string; descripti
|
|||
return summary;
|
||||
}
|
||||
|
||||
/** Read the app's README.md (root or dist/), if any. Best effort. */
|
||||
export async function readAppReadme(folder: string): Promise<string | undefined> {
|
||||
for (const candidate of ['README.md', 'dist/README.md']) {
|
||||
try {
|
||||
return await fs.readFile(path.join(appDir(folder), candidate), 'utf-8');
|
||||
} catch { /* try next */ }
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Whether a one-step rollback is available (§12.3: .previous/ exists). */
|
||||
export async function rollbackAvailable(folder: string): Promise<boolean> {
|
||||
try {
|
||||
return (await fs.stat(path.join(appDir(folder), '.previous'))).isDirectory();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Delete a local app (§5.3). Installed apps must go through uninstall. */
|
||||
export async function deleteApp(folder: string): Promise<void> {
|
||||
if (!FOLDER_SLUG_RE.test(folder)) throw new Error(`invalid_folder: ${folder}`);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue