feat(mini-apps): serve apps from ~/.rowboat/apps via app://miniapp

Apps are now user data on disk, not bundled in the repo.

- MiniAppManifest schema + mini-apps:list/get-data/seed IPC channels
- main: mini-apps-handler (list/get-data + seed install primitive) and an
  app://miniapp/<id>/ protocol host serving ~/.rowboat/apps/<id>/dist
- renderer lists installed apps from disk and loads each via app://miniapp
  (real origin: remote images/fetch work); data sourced from data.json
- removed the bundled sample apps from source (they live in ~/.rowboat/apps)
This commit is contained in:
Gagan 2026-07-01 00:27:00 +05:30
parent 6cbf40b165
commit 924a93fa4a
14 changed files with 289 additions and 693 deletions

View file

@ -78,6 +78,64 @@ rowboat.ready() // handshake: "send me data"
notifications (reuse `notify-user`).
6. **(V2)** Drag an app into the main workspace (macOS-style).
## 2b. Phase 2.5 — On-disk apps + `app://miniapp` serving (CURRENT)
Move built-in apps out of source into `~/.rowboat/apps/<id>/`, served as static
assets, with an optional background agent producing `data.json`. Sets up the
copilot builder path (it will write the same folder layout). Team-agreed.
### On-disk layout (one folder per app)
```
~/.rowboat/apps/<id>/
manifest.json # id, title, description, source, scope[], active, lastRun,
# entry (default "dist/index.html"), agent (optional bg-task slug)
dist/ # static assets served via app://miniapp/<id>/...
index.html # the app (self-contained; bridge shim inlined)
data.json # latest agent output (read by the host, pushed to the app)
```
### Serving — `app://miniapp/<id>/<path>` (option A)
- Extend `registerAppProtocol` (`apps/main/src/main.ts`) with a new host
`miniapp`: map `app://miniapp/<id>/<path>``~/.rowboat/apps/<id>/dist/<path>`
(path-traversal guarded; default `index.html`). `app://` is already registered
privileged (standard/secure/fetch/cors) so apps get a **real origin**
remote CDNs/images and `fetch` work, unlike the opaque `srcdoc` origin.
- The iframe loads via `src="app://miniapp/<id>/index.html"` (not `srcDoc`).
Sandbox becomes `allow-scripts allow-same-origin allow-popups allow-forms
allow-modals allow-downloads` — same-origin is its OWN `app://miniapp` origin,
still isolated from the renderer (different host).
### Data path
- Built-in/static `data` is seeded to `data.json`. A future background agent
(reuse bg-tasks engine, linked via manifest `agent`) overwrites it on schedule.
- App keeps using `rowboat.onData`; the **host** now sources that data by reading
`~/.rowboat/apps/<id>/data.json` (via IPC) and posting it on `ready`. Composio
still flows through the bridge RPC. (GitHub app needs no `data.json` — it pulls
live via Composio.)
### Steps
1. **Shared schema**`packages/shared/src/mini-app.ts`: `MiniAppManifest` zod +
type. Export it. New IPC channels in `shared/src/ipc.ts`:
- `mini-apps:seed` (req: `{apps:[{manifest, html, data?}]}`) — idempotent.
- `mini-apps:list` (res: `{manifests: MiniAppManifest[]}`).
- `mini-apps:get-data` (req `{id}`, res `{data: unknown|null}`).
2. **Main**`apps/main/src/mini-apps-handler.ts`: `seedApps` (write
manifest/dist/data to `~/.rowboat/apps/<id>/` only if absent), `listApps`
(read manifests), `getAppData` (read data.json). Register handlers in
`ipc.ts`. Extend `registerAppProtocol` for the `miniapp` host.
3. **Renderer** — keep `MiniApp` defs + `buildMiniAppHtml`; `registry.ts` adds
`toSeed(app)` (→ `{manifest, html, data}`). `MiniAppsView`: on mount → `seed`
`list` → render cards from manifests. `MiniAppFrame`: take the manifest,
load `src=app://miniapp/<id>/<entry>`, on `ready` fetch `mini-apps:get-data`
and post it; RPC scope from `manifest.scope`.
4. **Verify**: GitHub app end-to-end from disk (`~/.rowboat/apps/github-radar/`),
then the others; confirm connect + live PRs still work.
### Out of scope here (next: copilot builder, tomorrow)
Copilot writing a new app folder + an associated background agent; the agent
browsing via embedded browser for social feeds; copilot verifying Composio wiring
by actually calling tools before finalizing.
## 3. Phase 1 — detailed implementation
UI-first. Everything hand-coded; no `~/.rowboat` storage, no IPC, no agent yet.

View file

@ -56,6 +56,7 @@ import { syncSlackKnowledgeSources, triggerSync as triggerSlackKnowledgeSync, ge
import { isOnboardingComplete, markOnboardingComplete } from '@x/core/dist/config/note_creation_config.js';
import { loadNotificationSettings, saveNotificationSettings } from '@x/core/dist/config/notification_config.js';
import * as composioHandler from './composio-handler.js';
import * as miniAppsHandler from './mini-apps-handler.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';
@ -1313,6 +1314,16 @@ export function setupIpcHandlers() {
'migration:check-composio-google': async () => {
return qualifyAndDisconnectComposioGoogle();
},
// Mini Apps handlers
'mini-apps:seed': async (_event, args) => {
return miniAppsHandler.seedApps(args.apps);
},
'mini-apps:list': async () => {
return miniAppsHandler.listApps();
},
'mini-apps:get-data': async (_event, args) => {
return miniAppsHandler.getAppData(args.id);
},
// Agent schedule handlers
'agent-schedule:getConfig': async () => {
const repo = container.resolve<IAgentScheduleRepo>('agentScheduleRepo');

View file

@ -39,6 +39,7 @@ import { identifyIfSignedIn } from "@x/core/dist/analytics/identify.js";
import { initConfigs } from "@x/core/dist/config/initConfigs.js";
import { getAgentSlackCliStatus } from "@x/core/dist/slack/agent-slack-exec.js";
import { resolveWorkspacePath } from "@x/core/dist/workspace/workspace.js";
import { resolveMiniAppAsset } from "./mini-apps-handler.js";
import started from "electron-squirrel-startup";
import { execFileSync } from "node:child_process";
import { init as initChromeSync } from "@x/core/dist/knowledge/chrome-extension/server/server.js";
@ -166,6 +167,20 @@ function registerAppProtocol() {
}
}
// Mini App assets: app://miniapp/<id>/<rel-path> → ~/.rowboat/apps/<id>/dist/
if (url.host === "miniapp") {
try {
const segments = decodeURIComponent(url.pathname).replace(/^\/+/, "").split("/");
const id = segments.shift();
if (!id) return new Response("Not Found", { status: 404 });
const absPath = resolveMiniAppAsset(id, segments.join("/"));
if (!absPath) return new Response("Forbidden", { status: 403 });
return net.fetch(pathToFileURL(absPath).toString());
} catch {
return new Response("Forbidden", { status: 403 });
}
}
// Renderer SPA — existing logic
let urlPath = url.pathname;
if (urlPath === "/" || !path.extname(urlPath)) {

View file

@ -0,0 +1,83 @@
import fs from 'fs';
import path from 'path';
import { WorkDir } from '@x/core/dist/config/config.js';
import { MiniAppManifest } from '@x/shared/dist/mini-app.js';
import type { z } from 'zod';
type Manifest = z.infer<typeof MiniAppManifest>;
// All Mini Apps live under ~/.rowboat/apps/<id>/.
// manifest.json — MiniAppManifest
// dist/ — static assets served via app://miniapp/<id>/
// data.json — latest agent output (read by the host)
const APPS_DIR = path.join(WorkDir, 'apps');
function appDir(id: string): string {
return path.join(APPS_DIR, id);
}
function ensureDir(dir: string): void {
fs.mkdirSync(dir, { recursive: true });
}
/**
* Seed/refresh built-in apps. Manifest + dist are managed (always overwritten so
* built-in updates propagate); data.json is written only if missing so a
* background agent's output is never clobbered.
*/
export function seedApps(apps: Array<{ manifest: Manifest; html: string; data?: unknown }>): { seeded: string[] } {
const seeded: string[] = [];
for (const app of apps) {
const manifest = MiniAppManifest.parse(app.manifest);
const dir = appDir(manifest.id);
const distDir = path.join(dir, 'dist');
ensureDir(distDir);
fs.writeFileSync(path.join(dir, 'manifest.json'), JSON.stringify(manifest, null, 2));
fs.writeFileSync(path.join(distDir, 'index.html'), app.html);
const dataPath = path.join(dir, 'data.json');
if (app.data !== undefined && !fs.existsSync(dataPath)) {
fs.writeFileSync(dataPath, JSON.stringify(app.data, null, 2));
}
seeded.push(manifest.id);
}
return { seeded };
}
/** List installed app manifests (skips folders without a valid manifest). */
export function listApps(): { manifests: Manifest[] } {
const manifests: Manifest[] = [];
if (!fs.existsSync(APPS_DIR)) return { manifests };
for (const entry of fs.readdirSync(APPS_DIR, { withFileTypes: true })) {
if (!entry.isDirectory()) continue;
const file = path.join(APPS_DIR, entry.name, 'manifest.json');
try {
const parsed = MiniAppManifest.parse(JSON.parse(fs.readFileSync(file, 'utf-8')));
manifests.push(parsed);
} catch {
// skip folders without a valid manifest
}
}
return { manifests };
}
/** Read an app's latest data.json (agent output), or null if absent/invalid. */
export function getAppData(id: string): { data: unknown | null } {
try {
const raw = fs.readFileSync(path.join(appDir(id), 'data.json'), 'utf-8');
return { data: JSON.parse(raw) };
} catch {
return { data: null };
}
}
/**
* Resolve app://miniapp/<id>/<rel> to an absolute file path under the app's dist
* dir, guarding against path traversal. Returns null if outside the dist root.
*/
export function resolveMiniAppAsset(id: string, relPath: string): string | null {
const distRoot = path.resolve(appDir(id), 'dist');
const clean = relPath.replace(/^\/+/, '') || 'index.html';
const abs = path.resolve(distRoot, clean);
if (abs !== distRoot && !abs.startsWith(distRoot + path.sep)) return null;
return abs;
}

View file

@ -1,22 +1,25 @@
import { useEffect, useRef } from 'react'
import type { MiniApp, MiniAppOutboundMessage } from '@/mini-apps/types'
import { miniApp } from '@x/shared'
import type { MiniAppOutboundMessage } from '@/mini-apps/types'
import { MINI_APP_MESSAGE } from '@/mini-apps/types'
// Host side of the Mini App bridge. Renders the app's self-contained HTML in a
// sandboxed iframe and answers the postMessage protocol in mini-apps/types.ts.
// Host side of the Mini App bridge. Loads the app's static assets from
// app://miniapp/<id>/ (served from ~/.rowboat/apps/<id>/dist) and answers the
// postMessage protocol in mini-apps/types.ts.
//
// Phase 2: the bridge is real. App rpc calls (callAction/searchTools/
// isConnected/connect) are scope-checked against the app's declared scope and
// routed to Composio over IPC. Per-app state is still in-memory for now.
// - data: sourced from the app's on-disk data.json (agent output) via IPC.
// - bridge rpc (callAction/searchTools/isConnected/connect): scope-checked
// against the manifest, routed to Composio over IPC.
// Sandbox intentionally omits allow-same-origin: the app gets an opaque origin so
// it cannot reach host cookies/storage. The bridge is the only channel out.
const SANDBOX = 'allow-scripts allow-popups allow-popups-to-escape-sandbox allow-forms allow-modals allow-downloads'
// app://miniapp/<id> is a distinct origin from the renderer, so allow-same-origin
// here grants the app same-origin to its OWN origin only (lets it use fetch /
// remote assets) while staying isolated from the renderer.
const SANDBOX = 'allow-scripts allow-same-origin allow-popups allow-popups-to-escape-sandbox allow-forms allow-modals allow-downloads'
export function MiniAppFrame({ app }: { app: MiniApp }) {
export function MiniAppFrame({ manifest }: { manifest: miniApp.MiniAppManifest }) {
const iframeRef = useRef<HTMLIFrameElement | null>(null)
// In-memory per-app state for Phase 1/2 (resets when the frame unmounts).
const stateRef = useRef<Record<string, unknown>>({})
const scope = manifest.scope
useEffect(() => {
stateRef.current = {}
@ -25,26 +28,24 @@ export function MiniAppFrame({ app }: { app: MiniApp }) {
iframeRef.current?.contentWindow?.postMessage(message, '*')
}
// Dispatch an app rpc call to real Composio IPC. Throws on scope violation
// or failure; the caller turns that into an rpc-result error.
async function handleRpc(method: string, params: Record<string, unknown>): Promise<unknown> {
const scope = typeof params.scope === 'string' ? params.scope : ''
if (!scope || !app.scope.includes(scope)) {
throw new Error(`This app is not allowed to use "${scope || '(none)'}".`)
const s = typeof params.scope === 'string' ? params.scope : ''
if (!s || !scope.includes(s)) {
throw new Error(`This app is not allowed to use "${s || '(none)'}".`)
}
switch (method) {
case 'isConnected': {
const r = await window.ipc.invoke('composio:get-connection-status', { toolkitSlug: scope })
const r = await window.ipc.invoke('composio:get-connection-status', { toolkitSlug: s })
return r.isConnected
}
case 'connect': {
const r = await window.ipc.invoke('composio:initiate-connection', { toolkitSlug: scope })
const r = await window.ipc.invoke('composio:initiate-connection', { toolkitSlug: s })
if (!r.success && r.error) throw new Error(r.error)
return { started: r.success }
}
case 'searchTools': {
const query = typeof params.query === 'string' ? params.query : ''
const r = await window.ipc.invoke('composio:search-tools', { toolkitSlug: scope, query })
const r = await window.ipc.invoke('composio:search-tools', { toolkitSlug: s, query })
if (r.error) throw new Error(r.error)
return r.tools
}
@ -52,7 +53,7 @@ export function MiniAppFrame({ app }: { app: MiniApp }) {
const tool = typeof params.tool === 'string' ? params.tool : ''
if (!tool) throw new Error('No tool specified.')
const args = (params.args && typeof params.args === 'object' ? params.args : {}) as Record<string, unknown>
const r = await window.ipc.invoke('composio:execute-tool', { toolkitSlug: scope, toolSlug: tool, arguments: args })
const r = await window.ipc.invoke('composio:execute-tool', { toolkitSlug: s, toolSlug: tool, arguments: args })
if (!r.successful) throw new Error(r.error || 'Action failed.')
return r.data
}
@ -61,6 +62,18 @@ export function MiniAppFrame({ app }: { app: MiniApp }) {
}
}
async function handleReady() {
let data: unknown = null
try {
const r = await window.ipc.invoke('mini-apps:get-data', { id: manifest.id })
data = r.data
} catch {
data = null
}
postToFrame({ type: MINI_APP_MESSAGE.data, data })
postToFrame({ type: MINI_APP_MESSAGE.state, state: stateRef.current })
}
function handleMessage(event: MessageEvent) {
const frameWindow = iframeRef.current?.contentWindow
if (!frameWindow || event.source !== frameWindow) return
@ -70,8 +83,7 @@ export function MiniAppFrame({ app }: { app: MiniApp }) {
switch (msg.type) {
case MINI_APP_MESSAGE.ready: {
postToFrame({ type: MINI_APP_MESSAGE.data, data: app.data })
postToFrame({ type: MINI_APP_MESSAGE.state, state: stateRef.current })
void handleReady()
break
}
case MINI_APP_MESSAGE.rpc: {
@ -96,13 +108,13 @@ export function MiniAppFrame({ app }: { app: MiniApp }) {
window.addEventListener('message', handleMessage)
return () => window.removeEventListener('message', handleMessage)
}, [app])
}, [manifest.id, scope])
return (
<iframe
ref={iframeRef}
title={app.name}
srcDoc={app.html}
title={manifest.title}
src={`app://miniapp/${manifest.id}/index.html`}
className="h-full w-full border-0 bg-neutral-950"
sandbox={SANDBOX}
/>

View file

@ -1,7 +1,6 @@
import { useState } from 'react'
import { useEffect, useState } from 'react'
import { ArrowLeft, Plus } from 'lucide-react'
import { MINI_APPS, getMiniApp } from '@/mini-apps/registry'
import type { MiniApp } from '@/mini-apps/types'
import { miniApp } from '@x/shared'
import { MiniAppFrame } from '@/components/mini-app-frame'
// The "Mini Apps" section: a gallery of premium product tiles; click one to open
@ -129,7 +128,7 @@ const CARD_CSS = `
.ma-new-hint { font-size:12px; color:var(--ma-new-hint); }
`
function Card({ app, index, onOpen }: { app: MiniApp; index: number; onOpen: () => void }) {
function Card({ app, index, onOpen }: { app: miniApp.MiniAppManifest; index: number; onOpen: () => void }) {
const theme = themeForIndex(index)
const pattern = patternFor(app.id)
return (
@ -144,7 +143,7 @@ function Card({ app, index, onOpen }: { app: MiniApp; index: number; onOpen: ()
{app.active ? 'ACTIVE' : 'PAUSED'}
</span>
</div>
<div className="ma-title">{app.name}</div>
<div className="ma-title">{app.title}</div>
<div className="ma-desc">{app.description}</div>
<div className="ma-footer">
<span className="ma-source">{app.source}</span>
@ -156,7 +155,25 @@ function Card({ app, index, onOpen }: { app: MiniApp; index: number; onOpen: ()
export function MiniAppsView() {
const [selectedId, setSelectedId] = useState<string | null>(null)
const selected = selectedId ? getMiniApp(selectedId) : undefined
const [manifests, setManifests] = useState<miniApp.MiniAppManifest[]>([])
// List apps installed under ~/.rowboat/apps. Apps are created there by the
// copilot builder (or placed manually); none are bundled in the repo.
useEffect(() => {
let cancelled = false
;(async () => {
try {
const r = await window.ipc.invoke('mini-apps:list', null)
if (cancelled) return
setManifests([...r.manifests].sort((a, b) => a.title.localeCompare(b.title)))
} catch {
if (!cancelled) setManifests([])
}
})()
return () => { cancelled = true }
}, [])
const selected = selectedId ? manifests.find((m) => m.id === selectedId) : undefined
if (selected) {
return (
@ -170,10 +187,10 @@ export function MiniAppsView() {
<ArrowLeft className="size-4" />
Apps
</button>
<span className="text-sm font-medium">{selected.name}</span>
<span className="text-sm font-medium">{selected.title}</span>
</div>
<div className="min-h-0 flex-1">
<MiniAppFrame app={selected} />
<MiniAppFrame manifest={selected} />
</div>
</div>
)
@ -187,8 +204,8 @@ export function MiniAppsView() {
<p className="ma-sub">Little apps that live inside Rowboat, powered by your agents and integrations.</p>
<div className="ma-grid">
{MINI_APPS.map((app, i) => (
<Card key={app.id} app={app} index={i} onOpen={() => setSelectedId(app.id)} />
{manifests.map((m, i) => (
<Card key={m.id} app={m} index={i} onOpen={() => setSelectedId(m.id)} />
))}
{/* Placeholder for copilot-generated apps (Phase 3). */}

View file

@ -1,46 +0,0 @@
// Two simple digest-style sample Mini Apps (Newsletter Digest, Competitor Watch).
// They share the list template in ./simple-list and exist mainly so the gallery
// shows the card design across several apps with different accent themes.
import type { MiniApp } from '../types'
import { buildSimpleListHtml } from './simple-list'
export const newsletterDigestApp: MiniApp = {
id: 'newsletter-digest',
name: 'Newsletter Digest',
description: 'Summarises your subscriptions into a single skimmable morning read.',
source: 'Email',
active: true,
lastRun: '1h ago',
scope: ['gmail'],
data: {
title: 'Newsletter Digest',
subtitle: 'Your subscriptions, summarised — 6 this morning',
items: [
{ id: 'n1', title: 'Stratechery', meta: '7 min', body: 'The platform shift to agents mirrors the mobile transition — distribution, not models, decides the winners.' },
{ id: 'n2', title: 'Lennys Newsletter', meta: '5 min', body: 'How three PMs run discovery without a researcher: weekly user calls, a shared notes doc, and ruthless prioritisation.' },
{ id: 'n3', title: 'The Pragmatic Engineer', meta: '9 min', body: 'Inside a staff-level promo packet: scope, impact, and the "without you it wouldnt have happened" test.' },
],
},
html: buildSimpleListHtml('Newsletter Digest'),
}
export const competitorWatchApp: MiniApp = {
id: 'competitor-watch',
name: 'Competitor Watch',
description: 'Tracks launches, pricing changes, and notable posts from your rivals.',
source: 'Web',
active: false,
lastRun: '4h ago',
scope: ['web'],
data: {
title: 'Competitor Watch',
subtitle: '3 updates since yesterday',
items: [
{ id: 'c1', title: 'Acme launched AI Mode', meta: 'pricing', body: 'New $40/mo tier bundles their assistant; older Pro plan unchanged. Positioning leans on “team workspaces”.' },
{ id: 'c2', title: 'Globex changelog', meta: 'product', body: 'Shipped offline sync and a public API. Docs hint at a desktop app in private beta.' },
{ id: 'c3', title: 'Initech blog', meta: 'content', body: 'A “build vs buy” post aimed squarely at your ICP — worth a counter-piece on local-first privacy.' },
],
},
html: buildSimpleListHtml('Competitor Watch'),
}

View file

@ -1,314 +0,0 @@
// Sample Mini App: GitHub Dashboard.
//
// A custom dashboard: pick repos to track, then view their open pull requests
// (title, author, description) — fetched LIVE from GitHub through the Phase 2
// bridge (searchTools -> callAction against a Composio managed-OAuth2 toolkit).
// Tracked repos persist via the per-app state store.
import { buildMiniAppHtml } from '../runtime'
import type { MiniApp } from '../types'
// Seed repos shown on first open (the user can add/remove their own).
const data = {
seedRepos: [
{ owner: 'vercel', repo: 'next.js' },
{ owner: 'rowboatlabs', repo: 'rowboat' },
],
}
const style = `
.app { height:100%; overflow:auto; background:#0d1117; color:#e6edf3; }
.wrap { max-width:760px; margin:0 auto; padding:24px 16px 64px; }
.h { font-size:22px; font-weight:600; letter-spacing:-0.02em; margin:0 0 4px; color:#f0f6fc; }
.sub { font-size:13px; color:#8b949e; margin:0 0 18px; }
.banner { display:flex; align-items:center; justify-content:space-between; gap:12px; margin:0 0 16px; padding:12px 14px; border:1px solid #1f6feb; background:rgba(31,111,235,0.12); border-radius:10px; }
.banner.ok { border-color:#238636; background:rgba(35,134,54,0.12); }
.banner-text { font-size:13px; color:#e6edf3; }
.banner-btn { background:#238636; color:#fff; border:0; border-radius:8px; padding:7px 14px; font-size:13px; font-weight:600; cursor:pointer; }
.banner-btn:disabled { opacity:.6; cursor:default; }
.profile { display:flex; gap:14px; align-items:center; margin-bottom:16px; }
.avatar { width:64px; height:64px; border-radius:50%; border:1px solid #30363d; flex:0 0 auto; object-fit:cover; }
.avatar-fb { width:64px; height:64px; border-radius:50%; background:#21262d; display:none; align-items:center; justify-content:center; font-size:24px; font-weight:600; color:#8b949e; flex:0 0 auto; }
.pname { font-size:17px; font-weight:600; color:#f0f6fc; }
.plogin { font-size:13px; color:#8b949e; }
.pbio { font-size:13px; color:#adbac7; margin-top:4px; }
.pstats { display:flex; gap:16px; margin-top:8px; font-size:12px; color:#8b949e; }
.pstats b { color:#e6edf3; }
.contrib { border:1px solid #30363d; background:#161b22; border-radius:10px; padding:14px; margin-bottom:18px; }
.contrib-title { font-size:13px; font-weight:600; color:#f0f6fc; margin-bottom:10px; }
.contrib-img { width:100%; height:auto; display:block; border-radius:6px; }
.contrib-note { font-size:12px; color:#6e7681; }
.adder { display:flex; gap:8px; margin-bottom:18px; }
.adder input { flex:1; background:#0d1117; border:1px solid #30363d; color:#e6edf3; border-radius:8px; padding:8px 11px; font-size:13px; outline:none; }
.adder input:focus { border-color:#1f6feb; }
.adder button { background:#21262d; border:1px solid #30363d; color:#e6edf3; border-radius:8px; padding:0 14px; font-size:13px; font-weight:600; cursor:pointer; }
.adder button:hover { background:#30363d; }
.repo { border:1px solid #30363d; background:#161b22; border-radius:10px; margin-bottom:14px; overflow:hidden; }
.repo-head { display:flex; align-items:center; justify-content:space-between; gap:10px; padding:12px 14px; border-bottom:1px solid #21262d; }
.repo-name { font-size:14px; font-weight:600; color:#2f81f7; }
.repo-actions { display:flex; gap:8px; align-items:center; }
.repo-count { font-size:12px; color:#8b949e; }
.icon-btn { background:none; border:0; color:#8b949e; cursor:pointer; font-size:13px; padding:2px 6px; border-radius:6px; }
.icon-btn:hover { background:#21262d; color:#e6edf3; }
.pr { border-bottom:1px solid #21262d; }
.pr:last-child { border-bottom:0; }
.pr-row { display:flex; gap:10px; align-items:flex-start; padding:10px 14px; cursor:pointer; }
.pr-row:hover { background:#1c2128; }
.pr-icon { color:#3fb950; font-size:13px; line-height:1.5; flex:0 0 auto; }
.pr-main { min-width:0; flex:1; }
.pr-title { font-size:13px; color:#e6edf3; }
.pr-sub { font-size:12px; color:#8b949e; margin-top:2px; }
.pr-body { padding:0 14px 12px 38px; font-size:13px; line-height:1.5; color:#adbac7; white-space:pre-wrap; word-wrap:break-word; }
.pr-body.empty { color:#6e7681; font-style:italic; }
.state { padding:14px; font-size:13px; color:#8b949e; }
.state.err { color:#f85149; }
.toast { position:fixed; bottom:20px; left:50%; transform:translateX(-50%); background:#238636; color:#fff; font-size:14px; font-weight:600; padding:10px 18px; border-radius:8px; opacity:0; transition:opacity .2s; pointer-events:none; }
.toast.show { opacity:1; }
.toast.err { background:#da3633; }
.empty-state { padding:32px 14px; text-align:center; color:#6e7681; font-size:13px; }
.loading { padding:48px 16px; text-align:center; color:#8b949e; }
`
const script = `
var root = document.getElementById('root');
var seed = [];
var repos = null; // [{owner,repo}], persisted
var expanded = {}; // pr key -> bool
var prCache = {}; // 'owner/repo' -> { loading, error, prs }
var profile = null; // authenticated user profile
var profileState = null; // { loading, error }
var repoInput = '';
var connected = null;
var connecting = false;
var toastTimer = null;
function esc(s){ return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }
function key(o,r){ return o + '/' + r; }
function persist(){ window.rowboat.setState({ repos: repos }); }
// ---- live GitHub reads via the bridge -------------------------------------
function asArray(d){
if (Array.isArray(d)) return d;
if (d && typeof d === 'object') {
var keys = ['details','data','items','pull_requests','pullRequests','response_data','result'];
for (var i=0;i<keys.length;i++){ if (Array.isArray(d[keys[i]])) return d[keys[i]]; }
for (var k in d){ if (Array.isArray(d[k]) && d[k].length && typeof d[k][0] === 'object') return d[k]; }
}
return [];
}
function normPRs(d){
return asArray(d).map(function(p){
return {
number: (p.number != null ? p.number : (p.id != null ? p.id : '?')),
title: p.title || '(untitled)',
body: (p.body || '').trim(),
author: (p.user && (p.user.login || p.user.name)) || p.author || '',
url: p.html_url || p.url || ''
};
});
}
// Pick the *list pull requests* tool, excluding review/comment/file/commit
// variants that happen to contain PULL + LIST but need pull_number/review_id.
function pickPRTool(tools){
var bad = ['REVIEW','COMMENT','FILE','COMMIT','REQUESTED'];
function ok(s){ for (var i=0;i<bad.length;i++) if (s.indexOf(bad[i])>=0) return false; return true; }
for (var i=0;i<tools.length;i++){ if ((tools[i].slug||'').toUpperCase() === 'GITHUB_LIST_PULL_REQUESTS') return tools[i]; }
for (var j=0;j<tools.length;j++){ var s=(tools[j].slug||'').toUpperCase(); if (/LIST_PULL_REQUESTS$/.test(s) && ok(s)) return tools[j]; }
for (var m=0;m<tools.length;m++){ var t=(tools[m].slug||'').toUpperCase(); if (t.indexOf('PULL_REQUEST')>=0 && t.indexOf('LIST')>=0 && ok(t)) return tools[m]; }
return null;
}
function fetchPRs(o, r){
var k = key(o,r);
prCache[k] = { loading:true }; render();
var args = { owner:o, repo:r, state:'open', sort:'updated', direction:'desc', per_page:20 };
// Canonical slug first; fall back to a strict search only if it's unknown.
window.rowboat.callAction('github', 'GITHUB_LIST_PULL_REQUESTS', args).catch(function(){
return window.rowboat.searchTools('github','list pull requests for a repository').then(function(tools){
var tool = pickPRTool(tools || []);
if (!tool) throw new Error('could not find a list-pull-requests tool');
return window.rowboat.callAction('github', tool.slug, args);
});
}).then(function(d){
prCache[k] = { loading:false, prs: normPRs(d) }; render();
}).catch(function(e){
prCache[k] = { loading:false, error: (e && e.message ? e.message : String(e)) }; render();
});
}
function fetchAll(){ if (!connected) return; loadProfile(); if (!repos) return; repos.forEach(function(rp){ var k=key(rp.owner,rp.repo); if (!prCache[k]) fetchPRs(rp.owner, rp.repo); }); }
// ---- authenticated user profile -------------------------------------------
function asObj(d){
if (d && typeof d === 'object'){
if (d.login) return d;
var ks = ['data','details','response_data','result','user'];
for (var i=0;i<ks.length;i++){ var v=d[ks[i]]; if (v && typeof v === 'object' && v.login) return v; }
}
return d || {};
}
function loadProfile(){
if (!connected || profile || (profileState && profileState.loading)) return;
profileState = { loading:true }; render();
window.rowboat.callAction('github','GITHUB_GET_THE_AUTHENTICATED_USER',{}).catch(function(){
return window.rowboat.searchTools('github','get the authenticated user').then(function(tools){
var pick = null;
for (var i=0;i<(tools||[]).length;i++){ if (/AUTHENTICATED_USER$/.test((tools[i].slug||'').toUpperCase())){ pick = tools[i]; break; } }
if (!pick && tools && tools.length) pick = tools[0];
if (!pick) throw new Error('no profile tool found');
return window.rowboat.callAction('github', pick.slug, {});
});
}).then(function(d){
var u = asObj(d);
profile = { login:u.login||'', name:u.name||u.login||'', avatar:u.avatar_url||'', bio:u.bio||'', repos:u.public_repos||0, followers:u.followers||0, following:u.following||0 };
profileState = { loading:false }; render();
}).catch(function(e){
profileState = { loading:false, error:(e && e.message ? e.message : String(e)) }; render();
});
}
// ---- connection -----------------------------------------------------------
function bannerHtml(){
if (connecting) return '<div class="banner"><span class="banner-text">Connecting GitHub — finish in your browser…</span><button class="banner-btn" disabled>Connecting…</button></div>';
if (connected === false) return '<div class="banner"><span class="banner-text">Connect GitHub to load pull requests.</span><button class="banner-btn" data-connect="1">Connect GitHub</button></div>';
if (connected === true) return '<div class="banner ok"><span class="banner-text">GitHub connected — pull requests are live.</span></div>';
return '';
}
function checkConn(){ window.rowboat.isConnected('github').then(function(c){ connected = c; render(); fetchAll(); }); }
function connect(){
connecting = true; render();
window.rowboat.connect('github').then(function(){
var tries = 0;
var iv = setInterval(function(){
tries++;
window.rowboat.isConnected('github').then(function(c){
if (c){ connected = true; connecting = false; clearInterval(iv); render(); flash('GitHub connected'); fetchAll(); }
else if (tries > 30){ connecting = false; clearInterval(iv); render(); }
});
}, 2000);
}).catch(function(e){ connecting = false; render(); flash('Connect failed: ' + (e && e.message ? e.message : e), true); });
}
// ---- repo management ------------------------------------------------------
function addRepo(){
var v = (repoInput || '').trim().replace(/^https?:\\/\\/github.com\\//i, '');
var m = v.match(/^([\\w.-]+)\\/([\\w.-]+)$/);
if (!m){ flash('Enter as owner/repo', true); return; }
var o = m[1], r = m[2];
if (repos.some(function(x){ return x.owner === o && x.repo === r; })){ flash('Already tracked'); return; }
repos.push({ owner:o, repo:r }); repoInput = ''; persist(); render();
if (connected) fetchPRs(o, r);
}
function removeRepo(o, r){
repos = repos.filter(function(x){ return !(x.owner === o && x.repo === r); });
delete prCache[key(o,r)]; persist(); render();
}
// ---- render ---------------------------------------------------------------
function prHtml(o, r, pr){
var k = key(o,r) + '#' + pr.number;
var open = !!expanded[k];
var body = open
? (pr.body ? '<div class="pr-body">' + esc(pr.body) + '</div>' : '<div class="pr-body empty">No description.</div>')
: '';
return '<div class="pr"><div class="pr-row" data-pr="' + esc(k) + '">'
+ '<span class="pr-icon">⌥</span><div class="pr-main">'
+ '<div class="pr-title">' + esc(pr.title) + '</div>'
+ '<div class="pr-sub">#' + esc(pr.number) + (pr.author ? ' · ' + esc(pr.author) : '') + '</div>'
+ '</div></div>' + body + '</div>';
}
function repoHtml(rp){
var k = key(rp.owner, rp.repo);
var c = prCache[k];
var inner;
if (!connected) inner = '<div class="state">Connect GitHub to load PRs.</div>';
else if (!c || c.loading) inner = '<div class="state">Loading pull requests…</div>';
else if (c.error) inner = '<div class="state err">' + esc(c.error) + '</div>';
else if (!c.prs.length) inner = '<div class="empty-state">No open pull requests 🎉</div>';
else inner = c.prs.map(function(pr){ return prHtml(rp.owner, rp.repo, pr); }).join('');
var count = (c && c.prs) ? '<span class="repo-count">' + c.prs.length + ' open</span>' : '';
return '<div class="repo"><div class="repo-head">'
+ '<span class="repo-name">' + esc(rp.owner) + '/' + esc(rp.repo) + '</span>'
+ '<span class="repo-actions">' + count
+ '<button class="icon-btn" data-refresh="' + esc(k) + '">↻</button>'
+ '<button class="icon-btn" data-remove="' + esc(k) + '">✕</button>'
+ '</span></div>' + inner + '</div>';
}
function profileHtml(){
if (!connected) return '';
if (!profile) return (profileState && profileState.loading) ? '<div class="state">Loading profile…</div>' : '';
var initials = (profile.name || profile.login || '?').slice(0,1).toUpperCase();
var img = profile.avatar ? '<img class="avatar" src="' + esc(profile.avatar) + '" alt="" />' : '';
var fbStyle = profile.avatar ? '' : ' style="display:flex"';
return '<div class="profile">' + img
+ '<div id="avfb" class="avatar-fb"' + fbStyle + '>' + esc(initials) + '</div>'
+ '<div><div class="pname">' + esc(profile.name) + '</div><div class="plogin">@' + esc(profile.login) + '</div>'
+ (profile.bio ? '<div class="pbio">' + esc(profile.bio) + '</div>' : '')
+ '<div class="pstats"><span><b>' + profile.repos + '</b> repos</span><span><b>' + profile.followers + '</b> followers</span><span><b>' + profile.following + '</b> following</span></div>'
+ '</div></div>'
+ '<div class="contrib"><div class="contrib-title">Contributions</div>'
+ '<img class="contrib-img" src="https://ghchart.rshah.org/' + encodeURIComponent(profile.login) + '" alt="contributions" />'
+ '<div id="contribnote" class="contrib-note" style="display:none">Contribution graph could not load.</div>'
+ '</div>';
}
function render(){
var list = repos
? (repos.length ? repos.map(repoHtml).join('') : '<div class="empty-state">No repos yet — add one above.</div>')
: '<div class="loading">Loading…</div>';
root.innerHTML = '<div class="app"><div class="wrap">'
+ '<h1 class="h">GitHub Dashboard</h1><p class="sub">Track repos and read their open pull requests.</p>'
+ bannerHtml()
+ profileHtml()
+ '<div class="adder"><input id="repo-input" placeholder="owner/repo (e.g. vercel/next.js)" /><button data-add="1">Add</button></div>'
+ list
+ '</div><div class="toast" id="toast"></div></div>';
var el = document.getElementById('repo-input');
if (el) el.value = repoInput;
var av = root.querySelector('.avatar');
if (av) av.onerror = function(){ this.style.display='none'; var f=document.getElementById('avfb'); if (f) f.style.display='flex'; };
var ci = root.querySelector('.contrib-img');
if (ci) ci.onerror = function(){ this.style.display='none'; var n=document.getElementById('contribnote'); if (n) n.style.display='block'; };
}
function flash(msg, isErr){
var el = document.getElementById('toast'); if (!el) return;
el.textContent = msg; el.className = 'toast show' + (isErr ? ' err' : '');
if (toastTimer) clearTimeout(toastTimer);
toastTimer = setTimeout(function(){ el.className = 'toast' + (isErr ? ' err' : ''); }, 2400);
}
// ---- events ---------------------------------------------------------------
root.addEventListener('input', function(e){ if (e.target && e.target.id === 'repo-input') repoInput = e.target.value; });
root.addEventListener('keydown', function(e){ if (e.target && e.target.id === 'repo-input' && e.key === 'Enter') addRepo(); });
root.addEventListener('click', function(e){
var t = e.target;
if (!t || !t.closest) return;
if (t.closest('[data-connect]')) { connect(); return; }
if (t.closest('[data-add]')) { addRepo(); return; }
var rm = t.closest('[data-remove]');
if (rm) { var p = rm.getAttribute('data-remove').split('/'); removeRepo(p[0], p[1]); return; }
var rf = t.closest('[data-refresh]');
if (rf) { var q = rf.getAttribute('data-refresh').split('/'); fetchPRs(q[0], q[1]); return; }
var pr = t.closest('[data-pr]');
if (pr) { var pk = pr.getAttribute('data-pr'); expanded[pk] = !expanded[pk]; render(); return; }
});
window.rowboat.onData(function(d){ seed = (d && d.seedRepos) || []; if (repos === null) { repos = seed.slice(); render(); } });
window.rowboat.onState(function(st){
if (st && st.repos) { repos = st.repos; render(); fetchAll(); }
else if (repos === null) { repos = seed.slice(); render(); }
});
render();
window.rowboat.ready();
checkConn();
`
export const githubRadarApp: MiniApp = {
id: 'github-radar',
name: 'GitHub Dashboard',
description: 'Track repos and read their open pull requests — live via GitHub.',
source: 'GitHub',
active: true,
lastRun: 'just now',
scope: ['github'],
data,
html: buildMiniAppHtml({ title: 'GitHub Dashboard', style, script }),
}

View file

@ -1,43 +0,0 @@
// Lightweight Mini App template: a clean single-column list of items.
//
// Used for simple "digest"-style apps (newsletter summaries, competitor updates)
// so the gallery shows the card design across multiple apps. Same dependency-free
// vanilla approach as the Twitter client.
import { buildMiniAppHtml } from '../runtime'
export type ListItem = { id: string; title: string; meta: string; body: string }
const style = `
.app { height:100%; overflow:auto; background:#0a0a0b; color:#e7e9ea; }
.wrap { max-width:640px; margin:0 auto; padding:24px 16px; }
.h { font-size:22px; font-weight:600; letter-spacing:-0.02em; margin:0 0 4px; color:#f5f5f5; }
.sub { font-size:13px; color:#71767b; margin:0 0 20px; }
.item { border:1px solid rgba(255,255,255,0.07); background:#141417; border-radius:14px; padding:16px; margin-bottom:12px; }
.it-top { display:flex; justify-content:space-between; align-items:baseline; gap:12px; }
.it-title { font-size:15px; font-weight:600; }
.it-meta { font-size:12px; color:#71767b; flex:0 0 auto; }
.it-body { font-size:14px; line-height:1.5; color:rgba(255,255,255,0.7); margin:6px 0 0; }
.loading { padding:48px 16px; text-align:center; color:#71767b; }
`
const script = `
var root = document.getElementById('root');
var current = null;
function esc(s){ return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }
function render(){
if(!current){ root.innerHTML = '<div class="app"><div class="loading">Loading…</div></div>'; return; }
var items = (current.items||[]).map(function(it){
return '<div class="item"><div class="it-top"><div class="it-title">'+esc(it.title)+'</div><div class="it-meta">'+esc(it.meta)+'</div></div>'
+ '<p class="it-body">'+esc(it.body)+'</p></div>';
}).join('');
root.innerHTML = '<div class="app"><div class="wrap"><h1 class="h">'+esc(current.title)+'</h1><p class="sub">'+esc(current.subtitle)+'</p>'+items+'</div></div>';
}
window.rowboat.onData(function(d){ current = d; render(); });
render();
window.rowboat.ready();
`
export function buildSimpleListHtml(title: string): string {
return buildMiniAppHtml({ title, style, script })
}

View file

@ -1,240 +0,0 @@
// Sample Mini App: a Twitter/X client.
//
// An X-style single-column timeline of important posts the agent has curated,
// with simple topic chips to filter what you see. Phase 1 ships static `data`
// and stubbed actions; later the bg-tasks engine produces the feed on a trigger
// and the bridge runs real Composio actions.
//
// Dependency-free vanilla JS + CSS so it renders reliably in the sandboxed
// iframe with no network. window.rowboat is the only channel to the host;
// selected topics persist via rowboat.setState.
import { buildMiniAppHtml } from '../runtime'
import type { MiniApp } from '../types'
const data = {
handle: '@you',
topics: ['AI', 'Startups', 'Dev', 'Design'],
posts: [
{
id: 't1',
author: 'Andrej Karpathy',
handle: '@karpathy',
avatar: '🧠',
time: '2h',
topics: ['AI', 'Dev'],
text: 'The hottest new programming language is English. Spend your time crafting the prompt, not the syntax.',
likes: 1240,
reposts: 312,
},
{
id: 't2',
author: 'Vercel',
handle: '@vercel',
avatar: '▲',
time: '5h',
topics: ['Dev'],
text: 'Shipping is a feature. We just cut cold starts by another 40% on the edge runtime.',
likes: 842,
reposts: 96,
},
{
id: 't3',
author: 'Indie Hackers',
handle: '@IndieHackers',
avatar: '🚀',
time: '1h',
topics: ['Startups'],
text: 'You do not need 1,000 true fans. You need 100 who will tell 10 friends each. Build for the tellers.',
likes: 503,
reposts: 121,
},
{
id: 't4',
author: 'Sarah Dev',
handle: '@sarah_builds',
avatar: '👩‍💻',
time: '32m',
topics: ['AI', 'Startups'],
text: 'Anyone using local-first AI desktop apps day to day? Curious what actually sticks vs. demo-ware.',
likes: 88,
reposts: 7,
},
{
id: 't5',
author: 'Design Notes',
handle: '@designnotes',
avatar: '🎨',
time: '3h',
topics: ['Design'],
text: 'Good defaults beat good settings. Every preference you add is a decision you forced on the user.',
likes: 967,
reposts: 204,
},
{
id: 't6',
author: 'Founder Diary',
handle: '@founderdiary',
avatar: '📈',
time: '6h',
topics: ['Startups', 'Design'],
text: 'Talked to 12 users this week. Shipped 0 features. Best week in a month.',
likes: 1502,
reposts: 388,
},
],
}
const style = `
.app { height:100%; overflow:auto; background:#000; color:#e7e9ea; }
.feed { max-width:600px; margin:0 auto; border-left:1px solid #2f3336; border-right:1px solid #2f3336; min-height:100%; }
.header { position:sticky; top:0; backdrop-filter:blur(12px); background:rgba(0,0,0,.65); border-bottom:1px solid #2f3336; padding:12px 16px; z-index:2; }
.h-title { font-size:20px; font-weight:800; margin:0 0 10px; }
.chips { display:flex; gap:8px; flex-wrap:wrap; }
.chip { border:1px solid #536471; background:transparent; color:#e7e9ea; border-radius:999px; padding:5px 14px; font-size:13px; font-weight:600; cursor:pointer; }
.chip.on { background:#1d9bf0; border-color:#1d9bf0; color:#fff; }
.post { display:flex; gap:12px; padding:12px 16px; border-bottom:1px solid #2f3336; }
.post:hover { background:#080808; }
.avatar { width:40px; height:40px; border-radius:50%; background:#16181c; display:flex; align-items:center; justify-content:center; font-size:18px; flex:0 0 auto; }
.pbody { flex:1; min-width:0; }
.line { display:flex; align-items:center; gap:4px; font-size:15px; }
.name { font-weight:700; }
.handle, .dot, .time { color:#71767b; font-weight:400; }
.text { font-size:15px; line-height:1.4; margin:2px 0 0; white-space:pre-wrap; word-wrap:break-word; }
.tags { margin-top:6px; display:flex; gap:8px; flex-wrap:wrap; }
.tag { font-size:12px; color:#1d9bf0; }
.actions { display:flex; justify-content:space-between; max-width:300px; margin-top:8px; }
.action { display:flex; align-items:center; gap:6px; background:none; border:0; color:#71767b; font-size:13px; cursor:pointer; padding:4px; border-radius:999px; }
.action:hover { color:#1d9bf0; }
.action.like.on { color:#f91880; }
.action.repost.on { color:#00ba7c; }
.empty { padding:48px 16px; text-align:center; color:#71767b; font-size:15px; }
.banner { display:flex; align-items:center; justify-content:space-between; gap:12px; margin:14px 16px 0; padding:12px 14px; border:1px solid #1d9bf0; background:rgba(29,155,240,0.12); border-radius:12px; }
.banner-text { font-size:13px; color:#e7e9ea; }
.banner-btn { background:#1d9bf0; color:#fff; border:0; border-radius:999px; padding:7px 16px; font-size:13px; font-weight:700; cursor:pointer; }
.banner-btn:disabled { opacity:.6; cursor:default; }
.toast { position:fixed; bottom:20px; left:50%; transform:translateX(-50%); background:#1d9bf0; color:#fff; font-size:14px; font-weight:600; padding:10px 18px; border-radius:999px; opacity:0; transition:opacity .2s; pointer-events:none; }
.toast.show { opacity:1; }
.loading { padding:48px 16px; text-align:center; color:#71767b; font-size:15px; }
`
// Vanilla JS. No backticks (so it embeds cleanly); window.rowboat is the bridge.
const script = `
var root = document.getElementById('root');
var current = null;
var selected = null; // selected topic names
var liked = {}; // id -> bool
var reposted = {}; // id -> bool
var toastTimer = null;
function esc(s) {
return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
function fmt(n) {
return n >= 1000 ? (n / 1000).toFixed(1).replace('.0', '') + 'K' : String(n);
}
function persist() {
window.rowboat.setState({ topics: selected, liked: liked, reposted: reposted });
}
// X/Twitter has no Composio managed-OAuth2 flow, so this sample stays a local UI
// demo (see GitHub Radar for the real connect -> execute bridge).
function act(action, id) {
if (action === 'reply') { flash('Reply drafted (demo)'); return; }
if (action === 'like') liked[id] = !liked[id];
if (action === 'repost') reposted[id] = !reposted[id];
persist(); render();
if (action === 'repost') flash(reposted[id] ? 'Reposted (demo)' : 'Removed');
if (action === 'like') flash(liked[id] ? 'Liked (demo)' : 'Unliked');
}
function postHtml(p) {
var likeOn = liked[p.id] ? ' on' : '';
var repoOn = reposted[p.id] ? ' on' : '';
var likeCt = p.likes + (liked[p.id] ? 1 : 0);
var repoCt = p.reposts + (reposted[p.id] ? 1 : 0);
var tags = (p.topics || []).map(function (t) { return '<span class="tag">#' + esc(t) + '</span>'; }).join('');
return '<div class="post">'
+ '<div class="avatar">' + esc(p.avatar) + '</div>'
+ '<div class="pbody">'
+ '<div class="line"><span class="name">' + esc(p.author) + '</span>'
+ '<span class="handle">' + esc(p.handle) + '</span><span class="dot">·</span><span class="time">' + esc(p.time) + '</span></div>'
+ '<p class="text">' + esc(p.text) + '</p>'
+ (tags ? '<div class="tags">' + tags + '</div>' : '')
+ '<div class="actions">'
+ '<button class="action reply" data-action="reply" data-id="' + p.id + '">💬</button>'
+ '<button class="action repost' + repoOn + '" data-action="repost" data-id="' + p.id + '">🔁 <span class="ct">' + fmt(repoCt) + '</span></button>'
+ '<button class="action like' + likeOn + '" data-action="like" data-id="' + p.id + '">' + (liked[p.id] ? '♥' : '♡') + ' <span class="ct">' + fmt(likeCt) + '</span></button>'
+ '</div></div></div>';
}
function render() {
if (!current) { root.innerHTML = '<div class="app"><div class="feed"><div class="loading">Loading your feed…</div></div></div>'; return; }
var chips = current.topics.map(function (t) {
var on = selected.indexOf(t) >= 0 ? ' on' : '';
return '<button class="chip' + on + '" data-topic="' + esc(t) + '">' + esc(t) + '</button>';
}).join('');
var visible = current.posts.filter(function (p) {
return (p.topics || []).some(function (t) { return selected.indexOf(t) >= 0; });
});
var body = visible.length
? visible.map(postHtml).join('')
: '<div class="empty">No posts. Pick a topic above to see what is happening.</div>';
root.innerHTML = '<div class="app"><div class="feed">'
+ '<div class="header"><h1 class="h-title">Home</h1><div class="chips">' + chips + '</div></div>'
+ body
+ '</div><div class="toast" id="toast"></div></div>';
}
function flash(msg) {
var el = document.getElementById('toast');
if (!el) return;
el.textContent = msg;
el.className = 'toast show';
if (toastTimer) clearTimeout(toastTimer);
toastTimer = setTimeout(function () { el.className = 'toast'; }, 2000);
}
root.addEventListener('click', function (e) {
var t = e.target;
var chip = t && t.closest ? t.closest('.chip') : null;
if (chip) {
var topic = chip.getAttribute('data-topic');
var i = selected.indexOf(topic);
if (i >= 0) selected.splice(i, 1); else selected.push(topic);
persist();
render();
return;
}
var btn = t && t.closest ? t.closest('.action') : null;
if (!btn) return;
act(btn.getAttribute('data-action'), btn.getAttribute('data-id'));
});
window.rowboat.onData(function (d) {
current = d;
if (selected === null) selected = d.topics.slice();
render();
});
window.rowboat.onState(function (st) {
if (!st) return;
if (st.topics) selected = st.topics;
if (st.liked) liked = st.liked;
if (st.reposted) reposted = st.reposted;
if (current) render();
});
render();
window.rowboat.ready();
`
export const twitterClientApp: MiniApp = {
id: 'twitter-client',
name: 'Twitter, curated',
description: 'An X-style feed of the posts that matter, filtered by your topics.',
source: 'Twitter',
active: true,
lastRun: '2m ago',
scope: ['twitter'],
data,
html: buildMiniAppHtml({ title: 'Twitter, curated', style, script }),
}

View file

@ -1,15 +0,0 @@
// Mini Apps registry.
//
// Phase 1: apps are hardcoded here. Later phases replace this with apps loaded
// from ~/.rowboat/apps/<id>/ over IPC.
import type { MiniApp } from './types'
import { githubRadarApp } from './apps/github-radar'
import { twitterClientApp } from './apps/twitter-client'
import { newsletterDigestApp, competitorWatchApp } from './apps/digests'
export const MINI_APPS: MiniApp[] = [githubRadarApp, twitterClientApp, newsletterDigestApp, competitorWatchApp]
export function getMiniApp(id: string): MiniApp | undefined {
return MINI_APPS.find((app) => app.id === id)
}

View file

@ -19,4 +19,5 @@ export * as browserControl from './browser-control.js';
export * as billing from './billing.js';
export * as notificationSettings from './notification-settings.js';
export * as codeSessions from './code-sessions.js';
export * as miniApp from './mini-app.js';
export { PrefixLogger };

View file

@ -16,6 +16,7 @@ import {
import { UserMessageContent } from './message.js';
import { RowboatApiConfig } from './rowboat-account.js';
import { ZListToolkitsResponse } from './composio.js';
import { MiniAppManifest } from './mini-app.js';
import { BrowserStateSchema } from './browser-control.js';
import { BillingInfoSchema } from './billing.js';
import { EmailBlockSchema, GmailThreadSchema } from './blocks.js';
@ -1007,6 +1008,31 @@ const ipcSchemas = {
shouldShow: z.boolean(),
}),
},
// Mini Apps: seed built-in apps to ~/.rowboat/apps/<id>/ (idempotent).
'mini-apps:seed': {
req: z.object({
apps: z.array(z.object({
manifest: MiniAppManifest,
html: z.string(),
data: z.unknown().optional(),
})),
}),
res: z.object({
seeded: z.array(z.string()),
}),
},
// Mini Apps: list installed app manifests from ~/.rowboat/apps/.
'mini-apps:list': {
req: z.null(),
res: z.object({
manifests: z.array(MiniAppManifest),
}),
},
// Mini Apps: read an app's latest data.json (agent output).
'mini-apps:get-data': {
req: z.object({ id: z.string() }),
res: z.object({ data: z.unknown().nullable() }),
},
'composio:didConnect': {
req: z.object({
toolkitSlug: z.string(),

View file

@ -0,0 +1,31 @@
import { z } from 'zod';
/**
* Manifest for a Mini App stored at ~/.rowboat/apps/<id>/manifest.json.
*
* Static assets are served from `<id>/dist/` via app://miniapp/<id>/. The
* optional `agent` links a background task (bg-tasks engine) that writes
* `<id>/data.json`, which the host reads and pushes to the app.
*/
export const MiniAppManifest = z.object({
/** Stable slug; also the on-disk folder name and the app:// host path. */
id: z.string(),
/** Display name shown on the card and in the open view. */
title: z.string(),
/** One-line description for the card. */
description: z.string().default(''),
/** Primary integration shown in the card footer pill (e.g. 'GitHub'). */
source: z.string().default(''),
/** Composio toolkits this app may use; enforced host-side on bridge calls. */
scope: z.array(z.string()).default([]),
/** Whether the app's agent is active (drives the status badge). */
active: z.boolean().default(true),
/** Human last-run label for the card footer (e.g. '2m ago'). */
lastRun: z.string().default(''),
/** Entry file within the app folder, served via app://miniapp/<id>/. */
entry: z.string().default('dist/index.html'),
/** Optional associated background-task slug that produces data.json. */
agent: z.string().optional(),
});
export type MiniAppManifest = z.infer<typeof MiniAppManifest>;