Add agent workflows & stuff
This commit is contained in:
@@ -120,7 +120,12 @@ const AgentsPage = () => {
|
||||
<div key={request._id} className='border-border border p-4'>
|
||||
<p className='font-medium'>{request.prompt}</p>
|
||||
<p className='text-muted-foreground mt-1 text-sm'>
|
||||
{request.status.replaceAll('_', ' ')}
|
||||
{request.status.replaceAll('_', ' ')} ·{' '}
|
||||
{(request.requestType ?? 'future_code_change').replaceAll(
|
||||
'_',
|
||||
' ',
|
||||
)}{' '}
|
||||
· {request.source ?? 'user'}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -3,8 +3,15 @@
|
||||
import Link from 'next/link';
|
||||
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 } from 'lucide-react';
|
||||
import {
|
||||
Bot,
|
||||
GitBranch,
|
||||
GitPullRequest,
|
||||
RefreshCw,
|
||||
ShieldCheck,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { api } from '@spoon/backend/convex/_generated/api.js';
|
||||
import { Button, Card, CardContent, CardHeader, CardTitle } from '@spoon/ui';
|
||||
@@ -14,12 +21,18 @@ const DashboardPage = () => {
|
||||
const syncRuns = useQuery(api.syncRuns.listRecent, { limit: 5 }) ?? [];
|
||||
const agentRequests =
|
||||
useQuery(api.agentRequests.listRecent, { limit: 5 }) ?? [];
|
||||
const aiReviews = useQuery(api.aiReviews.listRecent, { limit: 5 }) ?? [];
|
||||
const activeSpoons = spoons.filter(
|
||||
(spoon) => spoon.status === 'active',
|
||||
).length;
|
||||
const needsReview = syncRuns.filter(
|
||||
(run) => run.status === 'needs_review',
|
||||
const behind = spoons.filter((spoon) => spoon.syncStatus === 'behind').length;
|
||||
const diverged = spoons.filter(
|
||||
(spoon) => spoon.syncStatus === 'diverged',
|
||||
).length;
|
||||
const openPullRequests = spoons.reduce(
|
||||
(total, spoon) => total + (spoon.upstreamAheadBy ?? 0),
|
||||
0,
|
||||
);
|
||||
|
||||
return (
|
||||
<main className='space-y-6'>
|
||||
@@ -49,9 +62,9 @@ const DashboardPage = () => {
|
||||
icon={GitPullRequest}
|
||||
/>
|
||||
<MetricCard
|
||||
label='Needs review'
|
||||
value={needsReview}
|
||||
note='Upstream updates'
|
||||
label='Behind upstream'
|
||||
value={behind}
|
||||
note={`${diverged} diverged`}
|
||||
icon={RefreshCw}
|
||||
/>
|
||||
<MetricCard
|
||||
@@ -60,8 +73,19 @@ const DashboardPage = () => {
|
||||
note='Queued and recent'
|
||||
icon={Bot}
|
||||
/>
|
||||
<MetricCard
|
||||
label='Upstream commits'
|
||||
value={openPullRequests}
|
||||
note='Waiting across Spoons'
|
||||
icon={ShieldCheck}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<section className='space-y-3'>
|
||||
<h2 className='text-lg font-semibold'>Maintenance queue</h2>
|
||||
<MaintenanceQueue spoons={spoons} />
|
||||
</section>
|
||||
|
||||
<div className='grid gap-6 xl:grid-cols-2'>
|
||||
<section className='space-y-3'>
|
||||
<h2 className='text-lg font-semibold'>Recent Spoons</h2>
|
||||
@@ -112,6 +136,35 @@ const DashboardPage = () => {
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className='mt-4 shadow-none'>
|
||||
<CardHeader>
|
||||
<CardTitle className='text-base'>AI reviews</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{aiReviews.length ? (
|
||||
<div className='space-y-3'>
|
||||
{aiReviews.map((review) => (
|
||||
<div
|
||||
key={review._id}
|
||||
className='border-border border p-3 text-sm'
|
||||
>
|
||||
<p className='font-medium capitalize'>
|
||||
{review.risk} risk
|
||||
</p>
|
||||
<p className='text-muted-foreground'>
|
||||
{review.outputSummary ?? review.inputSummary}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className='text-muted-foreground text-sm'>
|
||||
OpenAI compatibility reviews will appear here after you run
|
||||
them on a Spoon.
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { GitHubConnectClient } from '@/components/github/github-connect-client';
|
||||
|
||||
export default async function Page({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ installation_id?: string }>;
|
||||
}) {
|
||||
const params = await searchParams;
|
||||
return (
|
||||
<main className='space-y-6'>
|
||||
<div>
|
||||
<h1 className='text-3xl font-semibold tracking-normal'>
|
||||
Connect GitHub
|
||||
</h1>
|
||||
<p className='text-muted-foreground mt-2 max-w-2xl'>
|
||||
Spoon stores the GitHub App installation ID and uses short-lived
|
||||
installation tokens for repository automation.
|
||||
</p>
|
||||
</div>
|
||||
<GitHubConnectClient installationId={params.installation_id} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { OpenAiStatusPanel } from '@/components/integrations/openai-status-panel';
|
||||
|
||||
const AiSettingsPage = () => (
|
||||
<section className='max-w-3xl space-y-4'>
|
||||
<div>
|
||||
<h2 className='text-xl font-semibold'>AI</h2>
|
||||
<p className='text-muted-foreground mt-1 text-sm'>
|
||||
Configure the OpenAI key, review model, and thinking level used for
|
||||
compatibility reviews.
|
||||
</p>
|
||||
</div>
|
||||
<OpenAiStatusPanel />
|
||||
</section>
|
||||
);
|
||||
|
||||
export default AiSettingsPage;
|
||||
@@ -0,0 +1,15 @@
|
||||
import { GithubIntegrationPanel } from '@/components/integrations/github-integration-panel';
|
||||
|
||||
const IntegrationsPage = () => (
|
||||
<section className='max-w-3xl space-y-4'>
|
||||
<div>
|
||||
<h2 className='text-xl font-semibold'>Integrations</h2>
|
||||
<p className='text-muted-foreground mt-1 text-sm'>
|
||||
Provider access used by Spoon maintenance workflows.
|
||||
</p>
|
||||
</div>
|
||||
<GithubIntegrationPanel />
|
||||
</section>
|
||||
);
|
||||
|
||||
export default IntegrationsPage;
|
||||
@@ -0,0 +1,54 @@
|
||||
'use client';
|
||||
|
||||
import type { ReactNode } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { Brain, Github, Shield, User } from 'lucide-react';
|
||||
|
||||
import { cn } from '@spoon/ui';
|
||||
|
||||
const settingsItems = [
|
||||
{ href: '/settings/profile', label: 'Profile', icon: User },
|
||||
{ href: '/settings/integrations', label: 'Integrations', icon: Github },
|
||||
{ href: '/settings/ai', label: 'AI', icon: Brain },
|
||||
{ href: '/settings/security', label: 'Security', icon: Shield },
|
||||
];
|
||||
|
||||
const SettingsLayout = ({ children }: { children: ReactNode }) => {
|
||||
const pathname = usePathname();
|
||||
return (
|
||||
<main className='space-y-6'>
|
||||
<div className='flex flex-col justify-between gap-4 border-b pb-5 lg:flex-row lg:items-end'>
|
||||
<div>
|
||||
<h1 className='text-3xl font-semibold tracking-normal'>Settings</h1>
|
||||
<p className='text-muted-foreground mt-2'>
|
||||
Account, provider, AI, and security controls for this Spoon
|
||||
workspace.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className='grid gap-6 xl:grid-cols-[13rem_1fr]'>
|
||||
<nav className='border-border bg-card flex gap-1 overflow-x-auto border p-2 xl:flex-col xl:self-start'>
|
||||
{settingsItems.map(({ href, label, icon: Icon }) => (
|
||||
<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',
|
||||
pathname === href
|
||||
? 'bg-primary/10 text-primary'
|
||||
: 'text-muted-foreground hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
<Icon className='size-4' />
|
||||
{label}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
<div className='min-w-0'>{children}</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
};
|
||||
|
||||
export default SettingsLayout;
|
||||
@@ -0,0 +1,7 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
const SettingsPage = () => {
|
||||
redirect('/settings/profile');
|
||||
};
|
||||
|
||||
export default SettingsPage;
|
||||
@@ -0,0 +1,42 @@
|
||||
'use server';
|
||||
|
||||
import {
|
||||
AvatarUpload,
|
||||
ProfileHeader,
|
||||
ResetPasswordForm,
|
||||
UserInfoForm,
|
||||
} from '@/components/layout/auth/profile';
|
||||
import { preloadQuery } from 'convex/nextjs';
|
||||
|
||||
import { api } from '@spoon/backend/convex/_generated/api.js';
|
||||
import { Card, Separator } from '@spoon/ui';
|
||||
|
||||
const SettingsProfilePage = async () => {
|
||||
const preloadedUser = await preloadQuery(api.auth.getUser, {});
|
||||
const preloadedUserProvider = await preloadQuery(
|
||||
api.auth.getUserProvider,
|
||||
{},
|
||||
);
|
||||
return (
|
||||
<section className='max-w-3xl space-y-4'>
|
||||
<div>
|
||||
<h2 className='text-xl font-semibold'>Profile</h2>
|
||||
<p className='text-muted-foreground mt-1 text-sm'>
|
||||
Manage your identity, avatar, and account email.
|
||||
</p>
|
||||
</div>
|
||||
<Card className='shadow-none'>
|
||||
<ProfileHeader />
|
||||
<AvatarUpload preloadedUser={preloadedUser} />
|
||||
<Separator className='my-6' />
|
||||
<UserInfoForm
|
||||
preloadedUser={preloadedUser}
|
||||
preloadedProvider={preloadedUserProvider}
|
||||
/>
|
||||
<ResetPasswordForm preloadedProvider={preloadedUserProvider} />
|
||||
</Card>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default SettingsProfilePage;
|
||||
@@ -0,0 +1,19 @@
|
||||
import { SignOutForm } from '@/components/layout/auth/profile';
|
||||
|
||||
import { Card } from '@spoon/ui';
|
||||
|
||||
const SecuritySettingsPage = () => (
|
||||
<section className='max-w-3xl space-y-4'>
|
||||
<div>
|
||||
<h2 className='text-xl font-semibold'>Security</h2>
|
||||
<p className='text-muted-foreground mt-1 text-sm'>
|
||||
Session controls and security-sensitive account actions.
|
||||
</p>
|
||||
</div>
|
||||
<Card className='shadow-none'>
|
||||
<SignOutForm />
|
||||
</Card>
|
||||
</section>
|
||||
);
|
||||
|
||||
export default SecuritySettingsPage;
|
||||
@@ -0,0 +1,281 @@
|
||||
'use client';
|
||||
|
||||
import { useParams } from 'next/navigation';
|
||||
import { AgentJobList } from '@/components/agents/agent-job-list';
|
||||
import { AgentRequestForm } from '@/components/agents/agent-request-form';
|
||||
import { SpoonActivityTimeline } from '@/components/spoons/spoon-activity-timeline';
|
||||
import { SpoonAgentSettingsForm } from '@/components/spoons/spoon-agent-settings-form';
|
||||
import { SpoonAiReviewPanel } from '@/components/spoons/spoon-ai-review-panel';
|
||||
import { SpoonClonePanel } from '@/components/spoons/spoon-clone-panel';
|
||||
import { SpoonCommitList } from '@/components/spoons/spoon-commit-list';
|
||||
import { SpoonDetailHeader } from '@/components/spoons/spoon-detail-header';
|
||||
import { SpoonMetrics } from '@/components/spoons/spoon-metrics';
|
||||
import { SpoonPrList } from '@/components/spoons/spoon-pr-list';
|
||||
import { SpoonSecretsForm } from '@/components/spoons/spoon-secrets-form';
|
||||
import { SpoonSettingsForm } from '@/components/spoons/spoon-settings-form';
|
||||
import { useQuery } from 'convex/react';
|
||||
|
||||
import type { Id } from '@spoon/backend/convex/_generated/dataModel.js';
|
||||
import { api } from '@spoon/backend/convex/_generated/api.js';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
Tabs,
|
||||
TabsContent,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
} from '@spoon/ui';
|
||||
|
||||
const SpoonDetailPage = () => {
|
||||
const params = useParams<{ spoonId: string }>();
|
||||
const spoonId = params.spoonId as Id<'spoons'>;
|
||||
const details = useQuery(api.spoons.getDetails, { spoonId });
|
||||
const upstreamCommits =
|
||||
useQuery(api.spoonCommits.listForSpoon, {
|
||||
spoonId,
|
||||
side: 'upstream',
|
||||
limit: 100,
|
||||
}) ?? [];
|
||||
const forkCommits =
|
||||
useQuery(api.spoonCommits.listForSpoon, {
|
||||
spoonId,
|
||||
side: 'fork',
|
||||
limit: 100,
|
||||
}) ?? [];
|
||||
const pullRequests =
|
||||
useQuery(api.spoonPullRequests.listForSpoon, { spoonId, limit: 100 }) ?? [];
|
||||
const reviews =
|
||||
useQuery(api.aiReviews.listForSpoon, { spoonId, limit: 25 }) ?? [];
|
||||
const syncRuns =
|
||||
useQuery(api.syncRuns.listForSpoon, { spoonId, limit: 25 }) ?? [];
|
||||
const agentRequests =
|
||||
useQuery(api.agentRequests.listForSpoon, { spoonId, limit: 25 }) ?? [];
|
||||
const agentSettings = useQuery(api.spoonAgentSettings.getForSpoon, {
|
||||
spoonId,
|
||||
});
|
||||
const agentJobs =
|
||||
useQuery(api.agentJobs.listForSpoon, { spoonId, limit: 25 }) ?? [];
|
||||
|
||||
if (details === undefined) {
|
||||
return <main className='text-muted-foreground p-6'>Loading Spoon...</main>;
|
||||
}
|
||||
|
||||
return (
|
||||
<main className='space-y-6'>
|
||||
<SpoonDetailHeader spoon={details.spoon} state={details.state} />
|
||||
<SpoonMetrics
|
||||
spoon={details.spoon}
|
||||
state={details.state}
|
||||
latestReview={details.latestReview}
|
||||
/>
|
||||
{details.spoon.lastError ? (
|
||||
<Card className='border-destructive shadow-none'>
|
||||
<CardContent className='p-4 text-sm'>
|
||||
{details.spoon.lastError}
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
<Tabs defaultValue='overview' className='flex flex-col gap-5'>
|
||||
<TabsList
|
||||
variant='line'
|
||||
className='border-border flex h-auto w-full justify-start overflow-x-auto rounded-none border-b p-0'
|
||||
>
|
||||
<TabsTrigger className='h-9 flex-none px-3' value='overview'>
|
||||
Overview
|
||||
</TabsTrigger>
|
||||
<TabsTrigger className='h-9 flex-none px-3' value='upstream'>
|
||||
Upstream
|
||||
</TabsTrigger>
|
||||
<TabsTrigger className='h-9 flex-none px-3' value='fork'>
|
||||
Fork changes
|
||||
</TabsTrigger>
|
||||
<TabsTrigger className='h-9 flex-none px-3' value='pulls'>
|
||||
Pull requests
|
||||
</TabsTrigger>
|
||||
<TabsTrigger className='h-9 flex-none px-3' value='ai'>
|
||||
AI review
|
||||
</TabsTrigger>
|
||||
<TabsTrigger className='h-9 flex-none px-3' value='agent'>
|
||||
Agent work
|
||||
</TabsTrigger>
|
||||
<TabsTrigger className='h-9 flex-none px-3' value='activity'>
|
||||
Activity
|
||||
</TabsTrigger>
|
||||
<TabsTrigger className='h-9 flex-none px-3' value='settings'>
|
||||
Settings
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value='overview' className='space-y-4'>
|
||||
<div className='grid gap-4 xl:grid-cols-[1.15fr_0.85fr]'>
|
||||
<Card className='shadow-none'>
|
||||
<CardHeader className='pb-3'>
|
||||
<CardTitle className='text-base'>Repository health</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className='grid gap-4 text-sm md:grid-cols-2'>
|
||||
<div>
|
||||
<p className='text-muted-foreground'>Drift state</p>
|
||||
<p className='mt-1 text-xl font-semibold capitalize'>
|
||||
{(
|
||||
details.state?.status ??
|
||||
details.spoon.syncStatus ??
|
||||
'unknown'
|
||||
).replaceAll('_', ' ')}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className='text-muted-foreground'>Default branches</p>
|
||||
<p className='mt-1 font-medium'>
|
||||
{details.state?.upstreamDefaultBranch ??
|
||||
details.spoon.upstreamDefaultBranch}{' '}
|
||||
→{' '}
|
||||
{details.state?.forkDefaultBranch ??
|
||||
details.spoon.forkDefaultBranch ??
|
||||
details.spoon.upstreamDefaultBranch}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className='text-muted-foreground'>Merge base</p>
|
||||
<p className='mt-1 truncate font-mono text-xs'>
|
||||
{details.state?.mergeBaseSha ??
|
||||
details.spoon.lastMergeBaseCommit ??
|
||||
'Unknown'}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className='text-muted-foreground'>Cadence</p>
|
||||
<p className='mt-1 font-medium capitalize'>
|
||||
{details.spoon.syncCadence}
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className='shadow-none'>
|
||||
<CardHeader className='pb-3'>
|
||||
<CardTitle className='text-base'>Latest AI review</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className='space-y-3 text-sm'>
|
||||
{details.latestReview ? (
|
||||
<>
|
||||
<div className='grid grid-cols-2 gap-3'>
|
||||
<div>
|
||||
<p className='text-muted-foreground'>Risk</p>
|
||||
<p className='mt-1 font-semibold capitalize'>
|
||||
{details.latestReview.risk}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className='text-muted-foreground'>Action</p>
|
||||
<p className='mt-1 font-semibold capitalize'>
|
||||
{details.latestReview.recommendedAction.replaceAll(
|
||||
'_',
|
||||
' ',
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className='text-muted-foreground'>
|
||||
{details.latestReview.outputSummary ??
|
||||
details.latestReview.inputSummary}
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<p className='text-muted-foreground'>
|
||||
Run a refresh and AI review to get a compatibility summary
|
||||
for upstream changes.
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<SpoonClonePanel spoon={details.spoon} />
|
||||
</div>
|
||||
|
||||
<div className='grid gap-4 xl:grid-cols-2'>
|
||||
<section className='space-y-3'>
|
||||
<div>
|
||||
<h2 className='text-base font-semibold'>Upstream waiting</h2>
|
||||
<p className='text-muted-foreground text-sm'>
|
||||
Commits upstream has that your fork does not.
|
||||
</p>
|
||||
</div>
|
||||
<SpoonCommitList
|
||||
commits={upstreamCommits.slice(0, 5)}
|
||||
empty='No upstream-only commits are cached. Refresh from GitHub to check drift.'
|
||||
/>
|
||||
</section>
|
||||
<section className='space-y-3'>
|
||||
<div>
|
||||
<h2 className='text-base font-semibold'>Fork changes</h2>
|
||||
<p className='text-muted-foreground text-sm'>
|
||||
Custom commits Spoon should preserve during maintenance.
|
||||
</p>
|
||||
</div>
|
||||
<SpoonCommitList
|
||||
commits={forkCommits.slice(0, 5)}
|
||||
empty='No fork-only commits are cached.'
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value='upstream'>
|
||||
<SpoonCommitList
|
||||
commits={upstreamCommits}
|
||||
empty='No upstream changes are waiting, or this Spoon has not been refreshed yet.'
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value='fork'>
|
||||
<SpoonCommitList
|
||||
commits={forkCommits}
|
||||
empty='No fork-only commits are cached. Your customizations will appear here after refresh.'
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value='pulls'>
|
||||
<SpoonPrList pullRequests={pullRequests} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value='ai' className='space-y-4'>
|
||||
<SpoonAiReviewPanel
|
||||
latestReview={details.latestReview}
|
||||
reviews={reviews}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value='agent' className='space-y-4'>
|
||||
<AgentRequestForm
|
||||
spoon={details.spoon}
|
||||
agentSettings={agentSettings}
|
||||
/>
|
||||
<AgentJobList jobs={agentJobs} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value='activity'>
|
||||
<SpoonActivityTimeline
|
||||
syncRuns={syncRuns}
|
||||
reviews={reviews}
|
||||
requests={agentRequests}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value='settings' className='space-y-4'>
|
||||
<SpoonSettingsForm
|
||||
spoon={details.spoon}
|
||||
settings={details.settings}
|
||||
/>
|
||||
<SpoonAgentSettingsForm
|
||||
spoon={details.spoon}
|
||||
settings={agentSettings}
|
||||
/>
|
||||
<SpoonSecretsForm spoonId={spoonId} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</main>
|
||||
);
|
||||
};
|
||||
|
||||
export default SpoonDetailPage;
|
||||
@@ -1,3 +1,5 @@
|
||||
import { GitHubConnectionPanel } from '@/components/github/github-connection-panel';
|
||||
import { GitHubForkForm } from '@/components/spoons/github-fork-form';
|
||||
import { NewSpoonForm } from '@/components/spoons/new-spoon-form';
|
||||
|
||||
const NewSpoonPage = () => (
|
||||
@@ -5,8 +7,18 @@ const NewSpoonPage = () => (
|
||||
<div>
|
||||
<h1 className='text-3xl font-semibold tracking-normal'>New Spoon</h1>
|
||||
<p className='text-muted-foreground mt-2 max-w-2xl'>
|
||||
Create a provider-neutral managed fork record. This does not call a Git
|
||||
provider yet; it prepares the dashboard surface for future automation.
|
||||
Connect GitHub to create real forks, or add an existing managed fork
|
||||
manually.
|
||||
</p>
|
||||
</div>
|
||||
<GitHubConnectionPanel />
|
||||
<GitHubForkForm />
|
||||
<div>
|
||||
<h2 className='text-xl font-semibold tracking-normal'>
|
||||
Add an existing fork manually
|
||||
</h2>
|
||||
<p className='text-muted-foreground mt-2 max-w-2xl text-sm'>
|
||||
Use this path for non-GitHub providers or forks that already exist.
|
||||
</p>
|
||||
</div>
|
||||
<NewSpoonForm />
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { MaintenanceQueue } from '@/components/updates/maintenance-queue';
|
||||
import { useQuery } from 'convex/react';
|
||||
|
||||
import { api } from '@spoon/backend/convex/_generated/api.js';
|
||||
@@ -76,6 +77,10 @@ const UpdatesPage = () => {
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<section className='space-y-3'>
|
||||
<h2 className='text-lg font-semibold'>Maintenance queue</h2>
|
||||
<MaintenanceQueue spoons={spoons} />
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,51 +1,6 @@
|
||||
'use server';
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
import {
|
||||
AvatarUpload,
|
||||
ProfileHeader,
|
||||
ResetPasswordForm,
|
||||
SignOutForm,
|
||||
UserInfoForm,
|
||||
} from '@/components/layout/auth/profile';
|
||||
import { preloadQuery } from 'convex/nextjs';
|
||||
|
||||
import { api } from '@spoon/backend/convex/_generated/api.js';
|
||||
import { Card, Separator } from '@spoon/ui';
|
||||
|
||||
const Profile = async () => {
|
||||
const preloadedUser = await preloadQuery(api.auth.getUser, {});
|
||||
const preloadedUserProvider = await preloadQuery(
|
||||
api.auth.getUserProvider,
|
||||
{},
|
||||
);
|
||||
return (
|
||||
<main className='container mx-auto px-4 py-12 md:py-16'>
|
||||
<div className='mx-auto max-w-3xl'>
|
||||
{/* Page Header */}
|
||||
<div className='mb-8 text-center'>
|
||||
<h1 className='mb-2 text-3xl font-bold tracking-tight sm:text-4xl'>
|
||||
Your Profile
|
||||
</h1>
|
||||
<p className='text-muted-foreground'>
|
||||
Manage your personal information and preferences
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Profile Card */}
|
||||
<Card className='border-border/40'>
|
||||
<ProfileHeader />
|
||||
<AvatarUpload preloadedUser={preloadedUser} />
|
||||
<Separator className='my-6' />
|
||||
<UserInfoForm
|
||||
preloadedUser={preloadedUser}
|
||||
preloadedProvider={preloadedUserProvider}
|
||||
/>
|
||||
<ResetPasswordForm preloadedProvider={preloadedUserProvider} />
|
||||
<Separator className='my-6' />
|
||||
<SignOutForm />
|
||||
</Card>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
const Profile = () => {
|
||||
redirect('/settings/profile');
|
||||
};
|
||||
export default Profile;
|
||||
|
||||
@@ -3,7 +3,10 @@
|
||||
import { useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { AuthentikSignInButton } from '@/components/layout/auth/buttons';
|
||||
import {
|
||||
AuthentikSignInButton,
|
||||
GitHubSignInButton,
|
||||
} from '@/components/layout/auth/buttons';
|
||||
import { useAuthActions } from '@convex-dev/auth/react';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { ConvexError } from 'convex/values';
|
||||
@@ -342,7 +345,8 @@ const SignIn = () => {
|
||||
<Separator className='ml-3 py-0.5' />
|
||||
</div>
|
||||
</div>
|
||||
<div className='mt-3 flex justify-center'>
|
||||
<div className='mt-3 flex flex-col items-center gap-3'>
|
||||
<GitHubSignInButton />
|
||||
<AuthentikSignInButton />
|
||||
</div>
|
||||
</CardContent>
|
||||
@@ -450,7 +454,8 @@ const SignIn = () => {
|
||||
<Separator className='ml-3 py-0.5' />
|
||||
</div>
|
||||
</div>
|
||||
<div className='mt-3 flex justify-center'>
|
||||
<div className='mt-3 flex flex-col items-center gap-3'>
|
||||
<GitHubSignInButton type='signUp' />
|
||||
<AuthentikSignInButton type='signUp' />
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
@@ -7,9 +7,6 @@ import { Geist, Geist_Mono } from 'next/font/google';
|
||||
import '@/app/styles.css';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import Footer from '@/components/layout/footer';
|
||||
import Header from '@/components/layout/header';
|
||||
import { ConvexClientProvider } from '@/components/providers';
|
||||
import { env } from '@/env';
|
||||
import { generateMetadata } from '@/lib/metadata';
|
||||
import * as Sentry from '@sentry/nextjs';
|
||||
@@ -59,20 +56,13 @@ const GlobalError = ({ error, reset = undefined }: GlobalErrorProps) => {
|
||||
enableSystem
|
||||
disableTransitionOnChange
|
||||
>
|
||||
<ConvexClientProvider>
|
||||
<main className='flex min-h-screen flex-col items-center'>
|
||||
<Header />
|
||||
<NextError statusCode={0} />
|
||||
{reset !== undefined && (
|
||||
<Button onClick={() => reset()}>Try Again</Button>
|
||||
)}
|
||||
<Toaster />
|
||||
<Footer />
|
||||
</main>
|
||||
<main className='flex min-h-[90vh] flex-col items-center'>
|
||||
<Toaster />
|
||||
</main>
|
||||
</ConvexClientProvider>
|
||||
<main className='flex min-h-screen flex-col items-center justify-center gap-4 p-6'>
|
||||
<NextError statusCode={0} />
|
||||
{reset !== undefined && (
|
||||
<Button onClick={() => reset()}>Try Again</Button>
|
||||
)}
|
||||
<Toaster />
|
||||
</main>
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
'use client';
|
||||
|
||||
import { Copy } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import type { Doc } from '@spoon/backend/convex/_generated/dataModel.js';
|
||||
import { Button } from '@spoon/ui';
|
||||
|
||||
export const AgentArtifactViewer = ({
|
||||
artifacts,
|
||||
}: {
|
||||
artifacts: Doc<'agentJobArtifacts'>[];
|
||||
}) => {
|
||||
if (!artifacts.length) {
|
||||
return (
|
||||
<p className='text-muted-foreground text-sm'>
|
||||
No artifacts captured yet.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='space-y-3'>
|
||||
{artifacts.map((artifact) => (
|
||||
<section key={artifact._id} className='border-border rounded-md border'>
|
||||
<div className='flex items-center justify-between gap-3 border-b p-3'>
|
||||
<div>
|
||||
<h3 className='text-sm font-semibold'>{artifact.title}</h3>
|
||||
<p className='text-muted-foreground text-xs'>{artifact.kind}</p>
|
||||
</div>
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
size='icon'
|
||||
aria-label='Copy artifact'
|
||||
onClick={async () => {
|
||||
await navigator.clipboard.writeText(artifact.content);
|
||||
toast.success('Artifact copied.');
|
||||
}}
|
||||
>
|
||||
<Copy className='size-4' />
|
||||
</Button>
|
||||
</div>
|
||||
<pre className='bg-muted/40 max-h-96 overflow-auto p-3 text-xs whitespace-pre-wrap'>
|
||||
{artifact.content}
|
||||
</pre>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,47 @@
|
||||
'use client';
|
||||
|
||||
import type { Doc } from '@spoon/backend/convex/_generated/dataModel.js';
|
||||
|
||||
const formatTime = (value: number) =>
|
||||
new Intl.DateTimeFormat(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
}).format(value);
|
||||
|
||||
export const AgentEventLog = ({
|
||||
events,
|
||||
}: {
|
||||
events: Doc<'agentJobEvents'>[];
|
||||
}) => {
|
||||
if (!events.length) {
|
||||
return (
|
||||
<p className='text-muted-foreground text-sm'>No worker events yet.</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='divide-border overflow-hidden rounded-md border'>
|
||||
{events.map((event) => (
|
||||
<div key={event._id} className='grid gap-1 border-b p-3 text-sm'>
|
||||
<div className='flex flex-wrap items-center gap-2'>
|
||||
<span className='font-mono text-xs uppercase'>{event.phase}</span>
|
||||
<span className='text-muted-foreground text-xs'>
|
||||
{formatTime(event.createdAt)}
|
||||
</span>
|
||||
<span className='text-muted-foreground text-xs capitalize'>
|
||||
{event.level}
|
||||
</span>
|
||||
</div>
|
||||
<p className='whitespace-pre-wrap'>{event.message}</p>
|
||||
{event.metadata ? (
|
||||
<pre className='bg-muted overflow-auto rounded p-2 text-xs'>
|
||||
{event.metadata}
|
||||
</pre>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,66 @@
|
||||
'use client';
|
||||
|
||||
import { useQuery } from 'convex/react';
|
||||
|
||||
import type { Doc } from '@spoon/backend/convex/_generated/dataModel.js';
|
||||
import { api } from '@spoon/backend/convex/_generated/api.js';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@spoon/ui';
|
||||
|
||||
import { AgentArtifactViewer } from './agent-artifact-viewer';
|
||||
import { AgentEventLog } from './agent-event-log';
|
||||
|
||||
export const AgentJobDetail = ({ job }: { job: Doc<'agentJobs'> }) => {
|
||||
const events =
|
||||
useQuery(api.agentJobs.listEvents, { jobId: job._id, limit: 200 }) ?? [];
|
||||
const artifacts =
|
||||
useQuery(api.agentJobs.listArtifacts, { jobId: job._id }) ?? [];
|
||||
|
||||
return (
|
||||
<Card className='shadow-none'>
|
||||
<CardHeader className='pb-3'>
|
||||
<CardTitle className='text-base'>Job details</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className='space-y-5'>
|
||||
<div className='grid gap-3 text-sm md:grid-cols-3'>
|
||||
<div>
|
||||
<p className='text-muted-foreground text-xs'>Status</p>
|
||||
<p className='font-medium capitalize'>
|
||||
{job.status.replaceAll('_', ' ')}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className='text-muted-foreground text-xs'>Branch</p>
|
||||
<p className='font-mono text-xs'>{job.workBranch}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className='text-muted-foreground text-xs'>Model</p>
|
||||
<p className='font-medium'>{job.model}</p>
|
||||
</div>
|
||||
</div>
|
||||
{job.pullRequestUrl ? (
|
||||
<a
|
||||
href={job.pullRequestUrl}
|
||||
target='_blank'
|
||||
rel='noreferrer'
|
||||
className='text-primary text-sm font-medium underline-offset-4 hover:underline'
|
||||
>
|
||||
Open draft PR #{job.pullRequestNumber}
|
||||
</a>
|
||||
) : null}
|
||||
{job.error ? (
|
||||
<pre className='border-destructive bg-destructive/5 text-destructive overflow-auto rounded-md border p-3 text-xs whitespace-pre-wrap'>
|
||||
{job.error}
|
||||
</pre>
|
||||
) : null}
|
||||
<section className='space-y-2'>
|
||||
<h3 className='text-sm font-semibold'>Events</h3>
|
||||
<AgentEventLog events={events} />
|
||||
</section>
|
||||
<section className='space-y-2'>
|
||||
<h3 className='text-sm font-semibold'>Artifacts</h3>
|
||||
<AgentArtifactViewer artifacts={artifacts} />
|
||||
</section>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,109 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useMutation } from 'convex/react';
|
||||
import { ExternalLink, XCircle } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import type { Doc } from '@spoon/backend/convex/_generated/dataModel.js';
|
||||
import { api } from '@spoon/backend/convex/_generated/api.js';
|
||||
import { Badge, Button } from '@spoon/ui';
|
||||
|
||||
import { AgentJobDetail } from './agent-job-detail';
|
||||
|
||||
const formatTime = (value: number) =>
|
||||
new Intl.DateTimeFormat(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
}).format(value);
|
||||
|
||||
export const AgentJobList = ({ jobs }: { jobs: Doc<'agentJobs'>[] }) => {
|
||||
const cancel = useMutation(api.agentJobs.cancel);
|
||||
const [selectedJobId, setSelectedJobId] = useState<string | null>(
|
||||
jobs[0]?._id ?? null,
|
||||
);
|
||||
const selectedJob = jobs.find((job) => job._id === selectedJobId) ?? jobs[0];
|
||||
|
||||
if (!jobs.length) {
|
||||
return (
|
||||
<div className='border-border rounded-md border p-5'>
|
||||
<h3 className='text-sm font-semibold'>No agent jobs yet</h3>
|
||||
<p className='text-muted-foreground mt-1 text-sm'>
|
||||
Queue a job to have Spoon open a draft PR against this fork.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='grid gap-4 xl:grid-cols-[0.85fr_1.15fr]'>
|
||||
<div className='divide-border overflow-hidden rounded-md border'>
|
||||
{jobs.map((job) => (
|
||||
<button
|
||||
key={job._id}
|
||||
type='button'
|
||||
className='hover:bg-muted/40 data-[selected=true]:bg-muted/60 block w-full border-b p-3 text-left'
|
||||
data-selected={job._id === selectedJob?._id}
|
||||
onClick={() => setSelectedJobId(job._id)}
|
||||
>
|
||||
<div className='flex items-start justify-between gap-3'>
|
||||
<div className='min-w-0'>
|
||||
<p className='truncate text-sm font-medium'>{job.prompt}</p>
|
||||
<p className='text-muted-foreground mt-1 font-mono text-xs'>
|
||||
{job.workBranch}
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant='outline' className='capitalize'>
|
||||
{job.status.replaceAll('_', ' ')}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className='text-muted-foreground mt-2 flex flex-wrap gap-2 text-xs'>
|
||||
<span>{formatTime(job.createdAt)}</span>
|
||||
{job.pullRequestUrl ? (
|
||||
<a
|
||||
href={job.pullRequestUrl}
|
||||
target='_blank'
|
||||
rel='noreferrer'
|
||||
className='text-primary inline-flex items-center gap-1'
|
||||
>
|
||||
PR <ExternalLink className='size-3' />
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{selectedJob ? (
|
||||
<div className='space-y-3'>
|
||||
{[
|
||||
'queued',
|
||||
'claimed',
|
||||
'preparing',
|
||||
'running',
|
||||
'checks_running',
|
||||
].includes(selectedJob.status) ? (
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
onClick={async () => {
|
||||
try {
|
||||
await cancel({ jobId: selectedJob._id });
|
||||
toast.success('Agent job cancelled.');
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error('Could not cancel job.');
|
||||
}
|
||||
}}
|
||||
>
|
||||
<XCircle className='size-4' />
|
||||
Cancel job
|
||||
</Button>
|
||||
) : null}
|
||||
<AgentJobDetail job={selectedJob} />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,145 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useMutation, useQuery } from 'convex/react';
|
||||
import { Bot } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import type { Doc, Id } from '@spoon/backend/convex/_generated/dataModel.js';
|
||||
import { api } from '@spoon/backend/convex/_generated/api.js';
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
Input,
|
||||
Label,
|
||||
Textarea,
|
||||
} from '@spoon/ui';
|
||||
|
||||
import { SecretSelector } from './secret-selector';
|
||||
|
||||
type AgentSettings = {
|
||||
defaultBaseBranch?: string;
|
||||
agentModel: string;
|
||||
reasoningEffort: string;
|
||||
};
|
||||
|
||||
export const AgentRequestForm = ({
|
||||
spoon,
|
||||
agentSettings,
|
||||
}: {
|
||||
spoon: Doc<'spoons'>;
|
||||
agentSettings?: AgentSettings | null;
|
||||
}) => {
|
||||
const secrets = useQuery(api.spoonSecrets.listForSpoon, {
|
||||
spoonId: spoon._id,
|
||||
});
|
||||
const createRequest = useMutation(api.agentRequests.create);
|
||||
const createJob = useMutation(api.agentJobs.createFromRequest);
|
||||
const [prompt, setPrompt] = useState('');
|
||||
const [baseBranch, setBaseBranch] = useState(
|
||||
agentSettings?.defaultBaseBranch ??
|
||||
spoon.forkDefaultBranch ??
|
||||
spoon.upstreamDefaultBranch,
|
||||
);
|
||||
const [requestedBranchName, setRequestedBranchName] = useState('');
|
||||
const [selectedSecretIds, setSelectedSecretIds] = useState<
|
||||
Id<'spoonSecrets'>[]
|
||||
>([]);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const submit = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const requestId = await createRequest({
|
||||
spoonId: spoon._id,
|
||||
prompt,
|
||||
targetBranch: baseBranch,
|
||||
});
|
||||
await createJob({
|
||||
requestId,
|
||||
selectedSecretIds,
|
||||
baseBranch,
|
||||
requestedBranchName: requestedBranchName || undefined,
|
||||
});
|
||||
setPrompt('');
|
||||
setRequestedBranchName('');
|
||||
setSelectedSecretIds([]);
|
||||
toast.success('Agent job queued.');
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error('Could not queue agent job.');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className='shadow-none'>
|
||||
<CardHeader className='pb-3'>
|
||||
<CardTitle className='flex items-center gap-2 text-base'>
|
||||
<Bot className='size-4' />
|
||||
Request agent work
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={submit} className='space-y-4'>
|
||||
<div className='grid gap-2'>
|
||||
<Label htmlFor='agentPrompt'>Prompt</Label>
|
||||
<Textarea
|
||||
id='agentPrompt'
|
||||
required
|
||||
minLength={12}
|
||||
value={prompt}
|
||||
placeholder='Update this fork to use Authentik as the sole Auth.js provider.'
|
||||
onChange={(event) => setPrompt(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className='grid gap-3 md:grid-cols-2'>
|
||||
<div className='grid gap-2'>
|
||||
<Label htmlFor='baseBranch'>Base branch</Label>
|
||||
<Input
|
||||
id='baseBranch'
|
||||
value={baseBranch}
|
||||
onChange={(event) => setBaseBranch(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className='grid gap-2'>
|
||||
<Label htmlFor='workBranch'>Work branch</Label>
|
||||
<Input
|
||||
id='workBranch'
|
||||
value={requestedBranchName}
|
||||
placeholder='Auto-generated if blank'
|
||||
onChange={(event) => setRequestedBranchName(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className='grid gap-2'>
|
||||
<Label>Secrets exposed to this job</Label>
|
||||
<SecretSelector
|
||||
secrets={secrets ?? []}
|
||||
selectedSecretIds={selectedSecretIds}
|
||||
onChange={setSelectedSecretIds}
|
||||
/>
|
||||
</div>
|
||||
<div className='bg-muted/40 grid gap-1 rounded-md p-3 text-xs'>
|
||||
<span>
|
||||
Model:{' '}
|
||||
<strong>{agentSettings?.agentModel ?? 'gpt-5.1-codex'}</strong>
|
||||
</span>
|
||||
<span>
|
||||
Reasoning:{' '}
|
||||
<strong>{agentSettings?.reasoningEffort ?? 'high'}</strong>
|
||||
</span>
|
||||
</div>
|
||||
<Button type='submit' disabled={submitting}>
|
||||
{submitting ? 'Queueing...' : 'Queue agent job'}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,60 @@
|
||||
'use client';
|
||||
|
||||
import type { Id } from '@spoon/backend/convex/_generated/dataModel.js';
|
||||
import { Checkbox, Label } from '@spoon/ui';
|
||||
|
||||
type Secret = {
|
||||
_id: Id<'spoonSecrets'>;
|
||||
name: string;
|
||||
valuePreview?: string;
|
||||
description?: string;
|
||||
};
|
||||
|
||||
export const SecretSelector = ({
|
||||
secrets,
|
||||
selectedSecretIds,
|
||||
onChange,
|
||||
}: {
|
||||
secrets: Secret[];
|
||||
selectedSecretIds: Id<'spoonSecrets'>[];
|
||||
onChange: (secretIds: Id<'spoonSecrets'>[]) => void;
|
||||
}) => {
|
||||
const toggle = (secretId: Id<'spoonSecrets'>, checked: boolean) => {
|
||||
onChange(
|
||||
checked
|
||||
? [...selectedSecretIds, secretId]
|
||||
: selectedSecretIds.filter((id) => id !== secretId),
|
||||
);
|
||||
};
|
||||
|
||||
if (!secrets.length) {
|
||||
return (
|
||||
<p className='text-muted-foreground text-sm'>
|
||||
No Spoon secrets saved. Add project secrets in Settings when a job needs
|
||||
environment variables.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='grid gap-2'>
|
||||
{secrets.map((secret) => (
|
||||
<label
|
||||
key={secret._id}
|
||||
className='border-border flex items-start gap-3 rounded-md border p-3'
|
||||
>
|
||||
<Checkbox
|
||||
checked={selectedSecretIds.includes(secret._id)}
|
||||
onCheckedChange={(checked) => toggle(secret._id, checked === true)}
|
||||
/>
|
||||
<span className='grid gap-1'>
|
||||
<Label className='font-mono text-xs'>{secret.name}</Label>
|
||||
<span className='text-muted-foreground text-xs'>
|
||||
{secret.description ?? secret.valuePreview ?? 'Configured'}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -3,7 +3,7 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { Bot, GitBranch, LayoutDashboard, RefreshCw, User } from 'lucide-react';
|
||||
import { GitBranch, LayoutDashboard, RefreshCw, Settings } from 'lucide-react';
|
||||
|
||||
import { cn } from '@spoon/ui';
|
||||
|
||||
@@ -11,8 +11,7 @@ const navItems = [
|
||||
{ href: '/dashboard', label: 'Dashboard', icon: LayoutDashboard },
|
||||
{ href: '/spoons', label: 'My Spoons', icon: GitBranch },
|
||||
{ href: '/updates', label: 'Updates', icon: RefreshCw },
|
||||
{ href: '/agents', label: 'Agents', icon: Bot },
|
||||
{ href: '/profile', label: 'Profile', icon: User },
|
||||
{ href: '/settings/profile', label: 'Settings', icon: Settings },
|
||||
];
|
||||
|
||||
export const AppShell = ({ children }: { children: ReactNode }) => {
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useMutation } from 'convex/react';
|
||||
|
||||
import { api } from '@spoon/backend/convex/_generated/api.js';
|
||||
import { Button, Card, CardContent } from '@spoon/ui';
|
||||
|
||||
export const GitHubConnectClient = ({
|
||||
installationId,
|
||||
}: {
|
||||
installationId?: string;
|
||||
}) => {
|
||||
const connectInstallation = useMutation(api.github.connectInstallation);
|
||||
const [status, setStatus] = useState<'idle' | 'saving' | 'saved' | 'failed'>(
|
||||
installationId ? 'saving' : 'idle',
|
||||
);
|
||||
const hasSubmitted = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!installationId || hasSubmitted.current) return;
|
||||
hasSubmitted.current = true;
|
||||
void connectInstallation({ installationId })
|
||||
.then(() => setStatus('saved'))
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
setStatus('failed');
|
||||
});
|
||||
}, [connectInstallation, installationId]);
|
||||
|
||||
return (
|
||||
<Card className='shadow-none'>
|
||||
<CardContent className='p-6'>
|
||||
<p className='text-lg font-medium'>GitHub App connection</p>
|
||||
<p className='text-muted-foreground mt-2'>
|
||||
{status === 'idle'
|
||||
? 'GitHub did not provide an installation ID in this callback.'
|
||||
: status === 'saving'
|
||||
? 'Saving your GitHub installation...'
|
||||
: status === 'saved'
|
||||
? 'GitHub is connected. You can create forks from Spoon now.'
|
||||
: 'Could not save this GitHub installation.'}
|
||||
</p>
|
||||
<Button className='mt-5' asChild>
|
||||
<Link href='/spoons/new'>Create a Spoon</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,63 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useAction, useQuery } from 'convex/react';
|
||||
import { makeFunctionReference } from 'convex/server';
|
||||
import { Github, RefreshCw } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { api } from '@spoon/backend/convex/_generated/api.js';
|
||||
import { Button, Card, CardContent } from '@spoon/ui';
|
||||
|
||||
const syncConfiguredInstallationRef = makeFunctionReference<
|
||||
'action',
|
||||
Record<string, never>,
|
||||
string
|
||||
>('githubNode:syncConfiguredInstallation');
|
||||
|
||||
export const GitHubConnectionPanel = () => {
|
||||
const connection = useQuery(api.github.getConnection, {});
|
||||
const installUrl = useQuery(api.github.getInstallUrl, {});
|
||||
const syncConfiguredInstallation = useAction(syncConfiguredInstallationRef);
|
||||
|
||||
const handleSync = async () => {
|
||||
try {
|
||||
await syncConfiguredInstallation({});
|
||||
toast.success('GitHub installation connected.');
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error('Could not connect the configured GitHub installation.');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className='shadow-none'>
|
||||
<CardContent className='flex flex-col gap-4 p-5 md:flex-row md:items-center md:justify-between'>
|
||||
<div className='flex gap-3'>
|
||||
<div className='bg-primary/10 text-primary flex size-10 shrink-0 items-center justify-center rounded-lg'>
|
||||
<Github className='size-5' />
|
||||
</div>
|
||||
<div>
|
||||
<p className='font-medium'>GitHub App connection</p>
|
||||
<p className='text-muted-foreground mt-1 text-sm'>
|
||||
{connection
|
||||
? `Connected to ${connection.displayName}`
|
||||
: 'Install or sync the Spoon GitHub App before creating forks.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex flex-wrap gap-2'>
|
||||
{installUrl ? (
|
||||
<Button asChild variant='outline'>
|
||||
<Link href={installUrl}>Install GitHub App</Link>
|
||||
</Button>
|
||||
) : null}
|
||||
<Button type='button' onClick={handleSync}>
|
||||
<RefreshCw className='size-4' />
|
||||
Sync configured installation
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,84 @@
|
||||
'use client';
|
||||
|
||||
import { useAction, useQuery } from 'convex/react';
|
||||
import { makeFunctionReference } from 'convex/server';
|
||||
import { Github, RefreshCw } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { api } from '@spoon/backend/convex/_generated/api.js';
|
||||
import { Button, Card, CardContent, CardHeader, CardTitle } from '@spoon/ui';
|
||||
|
||||
const listReposRef = makeFunctionReference<
|
||||
'action',
|
||||
Record<string, never>,
|
||||
{
|
||||
id: number;
|
||||
name: string;
|
||||
fullName: string;
|
||||
owner: string;
|
||||
private: boolean;
|
||||
fork: boolean;
|
||||
url: string;
|
||||
defaultBranch: string;
|
||||
description?: string;
|
||||
}[]
|
||||
>('githubNode:listInstallationRepositories');
|
||||
|
||||
export const GithubIntegrationPanel = () => {
|
||||
const connection = useQuery(api.github.getConnection, {});
|
||||
const installUrl = useQuery(api.github.getInstallUrl, {});
|
||||
const listRepos = useAction(listReposRef);
|
||||
|
||||
const refresh = async () => {
|
||||
try {
|
||||
const repos = await listRepos({});
|
||||
toast.success(`GitHub can access ${repos.length} repositories.`);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error('Could not list GitHub repositories.');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className='shadow-none'>
|
||||
<CardHeader>
|
||||
<CardTitle className='flex items-center gap-2 text-base'>
|
||||
<Github className='size-4' />
|
||||
GitHub App
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className='space-y-4'>
|
||||
{connection ? (
|
||||
<div className='grid gap-2 text-sm'>
|
||||
<div>
|
||||
<p className='text-muted-foreground'>Connected account</p>
|
||||
<p className='font-medium'>{connection.displayName}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className='text-muted-foreground'>Installation ID</p>
|
||||
<p className='font-mono text-xs'>{connection.installationId}</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className='text-muted-foreground text-sm'>
|
||||
Install the GitHub App to let Spoon create forks and refresh
|
||||
repository state.
|
||||
</p>
|
||||
)}
|
||||
<div className='flex flex-wrap gap-2'>
|
||||
{installUrl ? (
|
||||
<Button asChild>
|
||||
<a href={installUrl} target='_blank' rel='noreferrer'>
|
||||
Configure GitHub App
|
||||
</a>
|
||||
</Button>
|
||||
) : null}
|
||||
<Button variant='outline' onClick={refresh} disabled={!connection}>
|
||||
<RefreshCw className='size-4' />
|
||||
Check repository access
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,197 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useAction, useMutation, useQuery } from 'convex/react';
|
||||
import { makeFunctionReference } from 'convex/server';
|
||||
import { Brain } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { api } from '@spoon/backend/convex/_generated/api.js';
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
Input,
|
||||
Label,
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@spoon/ui';
|
||||
|
||||
type ReasoningEffort = 'none' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh';
|
||||
|
||||
const saveOpenAiSettingsRef = makeFunctionReference<
|
||||
'action',
|
||||
{ apiKey: string; model: string; reasoningEffort: ReasoningEffort },
|
||||
{ success: true }
|
||||
>('aiSettingsNode:saveOpenAiSettings');
|
||||
|
||||
const modelOptions = [
|
||||
{ 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' },
|
||||
];
|
||||
|
||||
const reasoningOptions: { value: ReasoningEffort; label: string }[] = [
|
||||
{ value: 'none', label: 'None' },
|
||||
{ value: 'minimal', label: 'Minimal' },
|
||||
{ value: 'low', label: 'Low' },
|
||||
{ value: 'medium', label: 'Medium' },
|
||||
{ value: 'high', label: 'High' },
|
||||
{ value: 'xhigh', label: 'Extra high' },
|
||||
];
|
||||
|
||||
export const OpenAiStatusPanel = () => {
|
||||
const status = useQuery(api.integrations.getStatus, {});
|
||||
const settings = useQuery(api.aiSettings.getMine, {});
|
||||
const saveOpenAiSettings = useAction(saveOpenAiSettingsRef);
|
||||
const updatePreferences = useMutation(api.aiSettings.updatePreferences);
|
||||
const removeOpenAiKey = useMutation(api.aiSettings.removeOpenAiKey);
|
||||
const [apiKey, setApiKey] = useState('');
|
||||
const [model, setModel] = useState('gpt-5.5');
|
||||
const [reasoningEffort, setReasoningEffort] =
|
||||
useState<ReasoningEffort>('medium');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!settings) return;
|
||||
setModel(settings.model);
|
||||
setReasoningEffort(settings.reasoningEffort as ReasoningEffort);
|
||||
}, [settings]);
|
||||
|
||||
const save = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
setSubmitting(true);
|
||||
try {
|
||||
if (apiKey.trim()) {
|
||||
await saveOpenAiSettings({
|
||||
apiKey,
|
||||
model,
|
||||
reasoningEffort,
|
||||
});
|
||||
setApiKey('');
|
||||
} else {
|
||||
await updatePreferences({
|
||||
model,
|
||||
reasoningEffort,
|
||||
});
|
||||
}
|
||||
toast.success('OpenAI settings saved.');
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error('Could not save OpenAI settings.');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const remove = async () => {
|
||||
try {
|
||||
await removeOpenAiKey({});
|
||||
toast.success('OpenAI API key removed.');
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error('Could not remove OpenAI API key.');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className='shadow-none'>
|
||||
<CardHeader>
|
||||
<CardTitle className='flex items-center gap-2 text-base'>
|
||||
<Brain className='size-4' />
|
||||
OpenAI reviews
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className='space-y-2 text-sm'>
|
||||
<p className='text-muted-foreground'>
|
||||
Compatibility reviews use your own OpenAI API key. Spoon encrypts the
|
||||
key before storing it and only shows a short preview.
|
||||
</p>
|
||||
<div>
|
||||
<p className='text-muted-foreground'>Encryption</p>
|
||||
<p className='font-medium'>
|
||||
{status?.encryptionConfigured ? 'Configured' : 'Missing server key'}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className='text-muted-foreground'>OpenAI API key</p>
|
||||
<p className='font-medium'>
|
||||
{settings?.configured ? settings.apiKeyPreview : 'Not configured'}
|
||||
</p>
|
||||
</div>
|
||||
<form onSubmit={save} className='space-y-4 pt-2'>
|
||||
<div className='grid gap-2'>
|
||||
<Label htmlFor='openai-api-key'>API key</Label>
|
||||
<Input
|
||||
id='openai-api-key'
|
||||
type='password'
|
||||
value={apiKey}
|
||||
placeholder={
|
||||
settings?.configured
|
||||
? 'Leave blank to keep current key'
|
||||
: 'sk-...'
|
||||
}
|
||||
onChange={(event) => setApiKey(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className='grid gap-2 md:grid-cols-2'>
|
||||
<div className='grid gap-2'>
|
||||
<Label>Review model</Label>
|
||||
<Select value={model} onValueChange={(value) => setModel(value)}>
|
||||
<SelectTrigger>
|
||||
<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>Thinking</Label>
|
||||
<Select
|
||||
value={reasoningEffort}
|
||||
onValueChange={(value) =>
|
||||
setReasoningEffort(value as ReasoningEffort)
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{reasoningOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex flex-wrap gap-2'>
|
||||
<Button type='submit' disabled={submitting}>
|
||||
{submitting ? 'Saving...' : 'Save OpenAI settings'}
|
||||
</Button>
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
onClick={remove}
|
||||
disabled={!settings?.configured}
|
||||
>
|
||||
Remove key
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { VariantProps } from 'class-variance-authority';
|
||||
import type { ComponentProps } from 'react';
|
||||
import { useAuthActions } from '@convex-dev/auth/react';
|
||||
import { KeyRound } from 'lucide-react';
|
||||
import { Github, KeyRound } from 'lucide-react';
|
||||
|
||||
import type { buttonVariants } from '@spoon/ui';
|
||||
import { Button } from '@spoon/ui';
|
||||
@@ -33,3 +33,21 @@ export const AuthentikSignInButton = ({
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
export const GitHubSignInButton = ({ buttonProps, type = 'signIn' }: Props) => {
|
||||
const { signIn } = useAuthActions();
|
||||
return (
|
||||
<Button
|
||||
size='lg'
|
||||
variant='outline'
|
||||
onClick={() => signIn('github')}
|
||||
className='text-lg font-semibold'
|
||||
{...buttonProps}
|
||||
>
|
||||
<div className='my-auto flex flex-row items-center gap-2'>
|
||||
<Github className='size-5' />
|
||||
<p>{type === 'signIn' ? 'Continue' : 'Sign up'} with GitHub</p>
|
||||
</div>
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1 +1 @@
|
||||
export { AuthentikSignInButton } from './gibs-auth';
|
||||
export { AuthentikSignInButton, GitHubSignInButton } from './gibs-auth';
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import type { Preloaded } from 'convex/react';
|
||||
import type { ChangeEvent } from 'react';
|
||||
import { useRef, useState } from 'react';
|
||||
import { isRemoteImageUrl } from '@/lib/avatar';
|
||||
import { useMutation, usePreloadedQuery, useQuery } from 'convex/react';
|
||||
import { Loader2, Pencil, Upload, XIcon } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
@@ -48,10 +49,12 @@ export const AvatarUpload = ({ preloadedUser }: AvatarUploadProps) => {
|
||||
const generateUploadUrl = useMutation(api.files.generateUploadUrl);
|
||||
const updateUser = useMutation(api.auth.updateUser);
|
||||
|
||||
const remoteImageUrl = isRemoteImageUrl(user?.image) ? user?.image : null;
|
||||
const currentImageUrl = useQuery(
|
||||
api.files.getImageUrl,
|
||||
user?.image ? { storageId: user.image as Id<'_storage'> } : 'skip',
|
||||
user?.image && !remoteImageUrl ? { storageId: user.image } : 'skip',
|
||||
);
|
||||
const avatarUrl = remoteImageUrl ?? currentImageUrl;
|
||||
|
||||
const handleFileChange = (event: ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0] ?? null;
|
||||
@@ -117,7 +120,7 @@ export const AvatarUpload = ({ preloadedUser }: AvatarUploadProps) => {
|
||||
onClick={() => inputRef.current?.click()}
|
||||
>
|
||||
<BasedAvatar
|
||||
src={currentImageUrl ?? undefined}
|
||||
src={avatarUrl ?? undefined}
|
||||
fullName={user?.name}
|
||||
className='h-42 w-42 text-6xl font-semibold'
|
||||
userIconProps={{ size: 100 }}
|
||||
|
||||
@@ -54,6 +54,7 @@ export const UserInfoForm = ({
|
||||
const providerMap: Record<string, string> = {
|
||||
unknown: 'Provider',
|
||||
authentik: 'Authentik',
|
||||
github: 'GitHub',
|
||||
};
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { isRemoteImageUrl } from '@/lib/avatar';
|
||||
import { useAuthActions } from '@convex-dev/auth/react';
|
||||
import { useConvexAuth, useQuery } from 'convex/react';
|
||||
|
||||
import type { Id } from '@spoon/backend/convex/_generated/dataModel.js';
|
||||
import { api } from '@spoon/backend/convex/_generated/api.js';
|
||||
import {
|
||||
BasedAvatar,
|
||||
@@ -23,10 +23,12 @@ export const AvatarDropdown = () => {
|
||||
const { isLoading, isAuthenticated } = useConvexAuth();
|
||||
const { signOut } = useAuthActions();
|
||||
const user = useQuery(api.auth.getUser, {});
|
||||
const remoteImageUrl = isRemoteImageUrl(user?.image) ? user?.image : null;
|
||||
const currentImageUrl = useQuery(
|
||||
api.files.getImageUrl,
|
||||
user?.image ? { storageId: user.image as Id<'_storage'> } : 'skip',
|
||||
user?.image && !remoteImageUrl ? { storageId: user.image } : 'skip',
|
||||
);
|
||||
const avatarUrl = remoteImageUrl ?? currentImageUrl;
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
@@ -51,7 +53,7 @@ export const AvatarDropdown = () => {
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger>
|
||||
<BasedAvatar
|
||||
src={currentImageUrl}
|
||||
src={avatarUrl}
|
||||
fullName={user?.name}
|
||||
className='h-9 w-9'
|
||||
fallbackProps={{ className: 'text-sm font-semibold' }}
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useAction, useQuery } from 'convex/react';
|
||||
import { makeFunctionReference } from 'convex/server';
|
||||
import { GitFork } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { api } from '@spoon/backend/convex/_generated/api.js';
|
||||
import { Button, Card, CardContent, Input, Label, Textarea } from '@spoon/ui';
|
||||
|
||||
type FormState = {
|
||||
upstreamOwner: string;
|
||||
upstreamRepo: string;
|
||||
forkName: string;
|
||||
organization: string;
|
||||
description: string;
|
||||
};
|
||||
|
||||
const initialState: FormState = {
|
||||
upstreamOwner: '',
|
||||
upstreamRepo: '',
|
||||
forkName: '',
|
||||
organization: '',
|
||||
description: '',
|
||||
};
|
||||
|
||||
const createForkRef = makeFunctionReference<
|
||||
'action',
|
||||
{
|
||||
upstreamOwner: string;
|
||||
upstreamRepo: string;
|
||||
name?: string;
|
||||
description?: string;
|
||||
organization?: string;
|
||||
},
|
||||
string
|
||||
>('githubNode:createFork');
|
||||
|
||||
const TextField = ({
|
||||
id,
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
required,
|
||||
placeholder,
|
||||
}: {
|
||||
id: keyof FormState;
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
required?: boolean;
|
||||
placeholder?: string;
|
||||
}) => (
|
||||
<div className='grid gap-2'>
|
||||
<Label htmlFor={id}>{label}</Label>
|
||||
<Input
|
||||
id={id}
|
||||
value={value}
|
||||
required={required}
|
||||
placeholder={placeholder}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const GitHubForkForm = () => {
|
||||
const router = useRouter();
|
||||
const connection = useQuery(api.github.getConnection, {});
|
||||
const createFork = useAction(createForkRef);
|
||||
const [form, setForm] = useState<FormState>(initialState);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const update = <K extends keyof FormState>(key: K, value: FormState[K]) => {
|
||||
setForm((current) => ({ ...current, [key]: value }));
|
||||
};
|
||||
|
||||
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await createFork({
|
||||
upstreamOwner: form.upstreamOwner,
|
||||
upstreamRepo: form.upstreamRepo,
|
||||
name: form.forkName || undefined,
|
||||
organization: form.organization || undefined,
|
||||
description: form.description || undefined,
|
||||
});
|
||||
toast.success('Fork created and added as a Spoon.');
|
||||
router.push('/spoons');
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error('Could not create the GitHub fork.');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className='shadow-none'>
|
||||
<CardContent className='p-5'>
|
||||
<div className='mb-5 flex gap-3'>
|
||||
<div className='bg-primary/10 text-primary flex size-10 shrink-0 items-center justify-center rounded-lg'>
|
||||
<GitFork className='size-5' />
|
||||
</div>
|
||||
<div>
|
||||
<p className='font-medium'>Fork with GitHub</p>
|
||||
<p className='text-muted-foreground mt-1 text-sm'>
|
||||
Create a real GitHub fork through the connected GitHub App and
|
||||
record it as a Spoon.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<form onSubmit={handleSubmit} className='grid gap-5'>
|
||||
<div className='grid gap-4 md:grid-cols-2'>
|
||||
<TextField
|
||||
id='upstreamOwner'
|
||||
label='Upstream owner'
|
||||
value={form.upstreamOwner}
|
||||
required
|
||||
placeholder='vercel'
|
||||
onChange={(value) => update('upstreamOwner', value)}
|
||||
/>
|
||||
<TextField
|
||||
id='upstreamRepo'
|
||||
label='Upstream repository'
|
||||
value={form.upstreamRepo}
|
||||
required
|
||||
placeholder='next.js'
|
||||
onChange={(value) => update('upstreamRepo', value)}
|
||||
/>
|
||||
<TextField
|
||||
id='forkName'
|
||||
label='Fork name'
|
||||
value={form.forkName}
|
||||
placeholder='Optional custom repository name'
|
||||
onChange={(value) => update('forkName', value)}
|
||||
/>
|
||||
<TextField
|
||||
id='organization'
|
||||
label='Organization'
|
||||
value={form.organization}
|
||||
placeholder='Optional org target'
|
||||
onChange={(value) => update('organization', value)}
|
||||
/>
|
||||
</div>
|
||||
<div className='grid gap-2'>
|
||||
<Label htmlFor='description'>Description</Label>
|
||||
<Textarea
|
||||
id='description'
|
||||
value={form.description}
|
||||
placeholder='Optional Spoon note'
|
||||
onChange={(event) => update('description', event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className='flex justify-end'>
|
||||
<Button type='submit' disabled={!connection || submitting}>
|
||||
{submitting ? 'Forking...' : 'Create GitHub fork'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,71 @@
|
||||
import type { Doc } from '@spoon/backend/convex/_generated/dataModel.js';
|
||||
import { Badge, Card, CardContent } from '@spoon/ui';
|
||||
|
||||
const formatDate = (value: number) =>
|
||||
new Intl.DateTimeFormat('en', {
|
||||
dateStyle: 'medium',
|
||||
timeStyle: 'short',
|
||||
}).format(value);
|
||||
|
||||
export const SpoonActivityTimeline = ({
|
||||
syncRuns,
|
||||
reviews,
|
||||
requests,
|
||||
}: {
|
||||
syncRuns: Doc<'syncRuns'>[];
|
||||
reviews: Doc<'aiReviews'>[];
|
||||
requests: Doc<'agentRequests'>[];
|
||||
}) => {
|
||||
const items = [
|
||||
...syncRuns.map((item) => ({
|
||||
id: item._id,
|
||||
kind: item.kind.replaceAll('_', ' '),
|
||||
status: item.status,
|
||||
summary: item.summary ?? item.error ?? 'Sync run recorded.',
|
||||
time: item.createdAt,
|
||||
})),
|
||||
...reviews.map((item) => ({
|
||||
id: item._id,
|
||||
kind: 'AI review',
|
||||
status: item.status,
|
||||
summary: item.outputSummary ?? item.inputSummary,
|
||||
time: item.createdAt,
|
||||
})),
|
||||
...requests.map((item) => ({
|
||||
id: item._id,
|
||||
kind: 'Agent request',
|
||||
status: item.status,
|
||||
summary: item.prompt,
|
||||
time: item.createdAt,
|
||||
})),
|
||||
].sort((a, b) => b.time - a.time);
|
||||
|
||||
return (
|
||||
<div className='space-y-3'>
|
||||
{items.length ? (
|
||||
items.map((item) => (
|
||||
<Card key={item.id} className='shadow-none'>
|
||||
<CardContent className='p-4'>
|
||||
<div className='flex flex-wrap items-center gap-2'>
|
||||
<p className='font-medium'>{item.kind}</p>
|
||||
<Badge variant='outline'>{item.status}</Badge>
|
||||
</div>
|
||||
<p className='text-muted-foreground mt-1 text-sm'>
|
||||
{item.summary}
|
||||
</p>
|
||||
<p className='text-muted-foreground mt-2 text-xs'>
|
||||
{formatDate(item.time)}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))
|
||||
) : (
|
||||
<Card className='shadow-none'>
|
||||
<CardContent className='text-muted-foreground p-6 text-sm'>
|
||||
Refreshes, AI reviews, and queued requests will build this timeline.
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,192 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useMutation } from 'convex/react';
|
||||
import { Bot } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import type { Doc } from '@spoon/backend/convex/_generated/dataModel.js';
|
||||
import { api } from '@spoon/backend/convex/_generated/api.js';
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
Input,
|
||||
Label,
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
Switch,
|
||||
} from '@spoon/ui';
|
||||
|
||||
const efforts = ['minimal', 'low', 'medium', 'high', 'xhigh'] as const;
|
||||
|
||||
type AgentSettings = {
|
||||
enabled: boolean;
|
||||
defaultBaseBranch?: string;
|
||||
branchPrefix: string;
|
||||
installCommand?: string;
|
||||
checkCommand?: string;
|
||||
testCommand?: string;
|
||||
agentModel: string;
|
||||
reasoningEffort: 'none' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh';
|
||||
};
|
||||
|
||||
export const SpoonAgentSettingsForm = ({
|
||||
spoon,
|
||||
settings,
|
||||
}: {
|
||||
spoon: Doc<'spoons'>;
|
||||
settings?: AgentSettings | null;
|
||||
}) => {
|
||||
const update = useMutation(api.spoonAgentSettings.update);
|
||||
const [enabled, setEnabled] = useState(settings?.enabled ?? true);
|
||||
const [defaultBaseBranch, setDefaultBaseBranch] = useState(
|
||||
settings?.defaultBaseBranch ??
|
||||
spoon.forkDefaultBranch ??
|
||||
spoon.upstreamDefaultBranch,
|
||||
);
|
||||
const [branchPrefix, setBranchPrefix] = useState(
|
||||
settings?.branchPrefix ?? 'spoon/agent',
|
||||
);
|
||||
const [installCommand, setInstallCommand] = useState(
|
||||
settings?.installCommand ?? '',
|
||||
);
|
||||
const [checkCommand, setCheckCommand] = useState(
|
||||
settings?.checkCommand ?? '',
|
||||
);
|
||||
const [testCommand, setTestCommand] = useState(settings?.testCommand ?? '');
|
||||
const [agentModel, setAgentModel] = useState(
|
||||
settings?.agentModel ?? 'gpt-5.1-codex',
|
||||
);
|
||||
const [reasoningEffort, setReasoningEffort] = useState<
|
||||
'minimal' | 'low' | 'medium' | 'high' | 'xhigh'
|
||||
>(
|
||||
settings?.reasoningEffort === 'none'
|
||||
? 'minimal'
|
||||
: (settings?.reasoningEffort ?? 'high'),
|
||||
);
|
||||
|
||||
const save = async () => {
|
||||
try {
|
||||
await update({
|
||||
spoonId: spoon._id,
|
||||
enabled,
|
||||
defaultBaseBranch,
|
||||
branchPrefix,
|
||||
installCommand: installCommand || undefined,
|
||||
checkCommand: checkCommand || undefined,
|
||||
testCommand: testCommand || undefined,
|
||||
agentModel,
|
||||
reasoningEffort,
|
||||
});
|
||||
toast.success('Agent settings saved.');
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error('Could not save agent settings.');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className='shadow-none'>
|
||||
<CardHeader>
|
||||
<CardTitle className='flex items-center gap-2 text-base'>
|
||||
<Bot className='size-4' />
|
||||
Agent runtime
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className='space-y-4'>
|
||||
<div className='flex items-center justify-between gap-4'>
|
||||
<Label htmlFor='agentEnabled'>Enable agent jobs</Label>
|
||||
<Switch
|
||||
id='agentEnabled'
|
||||
checked={enabled}
|
||||
onCheckedChange={setEnabled}
|
||||
/>
|
||||
</div>
|
||||
<div className='grid gap-3 md:grid-cols-2'>
|
||||
<div className='grid gap-2'>
|
||||
<Label htmlFor='defaultBaseBranch'>Default base branch</Label>
|
||||
<Input
|
||||
id='defaultBaseBranch'
|
||||
value={defaultBaseBranch}
|
||||
onChange={(event) => setDefaultBaseBranch(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className='grid gap-2'>
|
||||
<Label htmlFor='branchPrefix'>Branch prefix</Label>
|
||||
<Input
|
||||
id='branchPrefix'
|
||||
value={branchPrefix}
|
||||
onChange={(event) => setBranchPrefix(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className='grid gap-2'>
|
||||
<Label htmlFor='agentModel'>Model</Label>
|
||||
<Input
|
||||
id='agentModel'
|
||||
value={agentModel}
|
||||
onChange={(event) => setAgentModel(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className='grid gap-2'>
|
||||
<Label>Reasoning effort</Label>
|
||||
<Select
|
||||
value={reasoningEffort}
|
||||
onValueChange={(value) =>
|
||||
setReasoningEffort(
|
||||
value as 'minimal' | 'low' | 'medium' | 'high' | 'xhigh',
|
||||
)
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{efforts.map((effort) => (
|
||||
<SelectItem key={effort} value={effort}>
|
||||
{effort}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className='grid gap-2'>
|
||||
<Label htmlFor='installCommand'>Install command</Label>
|
||||
<Input
|
||||
id='installCommand'
|
||||
value={installCommand}
|
||||
placeholder='bun install'
|
||||
onChange={(event) => setInstallCommand(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className='grid gap-2'>
|
||||
<Label htmlFor='checkCommand'>Check command</Label>
|
||||
<Input
|
||||
id='checkCommand'
|
||||
value={checkCommand}
|
||||
placeholder='bun typecheck'
|
||||
onChange={(event) => setCheckCommand(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className='grid gap-2'>
|
||||
<Label htmlFor='testCommand'>Test command</Label>
|
||||
<Input
|
||||
id='testCommand'
|
||||
value={testCommand}
|
||||
placeholder='bun test'
|
||||
onChange={(event) => setTestCommand(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Button type='button' onClick={save}>
|
||||
Save agent settings
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,75 @@
|
||||
import type { Doc } from '@spoon/backend/convex/_generated/dataModel.js';
|
||||
import { Badge, Card, CardContent, CardHeader, CardTitle } from '@spoon/ui';
|
||||
|
||||
export const SpoonAiReviewPanel = ({
|
||||
latestReview,
|
||||
reviews,
|
||||
}: {
|
||||
latestReview?: Doc<'aiReviews'> | null;
|
||||
reviews: Doc<'aiReviews'>[];
|
||||
}) => (
|
||||
<div className='space-y-4'>
|
||||
<Card className='shadow-none'>
|
||||
<CardHeader>
|
||||
<CardTitle className='text-base'>Latest compatibility review</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{latestReview ? (
|
||||
<div className='space-y-4'>
|
||||
<div className='flex flex-wrap gap-2'>
|
||||
<Badge>{latestReview.risk}</Badge>
|
||||
<Badge variant='outline'>{latestReview.recommendedAction}</Badge>
|
||||
{latestReview.requiresHumanReview ? (
|
||||
<Badge variant='secondary'>Human review required</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
<p className='text-sm'>
|
||||
{latestReview.outputSummary ?? latestReview.inputSummary}
|
||||
</p>
|
||||
{latestReview.reasoningSummary ? (
|
||||
<p className='text-muted-foreground text-sm'>
|
||||
{latestReview.reasoningSummary}
|
||||
</p>
|
||||
) : null}
|
||||
{latestReview.potentialConflicts?.length ? (
|
||||
<div>
|
||||
<p className='text-sm font-medium'>Potential conflicts</p>
|
||||
<ul className='text-muted-foreground mt-2 list-disc space-y-1 pl-5 text-sm'>
|
||||
{latestReview.potentialConflicts.map((item) => (
|
||||
<li key={item}>{item}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<p className='text-muted-foreground text-sm'>
|
||||
Run an AI review after a GitHub refresh to get compatibility notes.
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className='shadow-none'>
|
||||
<CardHeader>
|
||||
<CardTitle className='text-base'>Review history</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className='space-y-3'>
|
||||
{reviews.length ? (
|
||||
reviews.map((review) => (
|
||||
<div key={review._id} className='border-border border p-3 text-sm'>
|
||||
<div className='flex flex-wrap gap-2'>
|
||||
<Badge variant='outline'>{review.status}</Badge>
|
||||
<Badge variant='secondary'>{review.risk}</Badge>
|
||||
</div>
|
||||
<p className='mt-2'>
|
||||
{review.outputSummary ?? review.inputSummary}
|
||||
</p>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<p className='text-muted-foreground text-sm'>No AI reviews yet.</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
@@ -1,5 +1,8 @@
|
||||
import Link from 'next/link';
|
||||
import { SpoonStatusBadge } from '@/components/spoons/spoon-status-badge';
|
||||
|
||||
import type { Doc } from '@spoon/backend/convex/_generated/dataModel.js';
|
||||
import { Badge, Card, CardContent, CardHeader, CardTitle } from '@spoon/ui';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@spoon/ui';
|
||||
|
||||
const formatDate = (value?: number) =>
|
||||
value
|
||||
@@ -7,15 +10,19 @@ const formatDate = (value?: number) =>
|
||||
: 'Never';
|
||||
|
||||
export const SpoonCard = ({ spoon }: { spoon: Doc<'spoons'> }) => (
|
||||
<Card className='shadow-none'>
|
||||
<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'>{spoon.name}</CardTitle>
|
||||
<CardTitle className='text-lg'>
|
||||
<Link href={`/spoons/${spoon._id}`} className='hover:underline'>
|
||||
{spoon.name}
|
||||
</Link>
|
||||
</CardTitle>
|
||||
<p className='text-muted-foreground mt-1 text-sm'>
|
||||
{spoon.upstreamOwner}/{spoon.upstreamRepo}
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant='outline'>{spoon.status.replaceAll('_', ' ')}</Badge>
|
||||
<SpoonStatusBadge status={spoon.syncStatus ?? spoon.status} />
|
||||
</CardHeader>
|
||||
<CardContent className='grid gap-3 text-sm md:grid-cols-2'>
|
||||
<div>
|
||||
@@ -38,6 +45,14 @@ export const SpoonCard = ({ spoon }: { spoon: Doc<'spoons'> }) => (
|
||||
<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>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useMutation, useQuery } from 'convex/react';
|
||||
import { Check, Copy, ExternalLink, Plus, Trash2 } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import type { Doc } from '@spoon/backend/convex/_generated/dataModel.js';
|
||||
import { api } from '@spoon/backend/convex/_generated/api.js';
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
Input,
|
||||
Label,
|
||||
} from '@spoon/ui';
|
||||
|
||||
const RemoteRow = ({
|
||||
label,
|
||||
url,
|
||||
remoteName,
|
||||
onRemove,
|
||||
}: {
|
||||
label: string;
|
||||
url: string;
|
||||
remoteName?: string;
|
||||
onRemove?: () => Promise<void>;
|
||||
}) => {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const copy = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(url);
|
||||
setCopied(true);
|
||||
toast.success(`${label} URL copied.`);
|
||||
window.setTimeout(() => setCopied(false), 1800);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error('Could not copy remote URL.');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='border-border space-y-2 border-t pt-3 first:border-t-0 first:pt-0'>
|
||||
<div className='flex flex-wrap items-center justify-between gap-2'>
|
||||
<div>
|
||||
<p className='text-sm font-medium'>{label}</p>
|
||||
{remoteName ? (
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
git remote: {remoteName}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className='flex gap-2'>
|
||||
<Button type='button' variant='outline' size='icon' onClick={copy}>
|
||||
{copied ? (
|
||||
<Check className='size-4' />
|
||||
) : (
|
||||
<Copy className='size-4' />
|
||||
)}
|
||||
</Button>
|
||||
<Button type='button' variant='outline' size='icon' asChild>
|
||||
<a
|
||||
href={url}
|
||||
target='_blank'
|
||||
rel='noreferrer'
|
||||
aria-label={`Open ${label} repository`}
|
||||
>
|
||||
<ExternalLink className='size-4' />
|
||||
</a>
|
||||
</Button>
|
||||
{onRemove ? (
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
size='icon'
|
||||
onClick={() => void onRemove()}
|
||||
>
|
||||
<Trash2 className='size-4' />
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<Input
|
||||
readOnly
|
||||
value={url}
|
||||
className='font-mono text-xs'
|
||||
onFocus={(event) => event.currentTarget.select()}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const SpoonClonePanel = ({ spoon }: { spoon: Doc<'spoons'> }) => {
|
||||
const remotes =
|
||||
useQuery(api.spoonRemotes.listForSpoon, { spoonId: spoon._id }) ?? [];
|
||||
const createRemote = useMutation(api.spoonRemotes.create);
|
||||
const removeRemote = useMutation(api.spoonRemotes.remove);
|
||||
const [label, setLabel] = useState('');
|
||||
const [remoteName, setRemoteName] = useState('');
|
||||
const [url, setUrl] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const cloneUrl = spoon.forkUrl;
|
||||
|
||||
const addRemote = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await createRemote({
|
||||
spoonId: spoon._id,
|
||||
label,
|
||||
url,
|
||||
remoteName: remoteName || undefined,
|
||||
});
|
||||
setLabel('');
|
||||
setRemoteName('');
|
||||
setUrl('');
|
||||
toast.success('Remote added.');
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error('Could not add remote.');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className='shadow-none'>
|
||||
<CardHeader className='pb-3'>
|
||||
<CardTitle className='text-base'>Clone your fork</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className='space-y-3'>
|
||||
{cloneUrl ? (
|
||||
<>
|
||||
<RemoteRow
|
||||
label='Primary fork'
|
||||
remoteName='origin'
|
||||
url={cloneUrl}
|
||||
/>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
This GitHub fork remains Spoon's source of truth for upstream
|
||||
maintenance.
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<p className='text-muted-foreground text-sm'>
|
||||
Add fork metadata before Spoon can show a clone URL.
|
||||
</p>
|
||||
)}
|
||||
{remotes.length ? (
|
||||
<div className='space-y-3 pt-2'>
|
||||
{remotes.map((remote) => (
|
||||
<RemoteRow
|
||||
key={remote._id}
|
||||
label={remote.label}
|
||||
remoteName={remote.remoteName}
|
||||
url={remote.url}
|
||||
onRemove={async () => {
|
||||
await removeRemote({ remoteId: remote._id });
|
||||
toast.success('Remote removed.');
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { Doc } from '@spoon/backend/convex/_generated/dataModel.js';
|
||||
import { Button, Card, CardContent } from '@spoon/ui';
|
||||
|
||||
const shortSha = (sha: string) => sha.slice(0, 7);
|
||||
|
||||
const formatDate = (value?: number) =>
|
||||
value
|
||||
? new Intl.DateTimeFormat('en', { dateStyle: 'medium' }).format(value)
|
||||
: 'Unknown date';
|
||||
|
||||
export const SpoonCommitList = ({
|
||||
commits,
|
||||
empty,
|
||||
}: {
|
||||
commits: Doc<'spoonCommits'>[];
|
||||
empty: string;
|
||||
}) => (
|
||||
<div className='space-y-3'>
|
||||
{commits.length ? (
|
||||
commits.map((commit) => (
|
||||
<Card key={`${commit.side}-${commit.sha}`} className='shadow-none'>
|
||||
<CardContent className='flex flex-col gap-3 p-4 md:flex-row md:items-start md:justify-between'>
|
||||
<div className='min-w-0'>
|
||||
<p className='truncate text-sm font-medium'>
|
||||
{commit.message.split('\n')[0]}
|
||||
</p>
|
||||
<p className='text-muted-foreground mt-1 text-xs'>
|
||||
{shortSha(commit.sha)} by{' '}
|
||||
{commit.authorLogin ?? commit.authorName ?? 'unknown'} ·{' '}
|
||||
{formatDate(commit.committedAt)}
|
||||
</p>
|
||||
</div>
|
||||
{commit.htmlUrl ? (
|
||||
<Button variant='outline' size='sm' asChild>
|
||||
<a href={commit.htmlUrl} target='_blank' rel='noreferrer'>
|
||||
Open
|
||||
</a>
|
||||
</Button>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))
|
||||
) : (
|
||||
<Card className='shadow-none'>
|
||||
<CardContent className='text-muted-foreground p-6 text-sm'>
|
||||
{empty}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@@ -0,0 +1,126 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { SpoonStatusBadge } from '@/components/spoons/spoon-status-badge';
|
||||
import { useAction } from 'convex/react';
|
||||
import { makeFunctionReference } from 'convex/server';
|
||||
import { Brain, RefreshCw, RotateCw } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import type { Doc, Id } from '@spoon/backend/convex/_generated/dataModel.js';
|
||||
import { Button } from '@spoon/ui';
|
||||
|
||||
const refreshRef = makeFunctionReference<
|
||||
'action',
|
||||
{ spoonId: Id<'spoons'> },
|
||||
{
|
||||
success: boolean;
|
||||
status: string;
|
||||
upstreamAheadBy: number;
|
||||
forkAheadBy: number;
|
||||
}
|
||||
>('githubSync:refreshSpoonGithubState');
|
||||
|
||||
const syncRef = makeFunctionReference<
|
||||
'action',
|
||||
{ spoonId: Id<'spoons'> },
|
||||
unknown
|
||||
>('githubSync:syncForkWithUpstream');
|
||||
|
||||
const reviewRef = makeFunctionReference<
|
||||
'action',
|
||||
{ spoonId: Id<'spoons'> },
|
||||
{ reviewId: Id<'aiReviews'>; risk: string; recommendedAction: string }
|
||||
>('aiReviewActions:reviewLatestUpstreamChanges');
|
||||
|
||||
export const SpoonDetailHeader = ({
|
||||
spoon,
|
||||
state,
|
||||
}: {
|
||||
spoon: Doc<'spoons'>;
|
||||
state?: Doc<'spoonRepositoryStates'> | null;
|
||||
}) => {
|
||||
const refresh = useAction(refreshRef);
|
||||
const sync = useAction(syncRef);
|
||||
const review = useAction(reviewRef);
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
const canSync =
|
||||
spoon.provider === 'github' &&
|
||||
state?.status === 'behind' &&
|
||||
state.forkAheadBy === 0;
|
||||
|
||||
const run = async (label: string, action: () => Promise<unknown>) => {
|
||||
setBusy(label);
|
||||
try {
|
||||
await action();
|
||||
toast.success(`${label} complete.`);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error(`${label} failed.`);
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='border-border bg-card flex flex-col justify-between gap-5 rounded-lg border p-5 shadow-sm lg:flex-row lg:items-start'>
|
||||
<div className='min-w-0 space-y-2'>
|
||||
<div className='flex flex-wrap items-center gap-3'>
|
||||
<h1 className='truncate text-3xl font-semibold tracking-normal'>
|
||||
{spoon.name}
|
||||
</h1>
|
||||
<SpoonStatusBadge
|
||||
status={state?.status ?? spoon.syncStatus ?? spoon.status}
|
||||
/>
|
||||
</div>
|
||||
<div className='text-muted-foreground flex flex-wrap gap-x-4 gap-y-1 text-sm'>
|
||||
<a
|
||||
href={spoon.upstreamUrl}
|
||||
target='_blank'
|
||||
rel='noreferrer'
|
||||
className='hover:text-foreground'
|
||||
>
|
||||
Upstream: {spoon.upstreamOwner}/{spoon.upstreamRepo}
|
||||
</a>
|
||||
{spoon.forkUrl ? (
|
||||
<a
|
||||
href={spoon.forkUrl}
|
||||
target='_blank'
|
||||
rel='noreferrer'
|
||||
className='hover:text-foreground'
|
||||
>
|
||||
Fork: {spoon.forkOwner}/{spoon.forkRepo}
|
||||
</a>
|
||||
) : (
|
||||
<span>Fork metadata missing</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex shrink-0 flex-wrap gap-2'>
|
||||
<Button
|
||||
variant='outline'
|
||||
onClick={() => run('Refresh', () => refresh({ spoonId: spoon._id }))}
|
||||
disabled={Boolean(busy)}
|
||||
>
|
||||
<RefreshCw className='size-4' />
|
||||
Refresh
|
||||
</Button>
|
||||
<Button
|
||||
variant='outline'
|
||||
onClick={() => run('AI review', () => review({ spoonId: spoon._id }))}
|
||||
disabled={Boolean(busy)}
|
||||
>
|
||||
<Brain className='size-4' />
|
||||
Review with AI
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => run('Sync', () => sync({ spoonId: spoon._id }))}
|
||||
disabled={Boolean(busy) || !canSync}
|
||||
>
|
||||
<RotateCw className='size-4' />
|
||||
Sync fork
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,77 @@
|
||||
import {
|
||||
Clock,
|
||||
GitCommit,
|
||||
GitPullRequest,
|
||||
ShieldCheck,
|
||||
TrendingUp,
|
||||
} from 'lucide-react';
|
||||
|
||||
import type { Doc } from '@spoon/backend/convex/_generated/dataModel.js';
|
||||
import { Card, CardContent } from '@spoon/ui';
|
||||
|
||||
const formatDate = (value?: number) =>
|
||||
value
|
||||
? new Intl.DateTimeFormat('en', { dateStyle: 'medium' }).format(value)
|
||||
: 'Never';
|
||||
|
||||
export const SpoonMetrics = ({
|
||||
spoon,
|
||||
state,
|
||||
latestReview,
|
||||
}: {
|
||||
spoon: Doc<'spoons'>;
|
||||
state?: Doc<'spoonRepositoryStates'> | null;
|
||||
latestReview?: Doc<'aiReviews'> | null;
|
||||
}) => {
|
||||
const metrics = [
|
||||
{
|
||||
label: 'Upstream waiting',
|
||||
value: state?.upstreamAheadBy ?? spoon.upstreamAheadBy ?? 0,
|
||||
icon: TrendingUp,
|
||||
},
|
||||
{
|
||||
label: 'Fork-only commits',
|
||||
value: state?.forkAheadBy ?? spoon.forkAheadBy ?? 0,
|
||||
icon: GitCommit,
|
||||
},
|
||||
{
|
||||
label: 'Open PRs',
|
||||
value:
|
||||
(state?.openForkPullRequestCount ?? 0) +
|
||||
(state?.openUpstreamPullRequestCount ?? 0),
|
||||
icon: GitPullRequest,
|
||||
},
|
||||
{
|
||||
label: 'Latest AI risk',
|
||||
value: latestReview?.risk ?? 'unknown',
|
||||
icon: ShieldCheck,
|
||||
},
|
||||
{
|
||||
label: 'Last check',
|
||||
value: formatDate(spoon.lastCheckedAt ?? state?.refreshedAt),
|
||||
icon: Clock,
|
||||
},
|
||||
];
|
||||
return (
|
||||
<div className='grid gap-3 md:grid-cols-2 xl:grid-cols-5'>
|
||||
{metrics.map((metric) => {
|
||||
const Icon = metric.icon;
|
||||
return (
|
||||
<Card key={metric.label} className='shadow-none'>
|
||||
<CardContent className='flex items-center justify-between gap-3 p-4'>
|
||||
<div className='min-w-0'>
|
||||
<p className='text-muted-foreground text-xs'>{metric.label}</p>
|
||||
<p className='mt-1 truncate text-lg font-semibold capitalize'>
|
||||
{metric.value}
|
||||
</p>
|
||||
</div>
|
||||
<div className='bg-primary/10 text-primary flex size-8 shrink-0 items-center justify-center rounded-md'>
|
||||
<Icon className='size-4' />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { Doc } from '@spoon/backend/convex/_generated/dataModel.js';
|
||||
import { Badge, Button, Card, CardContent } from '@spoon/ui';
|
||||
|
||||
export const SpoonPrList = ({
|
||||
pullRequests,
|
||||
}: {
|
||||
pullRequests: Doc<'spoonPullRequests'>[];
|
||||
}) => (
|
||||
<div className='space-y-3'>
|
||||
{pullRequests.length ? (
|
||||
pullRequests.map((pullRequest) => (
|
||||
<Card
|
||||
key={`${pullRequest.repoFullName}-${pullRequest.githubId}`}
|
||||
className='shadow-none'
|
||||
>
|
||||
<CardContent className='flex flex-col gap-3 p-4 md:flex-row md:items-start md:justify-between'>
|
||||
<div>
|
||||
<div className='flex flex-wrap items-center gap-2'>
|
||||
<p className='font-medium'>
|
||||
#{pullRequest.number} {pullRequest.title}
|
||||
</p>
|
||||
<Badge variant='outline'>{pullRequest.state}</Badge>
|
||||
<Badge variant='secondary'>
|
||||
{pullRequest.scope.replaceAll('_', ' ')}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className='text-muted-foreground mt-1 text-sm'>
|
||||
{pullRequest.repoFullName}: {pullRequest.headRef} →{' '}
|
||||
{pullRequest.baseRef}
|
||||
</p>
|
||||
</div>
|
||||
<Button variant='outline' size='sm' asChild>
|
||||
<a href={pullRequest.htmlUrl} target='_blank' rel='noreferrer'>
|
||||
Open
|
||||
</a>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))
|
||||
) : (
|
||||
<Card className='shadow-none'>
|
||||
<CardContent className='text-muted-foreground p-6 text-sm'>
|
||||
Pull requests will appear after the next GitHub refresh.
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@@ -0,0 +1,133 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useAction, useMutation, useQuery } from 'convex/react';
|
||||
import { KeyRound, Trash2 } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import type { Id } from '@spoon/backend/convex/_generated/dataModel.js';
|
||||
import { api } from '@spoon/backend/convex/_generated/api.js';
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
Input,
|
||||
Label,
|
||||
} from '@spoon/ui';
|
||||
|
||||
export const SpoonSecretsForm = ({ spoonId }: { spoonId: Id<'spoons'> }) => {
|
||||
const secrets = useQuery(api.spoonSecrets.listForSpoon, { spoonId }) ?? [];
|
||||
const createSecret = useAction(api.spoonSecretsNode.create);
|
||||
const removeSecret = useMutation(api.spoonSecrets.remove);
|
||||
const [name, setName] = useState('');
|
||||
const [value, setValue] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const save = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
setSaving(true);
|
||||
try {
|
||||
await createSecret({
|
||||
spoonId,
|
||||
name,
|
||||
value,
|
||||
description: description || undefined,
|
||||
});
|
||||
setName('');
|
||||
setValue('');
|
||||
setDescription('');
|
||||
toast.success('Spoon secret saved.');
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error('Could not save secret.');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className='shadow-none'>
|
||||
<CardHeader>
|
||||
<CardTitle className='flex items-center gap-2 text-base'>
|
||||
<KeyRound className='size-4' />
|
||||
Project secrets
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className='space-y-4'>
|
||||
<form
|
||||
onSubmit={save}
|
||||
className='grid gap-3 md:grid-cols-[1fr_1fr_1fr_auto]'
|
||||
>
|
||||
<div className='grid gap-2'>
|
||||
<Label htmlFor='secretName'>Name</Label>
|
||||
<Input
|
||||
id='secretName'
|
||||
value={name}
|
||||
placeholder='AUTHENTIK_CLIENT_ID'
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className='grid gap-2'>
|
||||
<Label htmlFor='secretValue'>Value</Label>
|
||||
<Input
|
||||
id='secretValue'
|
||||
type='password'
|
||||
value={value}
|
||||
onChange={(event) => setValue(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className='grid gap-2'>
|
||||
<Label htmlFor='secretDescription'>Description</Label>
|
||||
<Input
|
||||
id='secretDescription'
|
||||
value={description}
|
||||
placeholder='Used for local validation'
|
||||
onChange={(event) => setDescription(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className='flex items-end'>
|
||||
<Button type='submit' disabled={saving}>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
<div className='divide-border overflow-hidden rounded-md border'>
|
||||
{secrets.length ? (
|
||||
secrets.map((secret) => (
|
||||
<div
|
||||
key={secret._id}
|
||||
className='flex items-center justify-between gap-3 border-b p-3'
|
||||
>
|
||||
<div>
|
||||
<p className='font-mono text-sm'>{secret.name}</p>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
{secret.description ?? secret.valuePreview ?? 'Configured'}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
aria-label={`Remove ${secret.name}`}
|
||||
onClick={async () => {
|
||||
await removeSecret({ secretId: secret._id });
|
||||
toast.success('Secret removed.');
|
||||
}}
|
||||
>
|
||||
<Trash2 className='size-4' />
|
||||
</Button>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<p className='text-muted-foreground p-3 text-sm'>
|
||||
No secrets saved for this Spoon.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,127 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useMutation } from 'convex/react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import type { Doc } from '@spoon/backend/convex/_generated/dataModel.js';
|
||||
import { api } from '@spoon/backend/convex/_generated/api.js';
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
Label,
|
||||
Switch,
|
||||
} from '@spoon/ui';
|
||||
|
||||
export const SpoonSettingsForm = ({
|
||||
spoon,
|
||||
settings,
|
||||
}: {
|
||||
spoon: Doc<'spoons'>;
|
||||
settings?: Doc<'spoonSettings'> | null;
|
||||
}) => {
|
||||
const update = useMutation(api.spoonSettings.update);
|
||||
const [autoRefreshEnabled, setAutoRefreshEnabled] = useState(
|
||||
settings?.autoRefreshEnabled ?? true,
|
||||
);
|
||||
const [autoReviewEnabled, setAutoReviewEnabled] = useState(
|
||||
settings?.autoReviewEnabled ?? true,
|
||||
);
|
||||
const [autoSyncEnabled, setAutoSyncEnabled] = useState(
|
||||
settings?.autoSyncEnabled ?? false,
|
||||
);
|
||||
const [requireAiLowRiskForSync, setRequireAiLowRiskForSync] = useState(
|
||||
settings?.requireAiLowRiskForSync ?? true,
|
||||
);
|
||||
const [requireCleanCompareForSync, setRequireCleanCompareForSync] = useState(
|
||||
settings?.requireCleanCompareForSync ?? true,
|
||||
);
|
||||
|
||||
const save = async () => {
|
||||
try {
|
||||
await update({
|
||||
spoonId: spoon._id,
|
||||
autoRefreshEnabled,
|
||||
autoReviewEnabled,
|
||||
autoSyncEnabled,
|
||||
requireAiLowRiskForSync,
|
||||
requireCleanCompareForSync,
|
||||
});
|
||||
toast.success('Spoon settings saved.');
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error('Could not save settings.');
|
||||
}
|
||||
};
|
||||
|
||||
const rows = [
|
||||
{
|
||||
label: 'Auto refresh',
|
||||
value: autoRefreshEnabled,
|
||||
onChange: setAutoRefreshEnabled,
|
||||
},
|
||||
{
|
||||
label: 'Auto AI review',
|
||||
value: autoReviewEnabled,
|
||||
onChange: setAutoReviewEnabled,
|
||||
},
|
||||
{
|
||||
label: 'Auto sync',
|
||||
value: autoSyncEnabled,
|
||||
onChange: setAutoSyncEnabled,
|
||||
},
|
||||
{
|
||||
label: 'Require low AI risk for sync',
|
||||
value: requireAiLowRiskForSync,
|
||||
onChange: setRequireAiLowRiskForSync,
|
||||
},
|
||||
{
|
||||
label: 'Require clean compare for sync',
|
||||
value: requireCleanCompareForSync,
|
||||
onChange: setRequireCleanCompareForSync,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Card className='shadow-none'>
|
||||
<CardHeader>
|
||||
<CardTitle className='text-base'>Maintenance settings</CardTitle>
|
||||
</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>
|
||||
<div>
|
||||
<p className='text-muted-foreground text-xs'>Maintenance mode</p>
|
||||
<p className='font-medium'>
|
||||
{spoon.maintenanceMode.replaceAll('_', ' ')}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className='text-muted-foreground text-xs'>Production ref</p>
|
||||
<p className='font-medium'>
|
||||
{spoon.productionRefStrategy.replaceAll('_', ' ')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className='space-y-3'>
|
||||
{rows.map((row) => (
|
||||
<div
|
||||
key={row.label}
|
||||
className='flex items-center justify-between gap-4 border-t pt-3'
|
||||
>
|
||||
<Label>{row.label}</Label>
|
||||
<Switch checked={row.value} onCheckedChange={row.onChange} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<Button onClick={save}>Save settings</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { Doc } from '@spoon/backend/convex/_generated/dataModel.js';
|
||||
import { Badge } from '@spoon/ui';
|
||||
|
||||
type Status =
|
||||
| NonNullable<Doc<'spoons'>['syncStatus']>
|
||||
| Doc<'spoons'>['status'];
|
||||
|
||||
const labels: Record<string, string> = {
|
||||
up_to_date: 'Up to date',
|
||||
behind: 'Behind',
|
||||
ahead: 'Ahead',
|
||||
diverged: 'Diverged',
|
||||
checking: 'Checking',
|
||||
conflict: 'Conflict',
|
||||
error: 'Error',
|
||||
unknown: 'Unknown',
|
||||
active: 'Active',
|
||||
draft: 'Draft',
|
||||
needs_connection: 'Needs connection',
|
||||
paused: 'Paused',
|
||||
archived: 'Archived',
|
||||
};
|
||||
|
||||
const variants: Record<
|
||||
string,
|
||||
'default' | 'secondary' | 'destructive' | 'outline'
|
||||
> = {
|
||||
up_to_date: 'default',
|
||||
behind: 'secondary',
|
||||
ahead: 'outline',
|
||||
diverged: 'destructive',
|
||||
conflict: 'destructive',
|
||||
error: 'destructive',
|
||||
checking: 'secondary',
|
||||
active: 'default',
|
||||
};
|
||||
|
||||
export const SpoonStatusBadge = ({ status }: { status?: Status }) => {
|
||||
const value = status ?? 'unknown';
|
||||
return (
|
||||
<Badge variant={variants[value] ?? 'outline'}>
|
||||
{labels[value] ?? value.replaceAll('_', ' ')}
|
||||
</Badge>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,52 @@
|
||||
import Link from 'next/link';
|
||||
import { SpoonStatusBadge } from '@/components/spoons/spoon-status-badge';
|
||||
|
||||
import type { Doc } from '@spoon/backend/convex/_generated/dataModel.js';
|
||||
import { Button, Card, CardContent } from '@spoon/ui';
|
||||
|
||||
export const MaintenanceQueue = ({ spoons }: { spoons: Doc<'spoons'>[] }) => {
|
||||
const queued = spoons
|
||||
.filter((spoon) =>
|
||||
['behind', 'diverged', 'conflict', 'error'].includes(
|
||||
spoon.syncStatus ?? '',
|
||||
),
|
||||
)
|
||||
.sort((a, b) => (b.upstreamAheadBy ?? 0) - (a.upstreamAheadBy ?? 0));
|
||||
|
||||
return (
|
||||
<div className='space-y-3'>
|
||||
{queued.length ? (
|
||||
queued.map((spoon) => (
|
||||
<Card key={spoon._id} className='shadow-none'>
|
||||
<CardContent className='grid gap-3 p-4 md:grid-cols-[1fr_auto] md:items-center'>
|
||||
<div>
|
||||
<div className='flex flex-wrap items-center gap-2'>
|
||||
<p className='font-medium'>{spoon.name}</p>
|
||||
<SpoonStatusBadge status={spoon.syncStatus} />
|
||||
</div>
|
||||
<p className='text-muted-foreground mt-1 text-sm'>
|
||||
{spoon.upstreamOwner}/{spoon.upstreamRepo} →{' '}
|
||||
{spoon.forkOwner ?? 'unknown'}/{spoon.forkRepo ?? 'unknown'}
|
||||
</p>
|
||||
<p className='text-muted-foreground mt-1 text-xs'>
|
||||
{spoon.upstreamAheadBy ?? 0} upstream commit(s),{' '}
|
||||
{spoon.forkAheadBy ?? 0} fork-only commit(s)
|
||||
</p>
|
||||
</div>
|
||||
<Button variant='outline' size='sm' asChild>
|
||||
<Link href={`/spoons/${spoon._id}`}>Open Spoon</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))
|
||||
) : (
|
||||
<Card className='shadow-none'>
|
||||
<CardContent className='text-muted-foreground p-6 text-sm'>
|
||||
No Spoons currently need review. Refresh GitHub state to populate
|
||||
this queue.
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -9,6 +9,16 @@ export const env = createEnv({
|
||||
SKIP_ENV_VALIDATION: z.boolean().default(false),
|
||||
SENTRY_AUTH_TOKEN: z.string(),
|
||||
CI: z.boolean().default(false),
|
||||
AUTH_GITHUB_ID: z.string().optional(),
|
||||
AUTH_GITHUB_SECRET: z.string().optional(),
|
||||
GITHUB_APP_ID: z.string().optional(),
|
||||
GITHUB_APP_CLIENT_ID: z.string().optional(),
|
||||
GITHUB_APP_CLIENT_SECRET: z.string().optional(),
|
||||
GITHUB_APP_PRIVATE_KEY: z.string().optional(),
|
||||
GITHUB_APP_WEBHOOK_SECRET: z.string().optional(),
|
||||
GITHUB_APP_SLUG: z.string().optional(),
|
||||
GITHUB_APP_INSTALLATION_ID: z.string().optional(),
|
||||
GITHUB_APP_OWNER: z.string().optional(),
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -32,6 +42,16 @@ export const env = createEnv({
|
||||
SKIP_ENV_VALIDATION: process.env.SKIP_ENV_VALIDATION,
|
||||
SENTRY_AUTH_TOKEN: process.env.SENTRY_AUTH_TOKEN,
|
||||
CI: process.env.CI,
|
||||
AUTH_GITHUB_ID: process.env.AUTH_GITHUB_ID,
|
||||
AUTH_GITHUB_SECRET: process.env.AUTH_GITHUB_SECRET,
|
||||
GITHUB_APP_ID: process.env.GITHUB_APP_ID,
|
||||
GITHUB_APP_CLIENT_ID: process.env.GITHUB_APP_CLIENT_ID,
|
||||
GITHUB_APP_CLIENT_SECRET: process.env.GITHUB_APP_CLIENT_SECRET,
|
||||
GITHUB_APP_PRIVATE_KEY: process.env.GITHUB_APP_PRIVATE_KEY,
|
||||
GITHUB_APP_WEBHOOK_SECRET: process.env.GITHUB_APP_WEBHOOK_SECRET,
|
||||
GITHUB_APP_SLUG: process.env.GITHUB_APP_SLUG,
|
||||
GITHUB_APP_INSTALLATION_ID: process.env.GITHUB_APP_INSTALLATION_ID,
|
||||
GITHUB_APP_OWNER: process.env.GITHUB_APP_OWNER,
|
||||
NEXT_PUBLIC_SITE_URL: process.env.NEXT_PUBLIC_SITE_URL,
|
||||
NEXT_PUBLIC_CONVEX_URL: process.env.NEXT_PUBLIC_CONVEX_URL,
|
||||
NEXT_PUBLIC_PLAUSIBLE_URL: process.env.NEXT_PUBLIC_PLAUSIBLE_URL,
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
export const isRemoteImageUrl = (value: string | null | undefined) => {
|
||||
if (!value) return false;
|
||||
return value.startsWith('http://') || value.startsWith('https://');
|
||||
};
|
||||
@@ -10,6 +10,8 @@ const isProtectedRoute = createRouteMatcher([
|
||||
'/spoons(.*)',
|
||||
'/updates(.*)',
|
||||
'/agents(.*)',
|
||||
'/github(.*)',
|
||||
'/settings(.*)',
|
||||
'/profile(.*)',
|
||||
]);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user