mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-07-21 21:31:12 +02:00
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 <noreply@anthropic.com>
This commit is contained in:
parent
70ddf19489
commit
826bce90ad
8 changed files with 304 additions and 89 deletions
|
|
@ -6284,6 +6284,7 @@ function App() {
|
||||||
onOpenBgTasks={() => { setBgTaskInitialSlug(null); setBgTaskSlugVersion((v) => v + 1); openBgTasksView() }}
|
onOpenBgTasks={() => { setBgTaskInitialSlug(null); setBgTaskSlugVersion((v) => v + 1); openBgTasksView() }}
|
||||||
onOpenAgent={(slug) => { setBgTaskInitialSlug(slug); setBgTaskSlugVersion((v) => v + 1); openBgTasksView() }}
|
onOpenAgent={(slug) => { setBgTaskInitialSlug(slug); setBgTaskSlugVersion((v) => v + 1); openBgTasksView() }}
|
||||||
onOpenApps={openAppsView}
|
onOpenApps={openAppsView}
|
||||||
|
onOpenApp={(folder) => { setAppInitialId(folder); setAppIdVersion((v) => v + 1); openAppsView() }}
|
||||||
recentRuns={runs}
|
recentRuns={runs}
|
||||||
onOpenRun={(rid) => void navigateToView({ type: 'chat', runId: rid })}
|
onOpenRun={(rid) => void navigateToView({ type: 'chat', runId: rid })}
|
||||||
onRenameRun={(rid, title) => {
|
onRenameRun={(rid, title) => {
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'
|
||||||
import { X, RotateCcw, Play, UploadCloud, ArrowUpCircle, Trash2 } from 'lucide-react'
|
import { X, RotateCcw, Play, UploadCloud, ArrowUpCircle, Trash2 } from 'lucide-react'
|
||||||
import type { rowboatApp } from '@x/shared'
|
import type { rowboatApp } from '@x/shared'
|
||||||
import { PublishDialog } from '@/components/apps/publish-dialog'
|
import { PublishDialog } from '@/components/apps/publish-dialog'
|
||||||
|
import { unpinApp } from '@/lib/pinned-apps'
|
||||||
import * as analytics from '@/lib/analytics'
|
import * as analytics from '@/lib/analytics'
|
||||||
|
|
||||||
// App detail panel (spec §14): manifest info, provenance/publish state,
|
// 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(', ')}.` : ''
|
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
|
if (!window.confirm(`Uninstall this app? Its data/ folder will be deleted.${agentNote}`)) return
|
||||||
await window.ipc.invoke('apps:uninstall', { folder })
|
await window.ipc.invoke('apps:uninstall', { folder })
|
||||||
|
unpinApp(folder)
|
||||||
onClose()
|
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.' : ''
|
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
|
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 })
|
await window.ipc.invoke('apps:delete', { folder })
|
||||||
|
unpinApp(folder)
|
||||||
onClose()
|
onClose()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,32 +1,20 @@
|
||||||
import { useEffect, useState } from 'react'
|
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 type { rowboatApp } from '@x/shared'
|
||||||
import { AppFrame } from '@/components/apps/app-frame'
|
import { AppFrame } from '@/components/apps/app-frame'
|
||||||
import { CatalogTab } from '@/components/apps/catalog'
|
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
|
// Apps home (spec §14): "My apps" grid + Catalog placeholder (M3). Cards are
|
||||||
// AppSummary-driven; click opens the app full-height on its own origin.
|
// 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 = `
|
const CARD_CSS = `
|
||||||
.ma-page {
|
.ma-page {
|
||||||
container-type: inline-size;
|
container-type: inline-size;
|
||||||
|
|
@ -61,6 +49,14 @@ const CARD_CSS = `
|
||||||
.ma-inner { max-width:1120px; margin:0 auto; padding:34px 30px 48px; }
|
.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-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-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-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 { 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); }
|
.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 theme = themeForIndex(index)
|
||||||
const pattern = patternFor(app.folder)
|
const pattern = patternFor(app.folder)
|
||||||
const invalid = app.status === 'invalid'
|
const invalid = app.status === 'invalid'
|
||||||
return (
|
return (
|
||||||
<button
|
<ContextMenu>
|
||||||
type="button"
|
<ContextMenuTrigger asChild>
|
||||||
onClick={onOpen}
|
<button
|
||||||
title={invalid ? app.manifestError : undefined}
|
type="button"
|
||||||
className={`ma-card ma-pat-${pattern}`}
|
onClick={onOpen}
|
||||||
style={{ '--accent': theme.accent, '--glow': theme.glow } as React.CSSProperties}
|
title={invalid ? app.manifestError : undefined}
|
||||||
>
|
className={`ma-card ma-pat-${pattern}`}
|
||||||
<div className="ma-top">
|
style={{ '--accent': theme.accent, '--glow': theme.glow } as React.CSSProperties}
|
||||||
{invalid && <span className="ma-badge err">INVALID</span>}
|
>
|
||||||
<span className={`ma-badge${app.kind === 'installed' ? '' : ' off'}`}>
|
<div className="ma-top">
|
||||||
{app.kind === 'installed' ? 'INSTALLED' : 'LOCAL'}
|
{invalid && <span className="ma-badge err">INVALID</span>}
|
||||||
</span>
|
<span className={`ma-badge${app.kind === 'installed' ? '' : ' off'}`}>
|
||||||
</div>
|
{app.kind === 'installed' ? 'INSTALLED' : 'LOCAL'}
|
||||||
<div className="ma-title">{app.manifest?.name ?? app.folder}</div>
|
</span>
|
||||||
<div className="ma-desc">{invalid ? (app.manifestError ?? 'Invalid manifest') : (app.manifest?.description || 'No description yet.')}</div>
|
</div>
|
||||||
<div className="ma-footer">
|
<div className="ma-title">{app.manifest?.name ?? app.folder}</div>
|
||||||
<span className="ma-source">v{app.manifest?.version ?? '?'}</span>
|
<div className="ma-desc">{invalid ? (app.manifestError ?? 'Invalid manifest') : (app.manifest?.description || 'No description yet.')}</div>
|
||||||
<span className="ma-lastrun">{app.folder}</span>
|
<div className="ma-footer">
|
||||||
</div>
|
<span className="ma-source">v{app.manifest?.version ?? '?'}</span>
|
||||||
</button>
|
<span className="ma-lastrun">{app.folder}</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</ContextMenuTrigger>
|
||||||
|
<ContextMenuContent>
|
||||||
|
<ContextMenuItem onClick={onTogglePin}>
|
||||||
|
{isPinned ? <PanelLeftClose className="mr-2 size-3.5" /> : <PanelLeft className="mr-2 size-3.5" />}
|
||||||
|
{isPinned ? 'Remove from sidebar' : 'Add to sidebar'}
|
||||||
|
</ContextMenuItem>
|
||||||
|
</ContextMenuContent>
|
||||||
|
</ContextMenu>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -167,10 +179,16 @@ export function AppsView({ initialAppFolder, initialVersion, onNewApp }: {
|
||||||
initialVersion?: number
|
initialVersion?: number
|
||||||
onNewApp?: () => void
|
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<string | null>(initialAppFolder ?? null)
|
const [selectedFolder, setSelectedFolder] = useState<string | null>(initialAppFolder ?? null)
|
||||||
const [apps, setApps] = useState<rowboatApp.AppSummary[]>([])
|
const [apps, setApps] = useState<rowboatApp.AppSummary[]>([])
|
||||||
|
const [appsLoaded, setAppsLoaded] = useState(false)
|
||||||
const [serverError, setServerError] = useState<string | null>(null)
|
const [serverError, setServerError] = useState<string | null>(null)
|
||||||
|
const [pinnedFolders, setPinnedFolders] = useState<string[]>(() => getPinnedApps())
|
||||||
|
|
||||||
|
useEffect(() => onPinnedAppsChanged(setPinnedFolders), [])
|
||||||
|
|
||||||
// Open a specific app when asked from outside (app-navigation open-app).
|
// Open a specific app when asked from outside (app-navigation open-app).
|
||||||
const [appliedVersion, setAppliedVersion] = useState(initialVersion)
|
const [appliedVersion, setAppliedVersion] = useState(initialVersion)
|
||||||
|
|
@ -186,11 +204,19 @@ export function AppsView({ initialAppFolder, initialVersion, onNewApp }: {
|
||||||
const r = await window.ipc.invoke('apps:list', {})
|
const r = await window.ipc.invoke('apps:list', {})
|
||||||
if (cancelled) return
|
if (cancelled) return
|
||||||
setApps(r.apps)
|
setApps(r.apps)
|
||||||
|
setAppsLoaded(true)
|
||||||
// Drop a selection whose app no longer exists (uninstalled while
|
// Drop a selection whose app no longer exists (uninstalled while
|
||||||
// open). Left stale, a later reinstall makes this view yank the user
|
// 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
|
// into the app frame mid-flow — e.g. while they're in the catalog's
|
||||||
// post-install agent dialog.
|
// post-install agent dialog.
|
||||||
setSelectedFolder((cur) => (cur && !r.apps.some((a) => a.folder === cur) ? null : cur))
|
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.'))
|
setServerError(r.serverRunning ? null : (r.serverError ?? 'Apps server is not running.'))
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (!cancelled) setServerError(e instanceof Error ? e.message : String(e))
|
if (!cancelled) setServerError(e instanceof Error ? e.message : String(e))
|
||||||
|
|
@ -206,6 +232,9 @@ export function AppsView({ initialAppFolder, initialVersion, onNewApp }: {
|
||||||
return <AppFrame app={selected} onBack={() => setSelectedFolder(null)} />
|
return <AppFrame app={selected} onBack={() => setSelectedFolder(null)} />
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const noOwnApps = appsLoaded && apps.length === 0
|
||||||
|
const activeTab = tab ?? (noOwnApps ? 'catalog' : 'mine')
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="ma-page">
|
<div className="ma-page">
|
||||||
<style>{CARD_CSS}</style>
|
<style>{CARD_CSS}</style>
|
||||||
|
|
@ -214,8 +243,8 @@ export function AppsView({ initialAppFolder, initialVersion, onNewApp }: {
|
||||||
<p className="ma-sub">Apps that live inside Rowboat, powered by your agents and integrations.</p>
|
<p className="ma-sub">Apps that live inside Rowboat, powered by your agents and integrations.</p>
|
||||||
|
|
||||||
<div className="ma-tabs">
|
<div className="ma-tabs">
|
||||||
<button type="button" className={`ma-tab${tab === 'mine' ? ' on' : ''}`} onClick={() => setTab('mine')}>My apps</button>
|
<button type="button" className={`ma-tab${activeTab === 'mine' ? ' on' : ''}`} onClick={() => setTab('mine')}>My apps</button>
|
||||||
<button type="button" className={`ma-tab${tab === 'catalog' ? ' on' : ''}`} onClick={() => setTab('catalog')}>Catalog</button>
|
<button type="button" className={`ma-tab${activeTab === 'catalog' ? ' on' : ''}`} onClick={() => setTab('catalog')}>Catalog</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{serverError && (
|
{serverError && (
|
||||||
|
|
@ -224,19 +253,39 @@ export function AppsView({ initialAppFolder, initialVersion, onNewApp }: {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{tab === 'catalog' ? (
|
{!appsLoaded && !serverError ? null : activeTab === 'catalog' ? (
|
||||||
<CatalogTab onInstalled={(folder) => { setSelectedFolder(folder); setTab('mine') }} />
|
<>
|
||||||
|
{noOwnApps && (
|
||||||
|
<div className="ma-welcome">
|
||||||
|
You don't have any apps of your own yet. Install one from this catalog, or{' '}
|
||||||
|
<button type="button" onClick={onNewApp}>build your own</button>.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<CatalogTab onInstalled={(folder) => { setSelectedFolder(folder); setTab('mine') }} />
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
<div className="ma-grid">
|
<>
|
||||||
{apps.map((app, i) => (
|
{apps.length > 0 && (
|
||||||
<Card key={app.folder} app={app} index={i} onOpen={() => setSelectedFolder(app.folder)} />
|
<p className="ma-hint">Tip: right-click an app to add it to the sidebar for quick access.</p>
|
||||||
))}
|
)}
|
||||||
<button type="button" className="ma-new" onClick={onNewApp}>
|
<div className="ma-grid">
|
||||||
<Plus className="size-5" />
|
{apps.map((app, i) => (
|
||||||
<div className="ma-new-title">New app</div>
|
<Card
|
||||||
<div className="ma-new-hint">Describe one to the copilot</div>
|
key={app.folder}
|
||||||
</button>
|
app={app}
|
||||||
</div>
|
index={i}
|
||||||
|
onOpen={() => setSelectedFolder(app.folder)}
|
||||||
|
isPinned={pinnedFolders.includes(app.folder)}
|
||||||
|
onTogglePin={() => (pinnedFolders.includes(app.folder) ? unpinApp(app.folder) : pinApp(app.folder))}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<button type="button" className="ma-new" onClick={onNewApp}>
|
||||||
|
<Plus className="size-5" />
|
||||||
|
<div className="ma-new-title">New app</div>
|
||||||
|
<div className="ma-new-hint">Describe one to the copilot</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
23
apps/x/apps/renderer/src/components/apps/card-theme.ts
Normal file
23
apps/x/apps/renderer/src/components/apps/card-theme.ts
Normal file
|
|
@ -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]
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { BadgeCheck, Bot, Download, Link2, RefreshCw, Search, ShieldAlert, Star } from 'lucide-react'
|
import { BadgeCheck, Bot, Download, Link2, RefreshCw, Search, ShieldAlert, Star } from 'lucide-react'
|
||||||
import type { rowboatApp } from '@x/shared'
|
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
|
// Catalog tab (spec §14): search the registry, install with the D18 capability
|
||||||
// disclosure, install from a direct bundle URL.
|
// 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.'}
|
{query ? 'No apps match your search.' : 'No apps in the catalog yet — be the first to publish one.'}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-2">
|
<div className="ma-grid">
|
||||||
{ranked.map((r) => (
|
{ranked.map((r, i) => {
|
||||||
<div key={r.name} className="flex items-center gap-3 rounded-xl border border-border bg-card px-4 py-3">
|
const theme = themeForIndex(i)
|
||||||
<div className="min-w-0 flex-1">
|
const installedFolder = installedByName.get(r.name)
|
||||||
<div className="flex items-baseline gap-2">
|
return (
|
||||||
<span className="truncate text-sm font-semibold">{r.name}</span>
|
<div
|
||||||
<span className="text-xs text-muted-foreground">by {r.owner}</span>
|
key={r.name}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onClick={() => 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}
|
||||||
|
>
|
||||||
|
<div className="ma-top">
|
||||||
|
{installedFolder && <span className="ma-badge">INSTALLED</span>}
|
||||||
|
<button type="button"
|
||||||
|
title={starred[r.repo] ? 'Unstar on GitHub' : 'Star on GitHub'}
|
||||||
|
onClick={(e) => { e.stopPropagation(); void toggleStar(r.repo) }}
|
||||||
|
className={`flex items-center gap-1 rounded-full px-2 py-1 text-xs font-medium hover:bg-foreground/10 ${starred[r.repo] ? 'text-amber-500' : 'text-muted-foreground'}`}>
|
||||||
|
<Star className={`size-3.5 ${starred[r.repo] ? 'fill-current' : ''}`} />
|
||||||
|
{stars[r.repo] ?? '—'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="ma-title">{r.name}</div>
|
||||||
|
<div className="ma-owner">by {r.owner}</div>
|
||||||
|
<div className="ma-desc">{r.description || 'No description.'}</div>
|
||||||
|
<div className="ma-footer">
|
||||||
|
<span className="ma-lastrun">{r.repo}</span>
|
||||||
|
{installedFolder ? (
|
||||||
|
<button type="button" title="Installed — open it"
|
||||||
|
onClick={(e) => { e.stopPropagation(); onInstalled(installedFolder) }}
|
||||||
|
className="flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium text-green-600 hover:bg-green-500/10 dark:text-green-500">
|
||||||
|
<BadgeCheck className="size-4" /> Open
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button type="button"
|
||||||
|
onClick={(e) => { e.stopPropagation(); void startInstall(r.name) }}
|
||||||
|
className="flex items-center gap-1.5 rounded-md border border-border px-3 py-1.5 text-sm font-medium hover:bg-accent">
|
||||||
|
<Download className="size-4" /> Install
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p className="truncate text-xs text-muted-foreground">{r.description || 'No description.'}</p>
|
|
||||||
</div>
|
</div>
|
||||||
<button type="button"
|
)
|
||||||
title={starred[r.repo] ? 'Unstar on GitHub' : 'Star on GitHub'}
|
})}
|
||||||
onClick={() => void toggleStar(r.repo)}
|
|
||||||
className={`flex items-center gap-1 rounded-md px-2 py-1.5 text-xs font-medium hover:bg-accent ${starred[r.repo] ? 'text-amber-500' : 'text-muted-foreground'}`}>
|
|
||||||
<Star className={`size-3.5 ${starred[r.repo] ? 'fill-current' : ''}`} />
|
|
||||||
{stars[r.repo] ?? '—'}
|
|
||||||
</button>
|
|
||||||
{installedByName.has(r.name) ? (
|
|
||||||
<button type="button" title="Installed — open it"
|
|
||||||
onClick={() => onInstalled(installedByName.get(r.name)!)}
|
|
||||||
className="flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium text-green-600 hover:bg-green-500/10 dark:text-green-500">
|
|
||||||
<BadgeCheck className="size-4" /> Installed
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<button type="button" onClick={() => void startInstall(r.name)}
|
|
||||||
className="flex items-center gap-1.5 rounded-md border border-border px-3 py-1.5 text-sm font-medium hover:bg-accent">
|
|
||||||
<Download className="size-4" /> Install
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
import { useCallback, useEffect, useRef, useState } from "react"
|
import { useCallback, useEffect, useRef, useState } from "react"
|
||||||
import {
|
import {
|
||||||
|
AppWindow,
|
||||||
ArrowUpRight,
|
ArrowUpRight,
|
||||||
Bot,
|
Bot,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
|
|
@ -16,6 +17,7 @@ import {
|
||||||
LayoutGrid,
|
LayoutGrid,
|
||||||
Mic,
|
Mic,
|
||||||
MoreVertical,
|
MoreVertical,
|
||||||
|
PanelLeftClose,
|
||||||
Pencil,
|
Pencil,
|
||||||
Pin,
|
Pin,
|
||||||
SquarePen,
|
SquarePen,
|
||||||
|
|
@ -71,7 +73,14 @@ import {
|
||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu"
|
} from "@/components/ui/dropdown-menu"
|
||||||
|
import {
|
||||||
|
ContextMenu,
|
||||||
|
ContextMenuContent,
|
||||||
|
ContextMenuItem,
|
||||||
|
ContextMenuTrigger,
|
||||||
|
} from "@/components/ui/context-menu"
|
||||||
import { cn } from "@/lib/utils"
|
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 { isOutOfCredits, CREDIT_EXHAUSTED_EVENT, CREDIT_REPLENISHED_EVENT } from "@/lib/credit-status"
|
||||||
import { SettingsDialog } from "@/components/settings-dialog"
|
import { SettingsDialog } from "@/components/settings-dialog"
|
||||||
import { MascotFaceIcon } from "@/components/talking-head"
|
import { MascotFaceIcon } from "@/components/talking-head"
|
||||||
|
|
@ -180,6 +189,8 @@ type SidebarContentPanelProps = {
|
||||||
onOpenCode?: () => void
|
onOpenCode?: () => void
|
||||||
onOpenBgTasks?: () => void
|
onOpenBgTasks?: () => void
|
||||||
onOpenApps?: () => void
|
onOpenApps?: () => void
|
||||||
|
/** Open a specific app (pinned in the sidebar) inside the Apps view. */
|
||||||
|
onOpenApp?: (folder: string) => void
|
||||||
onOpenAgent?: (slug: string) => void
|
onOpenAgent?: (slug: string) => void
|
||||||
recentRuns?: { id: string; title?: string; createdAt: string; modifiedAt?: string }[]
|
recentRuns?: { id: string; title?: string; createdAt: string; modifiedAt?: string }[]
|
||||||
onOpenRun?: (runId: string) => void
|
onOpenRun?: (runId: string) => void
|
||||||
|
|
@ -436,6 +447,7 @@ export function SidebarContentPanel({
|
||||||
onOpenCode,
|
onOpenCode,
|
||||||
onOpenBgTasks,
|
onOpenBgTasks,
|
||||||
onOpenApps,
|
onOpenApps,
|
||||||
|
onOpenApp,
|
||||||
recentRuns = [],
|
recentRuns = [],
|
||||||
onOpenRun,
|
onOpenRun,
|
||||||
onRenameRun,
|
onRenameRun,
|
||||||
|
|
@ -604,6 +616,27 @@ export function SidebarContentPanel({
|
||||||
return [...pinned, ...rest.slice(0, Math.max(0, 10 - pinned.length))]
|
return [...pinned, ...rest.slice(0, Math.max(0, 10 - pinned.length))]
|
||||||
}, [recentRuns, pinnedChatIds])
|
}, [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<string[]>(() => getPinnedApps())
|
||||||
|
const [pinnedAppNames, setPinnedAppNames] = useState<Map<string, string> | 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.
|
// Chat pending delete confirmation, if any.
|
||||||
const [deleteChatTarget, setDeleteChatTarget] = useState<{ id: string; title: string } | null>(null)
|
const [deleteChatTarget, setDeleteChatTarget] = useState<{ id: string; title: string } | null>(null)
|
||||||
|
|
||||||
|
|
@ -966,6 +999,24 @@ export function SidebarContentPanel({
|
||||||
<span className="flex-1 truncate">Apps</span>
|
<span className="flex-1 truncate">Apps</span>
|
||||||
</SidebarMenuButton>
|
</SidebarMenuButton>
|
||||||
</SidebarMenuItem>
|
</SidebarMenuItem>
|
||||||
|
{pinnedApps.map(({ folder, name }) => (
|
||||||
|
<SidebarMenuItem key={folder}>
|
||||||
|
<ContextMenu>
|
||||||
|
<ContextMenuTrigger asChild>
|
||||||
|
<SidebarMenuButton onClick={() => onOpenApp?.(folder)} className="pl-7">
|
||||||
|
<AppWindow className="size-4 shrink-0 text-muted-foreground" />
|
||||||
|
<span className="flex-1 truncate">{name}</span>
|
||||||
|
</SidebarMenuButton>
|
||||||
|
</ContextMenuTrigger>
|
||||||
|
<ContextMenuContent>
|
||||||
|
<ContextMenuItem onClick={() => unpinApp(folder)}>
|
||||||
|
<PanelLeftClose className="mr-2 size-3.5" />
|
||||||
|
Remove from sidebar
|
||||||
|
</ContextMenuItem>
|
||||||
|
</ContextMenuContent>
|
||||||
|
</ContextMenu>
|
||||||
|
</SidebarMenuItem>
|
||||||
|
))}
|
||||||
<SidebarMenuItem>
|
<SidebarMenuItem>
|
||||||
<SidebarMenuButton
|
<SidebarMenuButton
|
||||||
data-tour-id="nav-agents"
|
data-tour-id="nav-agents"
|
||||||
|
|
|
||||||
39
apps/x/apps/renderer/src/lib/pinned-apps.ts
Normal file
39
apps/x/apps/renderer/src/lib/pinned-apps.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
// Apps pinned to the nav sidebar: a per-machine UI preference persisted in
|
||||||
|
// localStorage (same pattern as pinned chats). A window event keeps the
|
||||||
|
// sidebar and the Apps view in sync within the session.
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'x:pinned-apps'
|
||||||
|
export const PINNED_APPS_CHANGED_EVENT = 'x:pinned-apps-changed'
|
||||||
|
|
||||||
|
export function getPinnedApps(): string[] {
|
||||||
|
try {
|
||||||
|
const raw = window.localStorage.getItem(STORAGE_KEY)
|
||||||
|
const parsed: unknown = raw ? JSON.parse(raw) : []
|
||||||
|
return Array.isArray(parsed) ? parsed.filter((x): x is string => 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)
|
||||||
|
}
|
||||||
|
|
@ -11,6 +11,28 @@ const countCache = new Map<string, { stars: number; at: number }>();
|
||||||
|
|
||||||
const REPO_RE = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
|
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<Record<string, string>> {
|
async function ghHeaders(): Promise<Record<string, string>> {
|
||||||
const headers: Record<string, string> = {
|
const headers: Record<string, string> = {
|
||||||
'Accept': 'application/vnd.github+json',
|
'Accept': 'application/vnd.github+json',
|
||||||
|
|
@ -25,16 +47,19 @@ async function ghHeaders(): Promise<Record<string, string>> {
|
||||||
export async function repoStars(repos: string[]): Promise<Record<string, number>> {
|
export async function repoStars(repos: string[]): Promise<Record<string, number>> {
|
||||||
const headers = await ghHeaders();
|
const headers = await ghHeaders();
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
const limited = isRateLimited();
|
||||||
const out: Record<string, number> = {};
|
const out: Record<string, number> = {};
|
||||||
await Promise.all([...new Set(repos)].filter((r) => REPO_RE.test(r)).map(async (repo) => {
|
await Promise.all([...new Set(repos)].filter((r) => REPO_RE.test(r)).map(async (repo) => {
|
||||||
const cached = countCache.get(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;
|
out[repo] = cached.stars;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (limited) return;
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`https://api.github.com/repos/${repo}`, { headers });
|
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 };
|
const body = await res.json() as { stargazers_count?: number };
|
||||||
if (typeof body.stargazers_count === 'number') {
|
if (typeof body.stargazers_count === 'number') {
|
||||||
countCache.set(repo, { stars: body.stargazers_count, at: now });
|
countCache.set(repo, { stars: body.stargazers_count, at: now });
|
||||||
|
|
@ -48,7 +73,7 @@ export async function repoStars(repos: string[]): Promise<Record<string, number>
|
||||||
/** Which of these repos the signed-in user has starred. Empty when signed out. */
|
/** Which of these repos the signed-in user has starred. Empty when signed out. */
|
||||||
export async function starredStatus(repos: string[]): Promise<Record<string, boolean>> {
|
export async function starredStatus(repos: string[]): Promise<Record<string, boolean>> {
|
||||||
const auth = await getGithubToken();
|
const auth = await getGithubToken();
|
||||||
if (!auth) return {};
|
if (!auth || isRateLimited()) return {};
|
||||||
const headers = await ghHeaders();
|
const headers = await ghHeaders();
|
||||||
const out: Record<string, boolean> = {};
|
const out: Record<string, boolean> = {};
|
||||||
await Promise.all([...new Set(repos)].filter((r) => REPO_RE.test(r)).map(async (repo) => {
|
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<Record<string, boo
|
||||||
const res = await fetch(`https://api.github.com/user/starred/${repo}`, { headers });
|
const res = await fetch(`https://api.github.com/user/starred/${repo}`, { headers });
|
||||||
if (res.status === 204) out[repo] = true;
|
if (res.status === 204) out[repo] = true;
|
||||||
else if (res.status === 404) out[repo] = false;
|
else if (res.status === 404) out[repo] = false;
|
||||||
|
else noteRateLimit(res);
|
||||||
// other statuses (401 revoked token, 403 rate limit) → unknown, omit
|
// other statuses (401 revoked token, 403 rate limit) → unknown, omit
|
||||||
} catch { /* offline — unknown */ }
|
} catch { /* offline — unknown */ }
|
||||||
}));
|
}));
|
||||||
|
|
@ -72,6 +98,7 @@ export async function setStar(repo: string, star: boolean): Promise<{ starred: b
|
||||||
headers: { ...(await ghHeaders()), 'Content-Length': '0' },
|
headers: { ...(await ghHeaders()), 'Content-Length': '0' },
|
||||||
});
|
});
|
||||||
if (res.status !== 204) {
|
if (res.status !== 204) {
|
||||||
|
noteRateLimit(res);
|
||||||
throw new Error(`star_failed: HTTP ${res.status} ${await res.text().catch(() => '')}`.trim());
|
throw new Error(`star_failed: HTTP ${res.status} ${await res.text().catch(() => '')}`.trim());
|
||||||
}
|
}
|
||||||
// Nudge the cached count so the UI reflects the action before the TTL expires.
|
// Nudge the cached count so the UI reflects the action before the TTL expires.
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue