Update stuff
Build and Push Next App / quality (push) Successful in 1m21s
Build and Push Next App / build-next (push) Successful in 3m34s

This commit is contained in:
Gabriel Brown
2026-06-22 00:41:51 -05:00
parent 2e13febfc7
commit 4114d5595c
20 changed files with 672 additions and 354 deletions
Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 960 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 960 KiB

+3 -15
View File
@@ -5,13 +5,7 @@ import { MetricCard } from '@/components/dashboard/metric-card';
import { SpoonCard } from '@/components/spoons/spoon-card';
import { MaintenanceQueue } from '@/components/updates/maintenance-queue';
import { useQuery } from 'convex/react';
import {
Bot,
GitBranch,
GitPullRequest,
RefreshCw,
ShieldCheck,
} from 'lucide-react';
import { Bot, GitBranch, RefreshCw, ShieldCheck } from 'lucide-react';
import { api } from '@spoon/backend/convex/_generated/api.js';
import { Button, Card, CardContent, CardHeader, CardTitle } from '@spoon/ui';
@@ -50,17 +44,11 @@ const DashboardPage = () => {
<div className='grid gap-4 md:grid-cols-2 xl:grid-cols-4'>
<MetricCard
label='Total Spoons'
label='Spoons'
value={spoons.length}
note='Managed forks'
note={`${activeSpoons} active`}
icon={GitBranch}
/>
<MetricCard
label='Active Spoons'
value={activeSpoons}
note='Ready for checks'
icon={GitPullRequest}
/>
<MetricCard
label='Behind upstream'
value={behind}
+1 -1
View File
@@ -13,7 +13,7 @@ const SpoonsPage = () => {
<main className='space-y-6'>
<div className='flex flex-col justify-between gap-4 md:flex-row md:items-end'>
<div>
<h1 className='text-3xl font-semibold tracking-normal'>My Spoons</h1>
<h1 className='text-3xl font-semibold tracking-normal'>Spoons</h1>
<p className='text-muted-foreground mt-2'>
Managed forks you want to keep close to their upstream projects.
</p>
+9 -1
View File
@@ -1,4 +1,11 @@
import { Agents, CTA, Features, Hero, Workflow } from '@/components/landing';
import {
Agents,
CTA,
Features,
Hero,
Security,
Workflow,
} from '@/components/landing';
const Home = () => (
<main className='flex min-h-screen flex-col'>
@@ -6,6 +13,7 @@ const Home = () => (
<Workflow />
<Features />
<Agents />
<Security />
<CTA />
</main>
);
@@ -1,49 +1,12 @@
'use client';
import type { ReactNode } from 'react';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { GitBranch, LayoutDashboard, RefreshCw, Settings } from 'lucide-react';
import { cn } from '@spoon/ui';
const navItems = [
{ href: '/dashboard', label: 'Dashboard', icon: LayoutDashboard },
{ href: '/spoons', label: 'My Spoons', icon: GitBranch },
{ href: '/updates', label: 'Updates', icon: RefreshCw },
{ href: '/settings/profile', label: 'Settings', icon: Settings },
];
export const AppShell = ({ children }: { children: ReactNode }) => {
const pathname = usePathname();
return (
<div className='bg-muted/20 flex-1 border-t'>
<div className='container mx-auto grid gap-6 px-4 py-6 lg:grid-cols-[14rem_1fr]'>
<aside className='lg:sticky lg:top-20 lg:self-start'>
<nav className='border-border bg-card flex gap-1 overflow-x-auto border p-2 lg:flex-col'>
{navItems.map(({ href, label, icon: Icon }) => {
const active =
pathname === href ||
(href !== '/dashboard' && pathname.startsWith(href));
return (
<Link
key={href}
href={href}
className={cn(
'hover:bg-muted flex min-w-fit items-center gap-2 rounded-md px-3 py-2 text-sm font-medium transition-colors',
active
? 'bg-primary/10 text-primary'
: 'text-muted-foreground hover:text-foreground',
)}
>
<Icon className='size-4' />
{label}
</Link>
);
})}
</nav>
</aside>
<div className='min-w-0'>{children}</div>
<div className='container mx-auto min-w-0 px-4 py-6 md:px-6'>
{children}
</div>
</div>
);
+10 -3
View File
@@ -1,16 +1,23 @@
import Image from 'next/image';
import Link from 'next/link';
import { Utensils } from 'lucide-react';
import { cn } from '@spoon/ui';
export const LogoMark = ({ className }: { className?: string }) => (
<span
className={cn(
'bg-primary text-primary-foreground inline-flex size-9 items-center justify-center rounded-md',
'inline-flex size-9 items-center justify-center overflow-hidden rounded-md',
className,
)}
>
<Utensils className='size-5' />
<Image
src='/favicon.png'
alt=''
width={36}
height={36}
className='size-full object-cover'
priority
/>
</span>
);
+29 -21
View File
@@ -1,27 +1,35 @@
'use client';
import Link from 'next/link';
import { useConvexAuth } from 'convex/react';
import { ArrowRight } from 'lucide-react';
import { Button } from '@spoon/ui';
export const CTA = () => (
<section className='container mx-auto px-4 py-20'>
<div className='border-border bg-card flex flex-col items-start justify-between gap-6 border p-8 md:flex-row md:items-center'>
<div>
<h2 className='text-2xl font-semibold tracking-normal'>
Start your first Spoon
</h2>
<p className='text-muted-foreground mt-2 max-w-2xl'>
Create a manual managed fork record today. Provider connections,
scheduled checks, and AI merge request automation can build on the
same foundation.
</p>
export const CTA = () => {
const { isAuthenticated } = useConvexAuth();
return (
<section className='container mx-auto px-4 py-20'>
<div className='bg-primary text-primary-foreground rounded-lg px-6 py-10 md:px-10'>
<div className='flex flex-col items-start justify-between gap-8 md:flex-row md:items-center'>
<div>
<h2 className='text-2xl font-semibold tracking-normal md:text-3xl'>
Keep the fork. Lose the maintenance dread.
</h2>
<p className='text-primary-foreground/80 mt-3 max-w-2xl leading-7'>
Create your first Spoon, connect GitHub, and make upstream drift
something you can see, review, and act on.
</p>
</div>
<Button variant='secondary' size='lg' asChild>
<Link href={isAuthenticated ? '/spoons/new' : '/sign-in'}>
{isAuthenticated ? 'New Spoon' : 'Start with Spoon'}
<ArrowRight className='size-4' />
</Link>
</Button>
</div>
</div>
<Button asChild>
<Link href='/spoons/new'>
New Spoon
<ArrowRight className='size-4' />
</Link>
</Button>
</div>
</section>
);
</section>
);
};
+241 -95
View File
@@ -1,100 +1,195 @@
import {
Bot,
GitMerge,
Code2,
GitBranch,
GitCompare,
GitPullRequest,
History,
SearchCheck,
ShieldCheck,
TriangleAlert,
KeyRound,
LockKeyhole,
RefreshCw,
ServerCog,
Sparkles,
} from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@spoon/ui';
import { Badge } from '@spoon/ui';
const maintenance = [
const workflow = [
{
title: 'Upstream security fixes',
title: 'Connect GitHub',
description:
'Track the changes that land upstream so important fixes do not disappear into fork drift.',
icon: ShieldCheck,
'Install the Spoon GitHub App so Spoon can read forks, compare branches, push agent branches, and open draft pull requests.',
},
{
title: 'Conflict detection',
title: 'Create a Spoon',
description:
'Make update risk visible before a merge request reaches the fork you actually maintain.',
icon: TriangleAlert,
'Register a managed fork with its upstream project, fork repository, default branches, sync cadence, and additional remote URLs.',
},
{
title: 'AI-reviewed changes',
title: 'Watch drift',
description:
'Prepare for agent-assisted analysis that explains whether upstream changes affect your custom work.',
icon: SearchCheck,
'Refresh GitHub state to see upstream commits waiting, fork-only commits, open pull requests, sync health, and merge history.',
},
{
title: 'Merge request history',
title: 'Review safely',
description:
'Keep a durable timeline of upstream checks, review outcomes, and merge request decisions.',
'Use AI compatibility reviews to summarize upstream changes, flag important files, and decide when a manual review is needed.',
},
{
title: 'Ship through PRs',
description:
'Queue agent jobs that work on fresh branches and open draft pull requests, while GitHub remains the source of truth.',
},
];
const features = [
{
title: 'Project dashboards',
description:
'Each Spoon gets a focused dashboard with upstream drift, fork-only changes, pull requests, AI reviews, activity, settings, clone URLs, and extra remotes.',
icon: GitCompare,
},
{
title: 'Upstream maintenance queue',
description:
'The global dashboard makes it obvious which forks are up to date, behind, ahead, diverged, or waiting for review.',
icon: RefreshCw,
},
{
title: 'Pull request visibility',
description:
'Spoon caches fork pull requests and relevant upstream pull requests so maintenance decisions are tied to real GitHub activity.',
icon: GitPullRequest,
},
{
title: 'AI compatibility review',
description:
'OpenAI reviews upstream changes against fork-only commits and returns structured risk, summary, recommended action, and conflict signals.',
icon: Sparkles,
},
{
title: 'Per-user AI settings',
description:
'Users bring their own OpenAI API key, choose a review model, and set reasoning effort. Keys are encrypted before storage.',
icon: KeyRound,
},
{
title: 'Agent job foundation',
description:
'Spoon can queue coding-agent work per project with selected secrets, job settings, event logs, artifacts, and draft PR targets.',
icon: Bot,
},
];
const builtFor = [
{
title: 'Self-hosted by design',
description:
'Run the app, Convex backend, Postgres, and optional agent worker on your own server so code automation stays under your control.',
icon: ServerCog,
},
{
title: 'Secrets stay deliberate',
description:
'Project secrets are per Spoon and selected per job. The agent runtime never receives every secret by default.',
icon: LockKeyhole,
},
{
title: 'Outside work is expected',
description:
'Spoon assumes you may push to GitHub directly, edit locally, or use another CI system. Refresh always starts from current GitHub state.',
icon: Code2,
},
{
title: 'History stays inspectable',
description:
'Sync runs, AI reviews, job logs, PR URLs, errors, and artifacts are stored so maintenance work is reviewable after the fact.',
icon: History,
},
];
export const Workflow = () => {
const steps = [
'Choose upstream',
'Create a Spoon',
'Customize your fork',
'Track upstream',
'Review and merge updates',
];
return (
<section id='workflow' className='border-border/60 bg-muted/30 border-y'>
<div className='container mx-auto px-4 py-16'>
<div className='mb-10 max-w-2xl'>
<h2 className='text-3xl font-semibold tracking-normal'>
A fork workflow that keeps moving
</h2>
<p className='text-muted-foreground mt-3'>
Spoon starts with a provider-neutral model: upstream project,
managed fork, update checks, and reviewable merge requests.
export const Workflow = () => (
<section id='workflow' className='border-border/60 bg-muted/30 border-y'>
<div className='container mx-auto px-4 py-20'>
<div className='mb-12 max-w-3xl'>
<Badge variant='outline' className='mb-4'>
Workflow
</Badge>
<h2 className='text-3xl font-semibold tracking-normal md:text-4xl'>
Forking should not mean drifting alone.
</h2>
<p className='text-muted-foreground mt-4 text-lg leading-8'>
Spoon treats a fork as an ongoing relationship with upstream. The
product keeps the original project, your custom work, and future
automation visible in one place.
</p>
</div>
<div className='grid gap-8 lg:grid-cols-[0.8fr_1.2fr]'>
<div className='border-border bg-background rounded-lg border p-6'>
<GitBranch className='text-primary size-6' />
<h3 className='mt-5 text-xl font-semibold'>
A Spoon is a managed fork
</h3>
<p className='text-muted-foreground mt-3 leading-7'>
It knows where upstream lives, where your fork lives, which branch
matters, what extra remotes you care about, and what rules should
govern updates. That gives maintenance a durable home instead of a
pile of one-off Git commands.
</p>
</div>
<div className='grid gap-3 md:grid-cols-5'>
{steps.map((step, index) => (
<div key={step} className='border-border bg-card border p-4'>
<p className='text-primary text-sm font-semibold'>
<ol className='grid gap-3'>
{workflow.map((step, index) => (
<li
key={step.title}
className='border-border bg-background grid gap-4 rounded-lg border p-5 sm:grid-cols-[4rem_1fr]'
>
<span className='text-primary text-sm font-semibold'>
{String(index + 1).padStart(2, '0')}
</p>
<p className='mt-4 text-sm font-medium'>{step}</p>
</div>
</span>
<div>
<h3 className='font-semibold'>{step.title}</h3>
<p className='text-muted-foreground mt-1 text-sm leading-6'>
{step.description}
</p>
</div>
</li>
))}
</div>
</ol>
</div>
</section>
);
};
</div>
</section>
);
export const Features = () => (
<section id='maintenance' className='container mx-auto px-4 py-20'>
<div className='mb-10 max-w-2xl'>
<h2 className='text-3xl font-semibold tracking-normal'>
Maintenance is the product
</h2>
<p className='text-muted-foreground mt-3'>
The first version establishes the dashboard surfaces and records that
future Git provider integrations and AI review jobs will use.
<section id='features' className='container mx-auto px-4 py-24'>
<div className='mb-12 flex flex-col justify-between gap-6 lg:flex-row lg:items-end'>
<div className='max-w-3xl'>
<Badge variant='outline' className='mb-4'>
Product surface
</Badge>
<h2 className='text-3xl font-semibold tracking-normal md:text-4xl'>
Everything important about a fork, without opening six tabs.
</h2>
</div>
<p className='text-muted-foreground max-w-xl leading-7'>
Spoon is not trying to replace GitHub. It is the layer that explains how
your fork relates to upstream and what should happen next.
</p>
</div>
<div className='grid gap-4 md:grid-cols-2 lg:grid-cols-4'>
{maintenance.map(({ title, description, icon: Icon }) => (
<Card key={title} className='border-border/70 shadow-none'>
<CardHeader>
<Icon className='text-primary size-5' />
<CardTitle className='text-base'>{title}</CardTitle>
</CardHeader>
<CardContent>
<p className='text-muted-foreground text-sm leading-6'>
{description}
</p>
</CardContent>
</Card>
<div className='grid gap-px overflow-hidden rounded-lg border md:grid-cols-2 lg:grid-cols-3'>
{features.map(({ title, description, icon: Icon }) => (
<div key={title} className='bg-card p-6'>
<div className='flex items-center gap-3'>
<Icon className='text-primary size-5 shrink-0' />
<h3 className='font-semibold'>{title}</h3>
</div>
<p className='text-muted-foreground mt-3 text-sm leading-6'>
{description}
</p>
</div>
))}
</div>
</section>
@@ -102,41 +197,92 @@ export const Features = () => (
export const Agents = () => (
<section id='agents' className='border-border/60 bg-muted/30 border-y'>
<div className='container mx-auto grid gap-8 px-4 py-20 lg:grid-cols-2'>
<div className='container mx-auto grid gap-10 px-4 py-24 lg:grid-cols-[0.95fr_1.05fr]'>
<div>
<div className='bg-primary/10 text-primary mb-4 flex size-10 items-center justify-center rounded-md'>
<Bot className='size-5' />
</div>
<h2 className='text-3xl font-semibold tracking-normal'>
Agent requests belong next to fork maintenance
<Badge variant='outline' className='mb-4'>
Agent work
</Badge>
<h2 className='text-3xl font-semibold tracking-normal md:text-4xl'>
The agent belongs inside the fork dashboard.
</h2>
<p className='text-muted-foreground mt-4 leading-7'>
Spoon is being shaped so a user can ask an agent to implement a
change, open a merge request against the managed fork, and still keep
upstream updates in view. This pass stores those requests without
running automation yet.
<p className='text-muted-foreground mt-4 text-lg leading-8'>
The goal is simple: ask for a change, let a worker clone the current
fork, expose only the secrets you selected, run checks, push a branch,
and open a draft pull request. The first pieces are already modeled:
encrypted Spoon secrets, agent settings, queued jobs, logs, and
artifacts.
</p>
</div>
<div className='border-border bg-card border p-5'>
<div className='mb-4 flex items-center gap-3'>
<GitMerge className='text-primary size-5' />
<p className='font-medium'>Queued agent request</p>
<div className='border-border bg-background rounded-lg border'>
<div className='border-border border-b p-5'>
<div className='flex items-center gap-3'>
<span className='bg-primary/10 text-primary flex size-9 items-center justify-center rounded-md'>
<Bot className='size-4' />
</span>
<div>
<p className='font-medium'>Draft PR agent flow</p>
<p className='text-muted-foreground text-sm'>
Built for review, not automatic merge.
</p>
</div>
</div>
</div>
<p className='text-muted-foreground text-sm leading-6'>
Add a project-specific onboarding flow, open a merge request, and
flag any upstream files this may affect.
</p>
<div className='mt-5 grid gap-2 text-sm'>
<div className='flex justify-between'>
<span className='text-muted-foreground'>Target</span>
<span>feature/onboarding</span>
</div>
<div className='flex justify-between'>
<span className='text-muted-foreground'>Status</span>
<span>Queued</span>
</div>
<div className='divide-border divide-y'>
{[
['Clone', 'Start from the current GitHub fork state.'],
['Branch', 'Create a short-lived agent branch.'],
['Edit', 'Apply focused changes with selected project context.'],
[
'Check',
'Run configured install, lint, typecheck, or test steps.',
],
['Review', 'Open a draft pull request with logs and artifacts.'],
].map(([phase, detail]) => (
<div key={phase} className='grid gap-3 p-5 sm:grid-cols-[8rem_1fr]'>
<p className='text-sm font-semibold'>{phase}</p>
<p className='text-muted-foreground text-sm leading-6'>
{detail}
</p>
</div>
))}
</div>
</div>
</div>
</section>
);
export const Security = () => (
<section id='security' className='container mx-auto px-4 py-24'>
<div className='grid gap-10 lg:grid-cols-[0.8fr_1.2fr]'>
<div>
<Badge variant='outline' className='mb-4'>
Ownership
</Badge>
<h2 className='text-3xl font-semibold tracking-normal md:text-4xl'>
Useful because it respects how forks are really maintained.
</h2>
<p className='text-muted-foreground mt-4 text-lg leading-8'>
A fork can have local experiments, CI changes, private deployment
settings, and emergency upstream fixes all happening at once. Spoon
keeps those threads visible without pretending every change must come
through the app.
</p>
</div>
<div className='grid gap-4 sm:grid-cols-2'>
{builtFor.map(({ title, description, icon: Icon }) => (
<div key={title} className='border-border rounded-lg border p-5'>
<div className='flex items-center gap-3'>
<Icon className='text-primary size-5 shrink-0' />
<h3 className='font-semibold'>{title}</h3>
</div>
<p className='text-muted-foreground mt-2 text-sm leading-6'>
{description}
</p>
</div>
))}
</div>
</div>
</section>
);
+54 -24
View File
@@ -6,8 +6,10 @@ import {
ArrowRight,
Bot,
CheckCircle2,
CircleDot,
GitBranch,
GitPullRequest,
KeyRound,
ShieldCheck,
} from 'lucide-react';
@@ -15,23 +17,23 @@ import { Badge, Button } from '@spoon/ui';
const previewRows = [
{
name: 'editor-spoon',
upstream: 'upstream/main',
status: 'Clean update',
name: 'gibsend',
upstream: 'usesend/usesend',
status: '3 upstream commits',
icon: CheckCircle2,
tone: 'text-emerald-600',
},
{
name: 'billing-fork',
upstream: 'release/2026.06',
status: 'AI review queued',
name: 'internal-docs',
upstream: 'platform/docs',
status: 'AI review ready',
icon: Bot,
tone: 'text-teal-600',
},
{
name: 'docs-platform',
upstream: 'main',
status: 'Needs review',
name: 'ops-console',
upstream: 'console/main',
status: 'fork-only changes',
icon: GitPullRequest,
tone: 'text-amber-600',
},
@@ -41,19 +43,21 @@ export const Hero = () => {
const { isAuthenticated } = useConvexAuth();
return (
<section className='container mx-auto px-4 py-16 md:py-24'>
<div className='grid items-center gap-10 lg:grid-cols-[0.92fr_1.08fr]'>
<div className='grid items-center gap-12 lg:grid-cols-[0.9fr_1.1fr]'>
<div className='max-w-3xl'>
<Badge variant='outline' className='mb-5 gap-2'>
<ShieldCheck className='size-3.5 text-emerald-600' />
Self-hostable fork maintenance
Self-hostable fork maintenance cockpit
</Badge>
<h1 className='max-w-4xl text-4xl font-semibold tracking-normal text-balance sm:text-5xl md:text-6xl'>
Fork freely. Stay close to upstream.
Make your forks <em className='text-primary'>intimately</em> close
to upstream.
</h1>
<p className='text-muted-foreground mt-6 max-w-2xl text-lg leading-8'>
Spoon helps you customize upstream projects without inheriting the
full maintenance burden. Track drift, review update risk, and keep
managed forks ready for merge requests.
Spoon gives every important fork a living maintenance dashboard.
Track upstream drift, preserve your custom commits, review pull
requests, and queue AI-assisted work without losing sight of the
project you forked from.
</p>
<div className='mt-8 flex flex-col gap-3 sm:flex-row'>
<Button size='lg' asChild>
@@ -66,29 +70,41 @@ export const Hero = () => {
<Link href='#workflow'>See how it works</Link>
</Button>
</div>
<div className='text-muted-foreground mt-8 grid max-w-xl gap-3 text-sm sm:grid-cols-3'>
{[
'GitHub App backed',
'OpenAI key per user',
'Draft PR workflow',
].map((item) => (
<span key={item} className='flex items-center gap-2'>
<CircleDot className='text-primary size-3.5' />
{item}
</span>
))}
</div>
</div>
<div className='border-border bg-card border shadow-sm'>
<div className='border-border bg-card overflow-hidden rounded-lg border shadow-sm'>
<div className='border-border flex items-center justify-between border-b px-5 py-4'>
<div>
<p className='text-sm font-medium'>Spoon dashboard</p>
<p className='text-sm font-medium'>Fork health</p>
<p className='text-muted-foreground text-xs'>
Upstream status across managed forks
Current state across managed Spoons
</p>
</div>
<Badge className='bg-primary/10 text-primary hover:bg-primary/10'>
3 active Spoons
Live GitHub sync
</Badge>
</div>
<div className='grid gap-4 p-5 md:grid-cols-3'>
{[
['Updates', '4', '2 clean'],
['Needs review', '1', 'conflict risk'],
['Agents', '2', 'queued'],
['Behind', '3', 'upstream commits'],
['Fork-only', '12', 'custom changes'],
['AI risk', 'Low', 'reviewed'],
].map(([label, value, note]) => (
<div
key={label}
className='border-border bg-background border p-4'
className='border-border bg-background rounded-md border p-4'
>
<p className='text-muted-foreground text-xs'>{label}</p>
<p className='mt-2 text-2xl font-semibold'>{value}</p>
@@ -100,7 +116,7 @@ export const Hero = () => {
{previewRows.map(({ name, upstream, status, icon: Icon, tone }) => (
<div
key={name}
className='border-border bg-background flex items-center justify-between gap-4 border p-4'
className='border-border bg-background flex items-center justify-between gap-4 rounded-md border p-4'
>
<div className='flex items-center gap-3'>
<span className='bg-muted flex size-9 items-center justify-center rounded-md'>
@@ -118,6 +134,20 @@ export const Hero = () => {
</div>
))}
</div>
<div className='border-border bg-muted/30 grid gap-3 border-t p-5 text-sm sm:grid-cols-2'>
<div className='flex items-start gap-3'>
<KeyRound className='text-primary mt-0.5 size-4' />
<p className='text-muted-foreground'>
User-owned OpenAI keys stay encrypted and selectable.
</p>
</div>
<div className='flex items-start gap-3'>
<GitPullRequest className='text-primary mt-0.5 size-4' />
<p className='text-muted-foreground'>
Agent jobs are shaped around draft pull requests.
</p>
</div>
</div>
</div>
</div>
</section>
+1 -1
View File
@@ -1,3 +1,3 @@
export { Hero } from './hero';
export { Agents, Features, Workflow } from './features';
export { Agents, Features, Security, Workflow } from './features';
export { CTA } from './cta';
@@ -4,7 +4,14 @@ import type { ComponentProps } from 'react';
import Link from 'next/link';
import { SpoonLogo } from '@/components/brand/logo';
import { useConvexAuth } from 'convex/react';
import { Bot, GitBranch, LayoutDashboard, ShieldCheck } from 'lucide-react';
import {
GitBranch,
LayoutDashboard,
RefreshCw,
Settings,
ShieldCheck,
Sparkles,
} from 'lucide-react';
import { Button } from '@spoon/ui';
@@ -14,31 +21,46 @@ import { DesktopNavigation, MobileNavigation } from './navigation';
const Header = (headerProps: ComponentProps<'header'>) => {
const { isAuthenticated } = useConvexAuth();
const navItems: NavItem[] = [
{
href: '/#workflow',
icon: GitBranch,
label: 'How it works',
},
{
href: '/#maintenance',
icon: ShieldCheck,
label: 'Maintenance',
},
{
href: '/#agents',
icon: Bot,
label: 'Agents',
},
];
if (isAuthenticated) {
navItems.push({
href: '/dashboard',
icon: LayoutDashboard,
label: 'Dashboard',
});
}
const navItems: NavItem[] = isAuthenticated
? [
{
href: '/dashboard',
icon: LayoutDashboard,
label: 'Dashboard',
},
{
href: '/spoons',
icon: GitBranch,
label: 'My Spoons',
},
{
href: '/updates',
icon: RefreshCw,
label: 'Updates',
},
{
href: '/settings/profile',
icon: Settings,
label: 'Settings',
},
]
: [
{
href: '/#workflow',
icon: GitBranch,
label: 'Workflow',
},
{
href: '/#features',
icon: Sparkles,
label: 'Features',
},
{
href: '/#security',
icon: ShieldCheck,
label: 'Security',
},
];
return (
<header
@@ -53,7 +75,7 @@ const Header = (headerProps: ComponentProps<'header'>) => {
<div className='flex items-center gap-2'>
{isAuthenticated ? (
<Button size='sm' variant='outline' asChild>
<Link href='/spoons'>My Spoons</Link>
<Link href='/spoons/new'>New Spoon</Link>
</Button>
) : (
<Button size='sm' className='hidden sm:inline-flex' asChild>
@@ -24,6 +24,14 @@ import {
} from '@spoon/ui';
const efforts = ['minimal', 'low', 'medium', 'high', 'xhigh'] as const;
const modelOptions = [
{ value: 'gpt-5.1-codex', label: 'GPT-5.1 Codex' },
{ value: 'gpt-5.5', label: 'GPT-5.5' },
{ value: 'gpt-5.5-pro', label: 'GPT-5.5 Pro' },
{ value: 'gpt-5.4', label: 'GPT-5.4' },
{ value: 'gpt-5.4-mini', label: 'GPT-5.4 Mini' },
] as const;
type AgentModel = (typeof modelOptions)[number]['value'];
type AgentSettings = {
enabled: boolean;
@@ -36,6 +44,11 @@ type AgentSettings = {
reasoningEffort: 'none' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh';
};
const toAgentModel = (value?: string): AgentModel =>
modelOptions.some((option) => option.value === value)
? (value as AgentModel)
: 'gpt-5.1-codex';
export const SpoonAgentSettingsForm = ({
spoon,
settings,
@@ -60,8 +73,8 @@ export const SpoonAgentSettingsForm = ({
settings?.checkCommand ?? '',
);
const [testCommand, setTestCommand] = useState(settings?.testCommand ?? '');
const [agentModel, setAgentModel] = useState(
settings?.agentModel ?? 'gpt-5.1-codex',
const [agentModel, setAgentModel] = useState<AgentModel>(
toAgentModel(settings?.agentModel),
);
const [reasoningEffort, setReasoningEffort] = useState<
'minimal' | 'low' | 'medium' | 'high' | 'xhigh'
@@ -127,11 +140,21 @@ export const SpoonAgentSettingsForm = ({
</div>
<div className='grid gap-2'>
<Label htmlFor='agentModel'>Model</Label>
<Input
id='agentModel'
<Select
value={agentModel}
onChange={(event) => setAgentModel(event.target.value)}
/>
onValueChange={(value) => setAgentModel(value as AgentModel)}
>
<SelectTrigger id='agentModel'>
<SelectValue />
</SelectTrigger>
<SelectContent>
{modelOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className='grid gap-2'>
<Label>Reasoning effort</Label>
@@ -160,27 +183,37 @@ export const SpoonAgentSettingsForm = ({
<Input
id='installCommand'
value={installCommand}
placeholder='bun install'
placeholder='Auto-detect from lockfile'
onChange={(event) => setInstallCommand(event.target.value)}
/>
<p className='text-muted-foreground text-xs'>
Leave blank to inspect the repository and choose bun, pnpm, yarn,
or npm.
</p>
</div>
<div className='grid gap-2'>
<Label htmlFor='checkCommand'>Check command</Label>
<Input
id='checkCommand'
value={checkCommand}
placeholder='bun typecheck'
placeholder='Auto-detect typecheck or lint'
onChange={(event) => setCheckCommand(event.target.value)}
/>
<p className='text-muted-foreground text-xs'>
Leave blank to read package.json scripts after cloning.
</p>
</div>
<div className='grid gap-2'>
<Label htmlFor='testCommand'>Test command</Label>
<Input
id='testCommand'
value={testCommand}
placeholder='bun test'
placeholder='Auto-detect test script'
onChange={(event) => setTestCommand(event.target.value)}
/>
<p className='text-muted-foreground text-xs'>
Leave blank to run the detected test script when one exists.
</p>
</div>
</div>
<Button type='button' onClick={save}>
+44 -44
View File
@@ -10,49 +10,49 @@ const formatDate = (value?: number) =>
: 'Never';
export const SpoonCard = ({ spoon }: { spoon: Doc<'spoons'> }) => (
<Card className='hover:border-primary/50 shadow-none transition-colors'>
<CardHeader className='flex-row items-start justify-between gap-4'>
<div>
<CardTitle className='text-lg'>
<Link href={`/spoons/${spoon._id}`} className='hover:underline'>
<Link href={`/spoons/${spoon._id}`} className='group/spoon-card block'>
<Card className='group-hover/spoon-card:border-primary/50 group-hover/spoon-card:bg-muted/20 shadow-none transition-colors'>
<CardHeader className='flex-row items-start justify-between gap-4'>
<div>
<CardTitle className='group-hover/spoon-card:text-primary text-lg transition-colors'>
{spoon.name}
</Link>
</CardTitle>
<p className='text-muted-foreground mt-1 text-sm'>
{spoon.upstreamOwner}/{spoon.upstreamRepo}
</p>
</div>
<SpoonStatusBadge status={spoon.syncStatus ?? spoon.status} />
</CardHeader>
<CardContent className='grid gap-3 text-sm md:grid-cols-2'>
<div>
<p className='text-muted-foreground'>Provider</p>
<p className='font-medium'>{spoon.provider}</p>
</div>
<div>
<p className='text-muted-foreground'>Cadence</p>
<p className='font-medium'>{spoon.syncCadence}</p>
</div>
<div>
<p className='text-muted-foreground'>Fork</p>
<p className='font-medium'>
{spoon.forkOwner && spoon.forkRepo
? `${spoon.forkOwner}/${spoon.forkRepo}`
: 'Not connected'}
</p>
</div>
<div>
<p className='text-muted-foreground'>Last checked</p>
<p className='font-medium'>{formatDate(spoon.lastCheckedAt)}</p>
</div>
<div>
<p className='text-muted-foreground'>Upstream waiting</p>
<p className='font-medium'>{spoon.upstreamAheadBy ?? 0}</p>
</div>
<div>
<p className='text-muted-foreground'>Fork-only commits</p>
<p className='font-medium'>{spoon.forkAheadBy ?? 0}</p>
</div>
</CardContent>
</Card>
</CardTitle>
<p className='text-muted-foreground mt-1 text-sm'>
{spoon.upstreamOwner}/{spoon.upstreamRepo}
</p>
</div>
<SpoonStatusBadge status={spoon.syncStatus ?? spoon.status} />
</CardHeader>
<CardContent className='grid gap-3 text-sm md:grid-cols-2'>
<div>
<p className='text-muted-foreground'>Provider</p>
<p className='font-medium'>{spoon.provider}</p>
</div>
<div>
<p className='text-muted-foreground'>Cadence</p>
<p className='font-medium'>{spoon.syncCadence}</p>
</div>
<div>
<p className='text-muted-foreground'>Fork</p>
<p className='font-medium'>
{spoon.forkOwner && spoon.forkRepo
? `${spoon.forkOwner}/${spoon.forkRepo}`
: 'Not connected'}
</p>
</div>
<div>
<p className='text-muted-foreground'>Last checked</p>
<p className='font-medium'>{formatDate(spoon.lastCheckedAt)}</p>
</div>
<div>
<p className='text-muted-foreground'>Upstream waiting</p>
<p className='font-medium'>{spoon.upstreamAheadBy ?? 0}</p>
</div>
<div>
<p className='text-muted-foreground'>Fork-only commits</p>
<p className='font-medium'>{spoon.forkAheadBy ?? 0}</p>
</div>
</CardContent>
</Card>
</Link>
);
@@ -13,6 +13,13 @@ import {
CardContent,
CardHeader,
CardTitle,
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
Input,
Label,
} from '@spoon/ui';
@@ -102,6 +109,7 @@ export const SpoonClonePanel = ({ spoon }: { spoon: Doc<'spoons'> }) => {
const [remoteName, setRemoteName] = useState('');
const [url, setUrl] = useState('');
const [submitting, setSubmitting] = useState(false);
const [remoteDialogOpen, setRemoteDialogOpen] = useState(false);
const cloneUrl = spoon.forkUrl;
const addRemote = async (event: React.FormEvent<HTMLFormElement>) => {
@@ -117,6 +125,7 @@ export const SpoonClonePanel = ({ spoon }: { spoon: Doc<'spoons'> }) => {
setLabel('');
setRemoteName('');
setUrl('');
setRemoteDialogOpen(false);
toast.success('Remote added.');
} catch (error) {
console.error(error);
@@ -128,8 +137,64 @@ export const SpoonClonePanel = ({ spoon }: { spoon: Doc<'spoons'> }) => {
return (
<Card className='shadow-none'>
<CardHeader className='pb-3'>
<CardHeader className='flex-row items-center justify-between gap-4 pb-3'>
<CardTitle className='text-base'>Clone your fork</CardTitle>
<Dialog open={remoteDialogOpen} onOpenChange={setRemoteDialogOpen}>
<DialogTrigger asChild>
<Button type='button' variant='outline' size='sm'>
<Plus className='size-4' />
Add remote
</Button>
</DialogTrigger>
<DialogContent className='sm:max-w-lg'>
<DialogHeader>
<DialogTitle>Add git remote</DialogTitle>
<DialogDescription>
Store another repository URL to copy from this Spoon. GitHub
remains the source of truth for upstream maintenance.
</DialogDescription>
</DialogHeader>
<form onSubmit={addRemote} className='space-y-4'>
<div className='grid gap-3 md:grid-cols-[1fr_0.7fr]'>
<div className='grid gap-2'>
<Label htmlFor='remote-label'>Label</Label>
<Input
id='remote-label'
value={label}
placeholder='Gitea mirror'
required
onChange={(event) => setLabel(event.target.value)}
/>
</div>
<div className='grid gap-2'>
<Label htmlFor='remote-name'>Git remote name</Label>
<Input
id='remote-name'
value={remoteName}
placeholder='gitea'
onChange={(event) => setRemoteName(event.target.value)}
/>
</div>
</div>
<div className='grid gap-2'>
<Label htmlFor='remote-url'>Repository URL</Label>
<Input
id='remote-url'
value={url}
placeholder='https://git.example.com/you/project.git'
required
onChange={(event) => setUrl(event.target.value)}
/>
</div>
<DialogFooter className='mt-2'>
<Button type='submit' disabled={submitting}>
<Plus className='size-4' />
{submitting ? 'Adding...' : 'Add remote'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</CardHeader>
<CardContent className='space-y-3'>
{cloneUrl ? (
@@ -165,46 +230,6 @@ export const SpoonClonePanel = ({ spoon }: { spoon: Doc<'spoons'> }) => {
))}
</div>
) : null}
<form
onSubmit={addRemote}
className='border-border space-y-3 border-t pt-4'
>
<div className='grid gap-3 md:grid-cols-[1fr_0.7fr]'>
<div className='grid gap-2'>
<Label htmlFor='remote-label'>Label</Label>
<Input
id='remote-label'
value={label}
placeholder='Gitea mirror'
required
onChange={(event) => setLabel(event.target.value)}
/>
</div>
<div className='grid gap-2'>
<Label htmlFor='remote-name'>Git remote name</Label>
<Input
id='remote-name'
value={remoteName}
placeholder='gitea'
onChange={(event) => setRemoteName(event.target.value)}
/>
</div>
</div>
<div className='grid gap-2'>
<Label htmlFor='remote-url'>Repository URL</Label>
<Input
id='remote-url'
value={url}
placeholder='https://git.example.com/you/project.git'
required
onChange={(event) => setUrl(event.target.value)}
/>
</div>
<Button type='submit' variant='outline' disabled={submitting}>
<Plus className='size-4' />
{submitting ? 'Adding...' : 'Add remote'}
</Button>
</form>
</CardContent>
</Card>
);
@@ -95,6 +95,11 @@ export const SpoonDetailHeader = ({
<span>Fork metadata missing</span>
)}
</div>
{spoon.description ? (
<p className='text-muted-foreground max-w-3xl text-sm leading-6'>
{spoon.description}
</p>
) : null}
</div>
<div className='flex shrink-0 flex-wrap gap-2'>
<Button
@@ -13,9 +13,21 @@ import {
CardHeader,
CardTitle,
Label,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
Switch,
} from '@spoon/ui';
type MaintenanceMode = 'watch' | 'auto_pr' | 'paused';
type SyncCadence = 'daily' | 'weekly' | 'manual';
type ProductionRefStrategy =
| 'default_branch'
| 'latest_release'
| 'tag_pattern';
export const SpoonSettingsForm = ({
spoon,
settings,
@@ -24,6 +36,15 @@ export const SpoonSettingsForm = ({
settings?: Doc<'spoonSettings'> | null;
}) => {
const update = useMutation(api.spoonSettings.update);
const updateSpoonSettings = useMutation(api.spoons.updateSettings);
const [syncCadence, setSyncCadence] = useState<SyncCadence>(
spoon.syncCadence,
);
const [maintenanceMode, setMaintenanceMode] = useState<MaintenanceMode>(
spoon.maintenanceMode,
);
const [productionRefStrategy, setProductionRefStrategy] =
useState<ProductionRefStrategy>(spoon.productionRefStrategy);
const [autoRefreshEnabled, setAutoRefreshEnabled] = useState(
settings?.autoRefreshEnabled ?? true,
);
@@ -42,14 +63,22 @@ export const SpoonSettingsForm = ({
const save = async () => {
try {
await update({
spoonId: spoon._id,
autoRefreshEnabled,
autoReviewEnabled,
autoSyncEnabled,
requireAiLowRiskForSync,
requireCleanCompareForSync,
});
await Promise.all([
updateSpoonSettings({
spoonId: spoon._id,
syncCadence,
maintenanceMode,
productionRefStrategy,
}),
update({
spoonId: spoon._id,
autoRefreshEnabled,
autoReviewEnabled,
autoSyncEnabled,
requireAiLowRiskForSync,
requireCleanCompareForSync,
}),
]);
toast.success('Spoon settings saved.');
} catch (error) {
console.error(error);
@@ -92,21 +121,57 @@ export const SpoonSettingsForm = ({
</CardHeader>
<CardContent className='space-y-4'>
<div className='grid gap-3 md:grid-cols-2'>
<div>
<p className='text-muted-foreground text-xs'>Sync cadence</p>
<p className='font-medium'>{spoon.syncCadence}</p>
<div className='grid gap-2'>
<Label>Sync cadence</Label>
<Select
value={syncCadence}
onValueChange={(value) => setSyncCadence(value as SyncCadence)}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value='daily'>Daily</SelectItem>
<SelectItem value='weekly'>Weekly</SelectItem>
<SelectItem value='manual'>Manual</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<p className='text-muted-foreground text-xs'>Maintenance mode</p>
<p className='font-medium'>
{spoon.maintenanceMode.replaceAll('_', ' ')}
</p>
<div className='grid gap-2'>
<Label>Maintenance mode</Label>
<Select
value={maintenanceMode}
onValueChange={(value) =>
setMaintenanceMode(value as MaintenanceMode)
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value='watch'>Watch only</SelectItem>
<SelectItem value='auto_pr'>Prepare merge requests</SelectItem>
<SelectItem value='paused'>Paused</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<p className='text-muted-foreground text-xs'>Production ref</p>
<p className='font-medium'>
{spoon.productionRefStrategy.replaceAll('_', ' ')}
</p>
<div className='grid gap-2'>
<Label>Production ref</Label>
<Select
value={productionRefStrategy}
onValueChange={(value) =>
setProductionRefStrategy(value as ProductionRefStrategy)
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value='default_branch'>Default branch</SelectItem>
<SelectItem value='latest_release'>Latest release</SelectItem>
<SelectItem value='tag_pattern'>Tag pattern</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className='space-y-3'>
+1 -1
View File
@@ -25,7 +25,7 @@ describe('component test harness', () => {
render(<Hero />);
expect(
screen.getByRole('heading', {
name: /fork freely\. stay close to upstream\./i,
name: /make your forks intimately close to upstream\./i,
}),
).toBeInTheDocument();
});