From 826bce90ad1aad88357ff47c429b4b5bfdfec347 Mon Sep 17 00:00:00 2001 From: arkml <6592213+arkml@users.noreply.github.com> Date: Thu, 16 Jul 2026 18:43:10 +0530 Subject: [PATCH] feat(x): apps catalog cards, sidebar app pins, empty-state catalog landing (#759) Apps section improvements: - Landing with no apps of your own now falls through to the Catalog tab with a banner pointing at installing from the catalog or building your own (links into the copilot app-builder flow). - Catalog renders as a card grid matching the My Apps visual language (shared card-theme helpers) instead of one-line rows, with star button, INSTALLED badge, and Install/Open actions on each card. - Apps can be pinned to the nav sidebar: right-click an app card to add/remove; pinned rows sit under the Apps nav item, click opens the app, right-click removes. Persisted in localStorage (x:pinned-apps). - My Apps shows a hint that right-click pins an app to the sidebar. - Uninstall/delete unpins the app, and the Apps view prunes pins for apps that no longer exist (only off an authoritative server list). GitHub stars rate-limit backoff (packages/core/src/apps/stars.ts): once GitHub reports the budget spent (403/429 + x-ratelimit-remaining: 0), gate all star fetches until the advertised reset, serving stale cached counts instead of burning requests on guaranteed 403s. Co-authored-by: Claude Fable 5 --- apps/x/apps/renderer/src/App.tsx | 1 + .../src/components/apps/app-detail.tsx | 3 + .../src/components/apps/apps-view.tsx | 163 ++++++++++++------ .../src/components/apps/card-theme.ts | 23 +++ .../renderer/src/components/apps/catalog.tsx | 80 +++++---- .../src/components/sidebar-content.tsx | 51 ++++++ apps/x/apps/renderer/src/lib/pinned-apps.ts | 39 +++++ apps/x/packages/core/src/apps/stars.ts | 33 +++- 8 files changed, 304 insertions(+), 89 deletions(-) create mode 100644 apps/x/apps/renderer/src/components/apps/card-theme.ts create mode 100644 apps/x/apps/renderer/src/lib/pinned-apps.ts diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index 9d942607..ca3971f5 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -6284,6 +6284,7 @@ function App() { onOpenBgTasks={() => { setBgTaskInitialSlug(null); setBgTaskSlugVersion((v) => v + 1); openBgTasksView() }} onOpenAgent={(slug) => { setBgTaskInitialSlug(slug); setBgTaskSlugVersion((v) => v + 1); openBgTasksView() }} onOpenApps={openAppsView} + onOpenApp={(folder) => { setAppInitialId(folder); setAppIdVersion((v) => v + 1); openAppsView() }} recentRuns={runs} onOpenRun={(rid) => void navigateToView({ type: 'chat', runId: rid })} onRenameRun={(rid, title) => { diff --git a/apps/x/apps/renderer/src/components/apps/app-detail.tsx b/apps/x/apps/renderer/src/components/apps/app-detail.tsx index a3755c13..0a3c3a91 100644 --- a/apps/x/apps/renderer/src/components/apps/app-detail.tsx +++ b/apps/x/apps/renderer/src/components/apps/app-detail.tsx @@ -2,6 +2,7 @@ import { useEffect, useState } from 'react' import { X, RotateCcw, Play, UploadCloud, ArrowUpCircle, Trash2 } from 'lucide-react' import type { rowboatApp } from '@x/shared' import { PublishDialog } from '@/components/apps/publish-dialog' +import { unpinApp } from '@/lib/pinned-apps' import * as analytics from '@/lib/analytics' // App detail panel (spec §14): manifest info, provenance/publish state, @@ -79,6 +80,7 @@ export function AppDetail({ folder, onClose }: { folder: string; onClose: () => 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 }) + unpinApp(folder) onClose() }) @@ -88,6 +90,7 @@ export function AppDetail({ folder, onClose }: { folder: string; onClose: () => const publishNote = app?.publish ? '\n\nThe published copy (GitHub repo + catalog listing) is not touched.' : '' if (!window.confirm(`Delete this app? The whole folder, including data/, is removed from this machine.${publishNote}${agentNote}`)) return await window.ipc.invoke('apps:delete', { folder }) + unpinApp(folder) onClose() }) diff --git a/apps/x/apps/renderer/src/components/apps/apps-view.tsx b/apps/x/apps/renderer/src/components/apps/apps-view.tsx index 04a01bce..b4833d1c 100644 --- a/apps/x/apps/renderer/src/components/apps/apps-view.tsx +++ b/apps/x/apps/renderer/src/components/apps/apps-view.tsx @@ -1,32 +1,20 @@ import { useEffect, useState } from 'react' -import { Plus, RefreshCw } from 'lucide-react' +import { PanelLeft, PanelLeftClose, Plus, RefreshCw } from 'lucide-react' import type { rowboatApp } from '@x/shared' import { AppFrame } from '@/components/apps/app-frame' import { CatalogTab } from '@/components/apps/catalog' +import { themeForIndex, patternFor } from '@/components/apps/card-theme' +import { getPinnedApps, onPinnedAppsChanged, pinApp, unpinApp } from '@/lib/pinned-apps' +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuTrigger, +} from '@/components/ui/context-menu' // Apps home (spec §14): "My apps" grid + Catalog placeholder (M3). Cards are // AppSummary-driven; click opens the app full-height on its own origin. -type Theme = { accent: string; glow: string } - -const THEMES: Theme[] = [ - { accent: '#FF4D8D', glow: 'rgba(255,77,141,0.45)' }, // Pink - { accent: '#EF4444', glow: 'rgba(239,68,68,0.45)' }, // Red - { accent: '#22C55E', glow: 'rgba(34,197,94,0.40)' }, // Emerald - { accent: '#F59E0B', glow: 'rgba(245,158,11,0.42)' }, // Amber - { accent: '#14B8A6', glow: 'rgba(20,184,166,0.40)' }, // Teal - { accent: '#EC4899', glow: 'rgba(236,72,153,0.42)' }, // Rose -] -const PATTERNS = ['dots', 'grid', 'diagonal', 'radial', 'waves', 'mesh', 'cross', 'rings', 'zigzag', 'plus', 'checker', 'beams'] - -function hash(s: string): number { - let h = 0 - for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) | 0 - return Math.abs(h) -} -const themeForIndex = (i: number): Theme => THEMES[i % THEMES.length] -const patternFor = (id: string): string => PATTERNS[hash(id + '·pat') % PATTERNS.length] - const CARD_CSS = ` .ma-page { container-type: inline-size; @@ -61,6 +49,14 @@ const CARD_CSS = ` .ma-inner { max-width:1120px; margin:0 auto; padding:34px 30px 48px; } .ma-h1 { font-size:24px; font-weight:650; letter-spacing:-0.02em; color:var(--ma-h1); margin:0 0 4px; } .ma-sub { font-size:clamp(13px,1.5cqw,14px); color:var(--ma-sub); margin:0 0 clamp(14px,2cqw,20px); } +.ma-hint { font-size:12.5px; color:var(--ma-sub); margin:-6px 0 clamp(12px,1.8cqw,16px); } +.ma-welcome { + border:1px solid var(--ma-border); border-radius:12px; padding:12px 16px; + font-size:13.5px; color:var(--ma-desc); margin-bottom:clamp(14px,2cqw,20px); + background:color-mix(in srgb, var(--ma-title) 4%, transparent); +} +.ma-welcome button { color:var(--ma-title); font-weight:600; text-decoration:underline; text-underline-offset:3px; background:none; border:none; padding:0; font-size:inherit; cursor:pointer; } +.ma-owner { font-size:12px; color:var(--ma-sub); margin:-4px 0 8px; } .ma-tabs { display:flex; gap:6px; margin-bottom:clamp(14px,2cqw,22px); } .ma-tab { border:1px solid var(--ma-border); background:transparent; color:var(--ma-sub); border-radius:999px; padding:5px 14px; font-size:13px; font-weight:600; cursor:pointer; } .ma-tab.on { color:var(--ma-title); border-color:var(--ma-border-hover); background:color-mix(in srgb, var(--ma-title) 6%, transparent); } @@ -134,31 +130,47 @@ const CARD_CSS = ` } ` -function Card({ app, index, onOpen }: { app: rowboatApp.AppSummary; index: number; onOpen: () => void }) { +function Card({ app, index, onOpen, isPinned, onTogglePin }: { + app: rowboatApp.AppSummary + index: number + onOpen: () => void + isPinned: boolean + onTogglePin: () => void +}) { const theme = themeForIndex(index) const pattern = patternFor(app.folder) const invalid = app.status === 'invalid' return ( - + + + + + + + {isPinned ? : } + {isPinned ? 'Remove from sidebar' : 'Add to sidebar'} + + + ) } @@ -167,10 +179,16 @@ export function AppsView({ initialAppFolder, initialVersion, onNewApp }: { initialVersion?: number onNewApp?: () => void } = {}) { - const [tab, setTab] = useState<'mine' | 'catalog'>('mine') + // null = auto: land on "My apps" normally, but fall through to the catalog + // until the user has an app of their own. An explicit tab click wins. + const [tab, setTab] = useState<'mine' | 'catalog' | null>(null) const [selectedFolder, setSelectedFolder] = useState(initialAppFolder ?? null) const [apps, setApps] = useState([]) + const [appsLoaded, setAppsLoaded] = useState(false) const [serverError, setServerError] = useState(null) + const [pinnedFolders, setPinnedFolders] = useState(() => getPinnedApps()) + + useEffect(() => onPinnedAppsChanged(setPinnedFolders), []) // Open a specific app when asked from outside (app-navigation open-app). const [appliedVersion, setAppliedVersion] = useState(initialVersion) @@ -186,11 +204,19 @@ export function AppsView({ initialAppFolder, initialVersion, onNewApp }: { const r = await window.ipc.invoke('apps:list', {}) if (cancelled) return setApps(r.apps) + setAppsLoaded(true) // Drop a selection whose app no longer exists (uninstalled while // open). Left stale, a later reinstall makes this view yank the user // into the app frame mid-flow — e.g. while they're in the catalog's // post-install agent dialog. setSelectedFolder((cur) => (cur && !r.apps.some((a) => a.folder === cur) ? null : cur)) + // Prune sidebar pins for apps that no longer exist (uninstalled via + // the copilot or another window) — but only off an authoritative + // list, never while the apps server is down. + if (r.serverRunning) { + const live = new Set(r.apps.map((a) => a.folder)) + for (const f of getPinnedApps()) if (!live.has(f)) unpinApp(f) + } setServerError(r.serverRunning ? null : (r.serverError ?? 'Apps server is not running.')) } catch (e) { if (!cancelled) setServerError(e instanceof Error ? e.message : String(e)) @@ -206,6 +232,9 @@ export function AppsView({ initialAppFolder, initialVersion, onNewApp }: { return setSelectedFolder(null)} /> } + const noOwnApps = appsLoaded && apps.length === 0 + const activeTab = tab ?? (noOwnApps ? 'catalog' : 'mine') + return (
@@ -214,8 +243,8 @@ export function AppsView({ initialAppFolder, initialVersion, onNewApp }: {

Apps that live inside Rowboat, powered by your agents and integrations.

- - + +
{serverError && ( @@ -224,19 +253,39 @@ export function AppsView({ initialAppFolder, initialVersion, onNewApp }: {
)} - {tab === 'catalog' ? ( - { setSelectedFolder(folder); setTab('mine') }} /> + {!appsLoaded && !serverError ? null : activeTab === 'catalog' ? ( + <> + {noOwnApps && ( +
+ You don't have any apps of your own yet. Install one from this catalog, or{' '} + . +
+ )} + { setSelectedFolder(folder); setTab('mine') }} /> + ) : ( -
- {apps.map((app, i) => ( - setSelectedFolder(app.folder)} /> - ))} - -
+ <> + {apps.length > 0 && ( +

Tip: right-click an app to add it to the sidebar for quick access.

+ )} +
+ {apps.map((app, i) => ( + setSelectedFolder(app.folder)} + isPinned={pinnedFolders.includes(app.folder)} + onTogglePin={() => (pinnedFolders.includes(app.folder) ? unpinApp(app.folder) : pinApp(app.folder))} + /> + ))} + +
+ )} diff --git a/apps/x/apps/renderer/src/components/apps/card-theme.ts b/apps/x/apps/renderer/src/components/apps/card-theme.ts new file mode 100644 index 00000000..38da6d8f --- /dev/null +++ b/apps/x/apps/renderer/src/components/apps/card-theme.ts @@ -0,0 +1,23 @@ +// Deterministic accent/pattern assignment shared by the "My apps" grid and +// the catalog grid, so both render the same card visual language. + +export type CardTheme = { accent: string; glow: string } + +const THEMES: CardTheme[] = [ + { accent: '#FF4D8D', glow: 'rgba(255,77,141,0.45)' }, // Pink + { accent: '#EF4444', glow: 'rgba(239,68,68,0.45)' }, // Red + { accent: '#22C55E', glow: 'rgba(34,197,94,0.40)' }, // Emerald + { accent: '#F59E0B', glow: 'rgba(245,158,11,0.42)' }, // Amber + { accent: '#14B8A6', glow: 'rgba(20,184,166,0.40)' }, // Teal + { accent: '#EC4899', glow: 'rgba(236,72,153,0.42)' }, // Rose +] +const PATTERNS = ['dots', 'grid', 'diagonal', 'radial', 'waves', 'mesh', 'cross', 'rings', 'zigzag', 'plus', 'checker', 'beams'] + +function hash(s: string): number { + let h = 0 + for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) | 0 + return Math.abs(h) +} + +export const themeForIndex = (i: number): CardTheme => THEMES[i % THEMES.length] +export const patternFor = (id: string): string => PATTERNS[hash(id + '·pat') % PATTERNS.length] diff --git a/apps/x/apps/renderer/src/components/apps/catalog.tsx b/apps/x/apps/renderer/src/components/apps/catalog.tsx index 3741f56a..d0cc6464 100644 --- a/apps/x/apps/renderer/src/components/apps/catalog.tsx +++ b/apps/x/apps/renderer/src/components/apps/catalog.tsx @@ -1,6 +1,7 @@ import { useEffect, useState } from 'react' import { BadgeCheck, Bot, Download, Link2, RefreshCw, Search, ShieldAlert, Star } from 'lucide-react' import type { rowboatApp } from '@x/shared' +import { themeForIndex, patternFor } from '@/components/apps/card-theme' // Catalog tab (spec §14): search the registry, install with the D18 capability // disclosure, install from a direct bundle URL. @@ -338,37 +339,58 @@ export function CatalogTab({ onInstalled }: { onInstalled: (folder: string) => v {query ? 'No apps match your search.' : 'No apps in the catalog yet — be the first to publish one.'} ) : ( -
- {ranked.map((r) => ( -
-
-
- {r.name} - by {r.owner} +
+ {ranked.map((r, i) => { + const theme = themeForIndex(i) + const installedFolder = installedByName.get(r.name) + return ( +
installedFolder ? onInstalled(installedFolder) : void startInstall(r.name)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + if (installedFolder) onInstalled(installedFolder) + else void startInstall(r.name) + } + }} + className={`ma-card ma-pat-${patternFor(r.name)}`} + style={{ '--accent': theme.accent, '--glow': theme.glow } as React.CSSProperties} + > +
+ {installedFolder && INSTALLED} + +
+
{r.name}
+
by {r.owner}
+
{r.description || 'No description.'}
+
+ {r.repo} + {installedFolder ? ( + + ) : ( + + )}
-

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

- - {installedByName.has(r.name) ? ( - - ) : ( - - )} -
- ))} + ) + })}
)} diff --git a/apps/x/apps/renderer/src/components/sidebar-content.tsx b/apps/x/apps/renderer/src/components/sidebar-content.tsx index c8697205..8dfd79de 100644 --- a/apps/x/apps/renderer/src/components/sidebar-content.tsx +++ b/apps/x/apps/renderer/src/components/sidebar-content.tsx @@ -3,6 +3,7 @@ import * as React from "react" import { useCallback, useEffect, useRef, useState } from "react" import { + AppWindow, ArrowUpRight, Bot, ChevronRight, @@ -16,6 +17,7 @@ import { LayoutGrid, Mic, MoreVertical, + PanelLeftClose, Pencil, Pin, SquarePen, @@ -71,7 +73,14 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu" +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuTrigger, +} from "@/components/ui/context-menu" import { cn } from "@/lib/utils" +import { getPinnedApps, onPinnedAppsChanged, unpinApp } from "@/lib/pinned-apps" import { isOutOfCredits, CREDIT_EXHAUSTED_EVENT, CREDIT_REPLENISHED_EVENT } from "@/lib/credit-status" import { SettingsDialog } from "@/components/settings-dialog" import { MascotFaceIcon } from "@/components/talking-head" @@ -180,6 +189,8 @@ type SidebarContentPanelProps = { onOpenCode?: () => void onOpenBgTasks?: () => void onOpenApps?: () => void + /** Open a specific app (pinned in the sidebar) inside the Apps view. */ + onOpenApp?: (folder: string) => void onOpenAgent?: (slug: string) => void recentRuns?: { id: string; title?: string; createdAt: string; modifiedAt?: string }[] onOpenRun?: (runId: string) => void @@ -436,6 +447,7 @@ export function SidebarContentPanel({ onOpenCode, onOpenBgTasks, onOpenApps, + onOpenApp, recentRuns = [], onOpenRun, onRenameRun, @@ -604,6 +616,27 @@ export function SidebarContentPanel({ return [...pinned, ...rest.slice(0, Math.max(0, 10 - pinned.length))] }, [recentRuns, pinnedChatIds]) + // Apps pinned to the sidebar (right-click an app card in the Apps view). + // Names resolve via apps:list; until then rows show the folder slug, and + // pins whose app no longer exists are hidden (but kept in storage). + const [pinnedAppFolders, setPinnedAppFolders] = useState(() => getPinnedApps()) + const [pinnedAppNames, setPinnedAppNames] = useState | null>(null) + useEffect(() => onPinnedAppsChanged(setPinnedAppFolders), []) + useEffect(() => { + if (pinnedAppFolders.length === 0) return + let cancelled = false + void window.ipc.invoke('apps:list', {}) + .then((r) => { + if (cancelled) return + setPinnedAppNames(new Map(r.apps.map((a) => [a.folder, a.manifest?.name ?? a.folder]))) + }) + .catch(() => { /* fall back to folder names */ }) + return () => { cancelled = true } + }, [pinnedAppFolders]) + const pinnedApps = pinnedAppFolders + .filter((f) => pinnedAppNames === null || pinnedAppNames.has(f)) + .map((f) => ({ folder: f, name: pinnedAppNames?.get(f) ?? f })) + // Chat pending delete confirmation, if any. const [deleteChatTarget, setDeleteChatTarget] = useState<{ id: string; title: string } | null>(null) @@ -966,6 +999,24 @@ export function SidebarContentPanel({ Apps + {pinnedApps.map(({ folder, name }) => ( + + + + onOpenApp?.(folder)} className="pl-7"> + + {name} + + + + unpinApp(folder)}> + + Remove from sidebar + + + + + ))} typeof x === 'string') : [] + } catch { + return [] + } +} + +function save(folders: string[]): void { + try { + window.localStorage.setItem(STORAGE_KEY, JSON.stringify(folders)) + } catch { /* keep in-memory behavior */ } + window.dispatchEvent(new Event(PINNED_APPS_CHANGED_EVENT)) +} + +export function pinApp(folder: string): void { + const current = getPinnedApps() + if (!current.includes(folder)) save([...current, folder]) +} + +export function unpinApp(folder: string): void { + const current = getPinnedApps() + if (current.includes(folder)) save(current.filter((f) => f !== folder)) +} + +export function onPinnedAppsChanged(cb: (folders: string[]) => void): () => void { + const handler = () => cb(getPinnedApps()) + window.addEventListener(PINNED_APPS_CHANGED_EVENT, handler) + return () => window.removeEventListener(PINNED_APPS_CHANGED_EVENT, handler) +} diff --git a/apps/x/packages/core/src/apps/stars.ts b/apps/x/packages/core/src/apps/stars.ts index bf48c073..a5daf334 100644 --- a/apps/x/packages/core/src/apps/stars.ts +++ b/apps/x/packages/core/src/apps/stars.ts @@ -11,6 +11,28 @@ const countCache = new Map(); const REPO_RE = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/; +// Once GitHub says the hourly budget is spent (403/429 with +// x-ratelimit-remaining: 0), every further request until the reset is a +// guaranteed 403 — and the catalog re-requests counts on load and on every +// search keystroke. Gate all fetches until the advertised reset instead of +// burning requests on failures. +let rateLimitedUntilMs = 0; + +const isRateLimited = () => Date.now() < rateLimitedUntilMs; + +function noteRateLimit(res: Response): void { + if (res.status !== 403 && res.status !== 429) return; + const remaining = res.headers.get('x-ratelimit-remaining'); + const resetSec = Number(res.headers.get('x-ratelimit-reset')); + if (remaining === '0' && Number.isFinite(resetSec) && resetSec > 0) { + rateLimitedUntilMs = Math.max(rateLimitedUntilMs, resetSec * 1000); + } else { + // Secondary rate limit / abuse detection — headers don't say when it + // ends, so back off a few minutes. + rateLimitedUntilMs = Math.max(rateLimitedUntilMs, Date.now() + 5 * 60 * 1000); + } +} + async function ghHeaders(): Promise> { const headers: Record = { 'Accept': 'application/vnd.github+json', @@ -25,16 +47,19 @@ async function ghHeaders(): Promise> { export async function repoStars(repos: string[]): Promise> { const headers = await ghHeaders(); const now = Date.now(); + const limited = isRateLimited(); const out: Record = {}; await Promise.all([...new Set(repos)].filter((r) => REPO_RE.test(r)).map(async (repo) => { const cached = countCache.get(repo); - if (cached && now - cached.at < COUNT_TTL_MS) { + // While rate-limited, a stale count beats a guaranteed 403. + if (cached && (limited || now - cached.at < COUNT_TTL_MS)) { out[repo] = cached.stars; return; } + if (limited) return; try { const res = await fetch(`https://api.github.com/repos/${repo}`, { headers }); - if (!res.ok) return; // deleted repo / rate limited — no count is fine + if (!res.ok) { noteRateLimit(res); return; } // deleted repo / rate limited — no count is fine const body = await res.json() as { stargazers_count?: number }; if (typeof body.stargazers_count === 'number') { countCache.set(repo, { stars: body.stargazers_count, at: now }); @@ -48,7 +73,7 @@ export async function repoStars(repos: string[]): Promise /** Which of these repos the signed-in user has starred. Empty when signed out. */ export async function starredStatus(repos: string[]): Promise> { const auth = await getGithubToken(); - if (!auth) return {}; + if (!auth || isRateLimited()) return {}; const headers = await ghHeaders(); const out: Record = {}; await Promise.all([...new Set(repos)].filter((r) => REPO_RE.test(r)).map(async (repo) => { @@ -56,6 +81,7 @@ export async function starredStatus(repos: string[]): Promise '')}`.trim()); } // Nudge the cached count so the UI reflects the action before the TTL expires.