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() }}
|
||||
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) => {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpen}
|
||||
title={invalid ? app.manifestError : undefined}
|
||||
className={`ma-card ma-pat-${pattern}`}
|
||||
style={{ '--accent': theme.accent, '--glow': theme.glow } as React.CSSProperties}
|
||||
>
|
||||
<div className="ma-top">
|
||||
{invalid && <span className="ma-badge err">INVALID</span>}
|
||||
<span className={`ma-badge${app.kind === 'installed' ? '' : ' off'}`}>
|
||||
{app.kind === 'installed' ? 'INSTALLED' : 'LOCAL'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="ma-title">{app.manifest?.name ?? app.folder}</div>
|
||||
<div className="ma-desc">{invalid ? (app.manifestError ?? 'Invalid manifest') : (app.manifest?.description || 'No description yet.')}</div>
|
||||
<div className="ma-footer">
|
||||
<span className="ma-source">v{app.manifest?.version ?? '?'}</span>
|
||||
<span className="ma-lastrun">{app.folder}</span>
|
||||
</div>
|
||||
</button>
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpen}
|
||||
title={invalid ? app.manifestError : undefined}
|
||||
className={`ma-card ma-pat-${pattern}`}
|
||||
style={{ '--accent': theme.accent, '--glow': theme.glow } as React.CSSProperties}
|
||||
>
|
||||
<div className="ma-top">
|
||||
{invalid && <span className="ma-badge err">INVALID</span>}
|
||||
<span className={`ma-badge${app.kind === 'installed' ? '' : ' off'}`}>
|
||||
{app.kind === 'installed' ? 'INSTALLED' : 'LOCAL'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="ma-title">{app.manifest?.name ?? app.folder}</div>
|
||||
<div className="ma-desc">{invalid ? (app.manifestError ?? 'Invalid manifest') : (app.manifest?.description || 'No description yet.')}</div>
|
||||
<div className="ma-footer">
|
||||
<span className="ma-source">v{app.manifest?.version ?? '?'}</span>
|
||||
<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
|
||||
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 [apps, setApps] = useState<rowboatApp.AppSummary[]>([])
|
||||
const [appsLoaded, setAppsLoaded] = useState(false)
|
||||
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).
|
||||
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 <AppFrame app={selected} onBack={() => setSelectedFolder(null)} />
|
||||
}
|
||||
|
||||
const noOwnApps = appsLoaded && apps.length === 0
|
||||
const activeTab = tab ?? (noOwnApps ? 'catalog' : 'mine')
|
||||
|
||||
return (
|
||||
<div className="ma-page">
|
||||
<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>
|
||||
|
||||
<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${tab === 'catalog' ? ' on' : ''}`} onClick={() => setTab('catalog')}>Catalog</button>
|
||||
<button type="button" className={`ma-tab${activeTab === 'mine' ? ' on' : ''}`} onClick={() => setTab('mine')}>My apps</button>
|
||||
<button type="button" className={`ma-tab${activeTab === 'catalog' ? ' on' : ''}`} onClick={() => setTab('catalog')}>Catalog</button>
|
||||
</div>
|
||||
|
||||
{serverError && (
|
||||
|
|
@ -224,19 +253,39 @@ export function AppsView({ initialAppFolder, initialVersion, onNewApp }: {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{tab === 'catalog' ? (
|
||||
<CatalogTab onInstalled={(folder) => { setSelectedFolder(folder); setTab('mine') }} />
|
||||
{!appsLoaded && !serverError ? null : activeTab === 'catalog' ? (
|
||||
<>
|
||||
{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) => (
|
||||
<Card key={app.folder} app={app} index={i} onOpen={() => setSelectedFolder(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>
|
||||
<>
|
||||
{apps.length > 0 && (
|
||||
<p className="ma-hint">Tip: right-click an app to add it to the sidebar for quick access.</p>
|
||||
)}
|
||||
<div className="ma-grid">
|
||||
{apps.map((app, i) => (
|
||||
<Card
|
||||
key={app.folder}
|
||||
app={app}
|
||||
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>
|
||||
|
|
|
|||
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 { 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.'}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{ranked.map((r) => (
|
||||
<div key={r.name} className="flex items-center gap-3 rounded-xl border border-border bg-card px-4 py-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="truncate text-sm font-semibold">{r.name}</span>
|
||||
<span className="text-xs text-muted-foreground">by {r.owner}</span>
|
||||
<div className="ma-grid">
|
||||
{ranked.map((r, i) => {
|
||||
const theme = themeForIndex(i)
|
||||
const installedFolder = installedByName.get(r.name)
|
||||
return (
|
||||
<div
|
||||
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>
|
||||
<p className="truncate text-xs text-muted-foreground">{r.description || 'No description.'}</p>
|
||||
</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>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<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.
|
||||
const [deleteChatTarget, setDeleteChatTarget] = useState<{ id: string; title: string } | null>(null)
|
||||
|
||||
|
|
@ -966,6 +999,24 @@ export function SidebarContentPanel({
|
|||
<span className="flex-1 truncate">Apps</span>
|
||||
</SidebarMenuButton>
|
||||
</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>
|
||||
<SidebarMenuButton
|
||||
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_.-]+$/;
|
||||
|
||||
// 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>> {
|
||||
const headers: Record<string, string> = {
|
||||
'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>> {
|
||||
const headers = await ghHeaders();
|
||||
const now = Date.now();
|
||||
const limited = isRateLimited();
|
||||
const out: Record<string, number> = {};
|
||||
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<Record<string, number>
|
|||
/** Which of these repos the signed-in user has starred. Empty when signed out. */
|
||||
export async function starredStatus(repos: string[]): Promise<Record<string, boolean>> {
|
||||
const auth = await getGithubToken();
|
||||
if (!auth) return {};
|
||||
if (!auth || isRateLimited()) return {};
|
||||
const headers = await ghHeaders();
|
||||
const out: Record<string, boolean> = {};
|
||||
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 });
|
||||
if (res.status === 204) out[repo] = true;
|
||||
else if (res.status === 404) out[repo] = false;
|
||||
else noteRateLimit(res);
|
||||
// other statuses (401 revoked token, 403 rate limit) → unknown, omit
|
||||
} catch { /* offline — unknown */ }
|
||||
}));
|
||||
|
|
@ -72,6 +98,7 @@ export async function setStar(repo: string, star: boolean): Promise<{ starred: b
|
|||
headers: { ...(await ghHeaders()), 'Content-Length': '0' },
|
||||
});
|
||||
if (res.status !== 204) {
|
||||
noteRateLimit(res);
|
||||
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.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue