Add features & update project
Build and Push Spoon Images / quality (push) Successful in 1m41s
Build and Push Spoon Images / build-images (push) Successful in 7m4s

This commit is contained in:
Gabriel Brown
2026-06-23 02:06:58 -04:00
parent fe72fc2957
commit d207b8b0b8
26 changed files with 1257 additions and 231 deletions
@@ -13,12 +13,14 @@ export const AgentThread = ({
events,
interactions,
disabled,
agentTurnActive,
}: {
jobId: string;
messages: Doc<'agentJobMessages'>[];
events: Doc<'agentJobEvents'>[];
interactions: Doc<'agentInteractionRequests'>[];
disabled: boolean;
agentTurnActive: boolean;
}) => {
const [content, setContent] = useState('');
const [sending, setSending] = useState(false);
@@ -94,7 +96,7 @@ export const AgentThread = ({
type='button'
variant='outline'
size='sm'
disabled={disabled}
disabled={disabled || !agentTurnActive}
onClick={abort}
>
<Ban className='size-3' />
@@ -6,7 +6,7 @@ import { toast } from 'sonner';
import type { Id } from '@spoon/backend/convex/_generated/dataModel.js';
import { api } from '@spoon/backend/convex/_generated/api.js';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@spoon/ui';
import { Button, Tabs, TabsContent, TabsList, TabsTrigger } from '@spoon/ui';
import type { DiffResponse, FileResponse, FileTreeNode } from './types';
import { AgentThread } from './agent-thread';
@@ -40,6 +40,9 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
}) ?? [];
const uiState = useQuery(api.agentJobs.getWorkspaceUiState, { jobId });
const patchUiState = useMutation(api.agentJobs.patchWorkspaceUiState);
const createJobForThread = useMutation(api.agentJobs.createForThread);
const deleteWorkspace = useMutation(api.agentJobs.deleteWorkspace);
const markWorkspaceLost = useMutation(api.agentJobs.markWorkspaceLost);
const [tree, setTree] = useState<FileTreeNode | null>(null);
const [files, setFiles] = useState<Record<string, OpenFileState>>({});
const [openFilePaths, setOpenFilePaths] = useState<string[]>([]);
@@ -50,6 +53,8 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
const [vimEnabled, setVimEnabled] = useState(false);
const [hydratedUiState, setHydratedUiState] = useState(false);
const [diff, setDiff] = useState('');
const [workspaceError, setWorkspaceError] = useState<string>();
const [agentTurnActive, setAgentTurnActive] = useState(false);
const workspaceDisabled =
!job ||
@@ -62,6 +67,7 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
const response = await fetch(`/api/agent-jobs/${jobId}/tree`);
if (!response.ok) throw new Error(await response.text());
const data = (await response.json()) as { tree: FileTreeNode | null };
setWorkspaceError(undefined);
setTree(data.tree);
}, [jobId]);
@@ -69,9 +75,20 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
const response = await fetch(`/api/agent-jobs/${jobId}/diff`);
if (!response.ok) throw new Error(await response.text());
const data = (await response.json()) as DiffResponse;
setWorkspaceError(undefined);
setDiff(data.diff);
}, [jobId]);
const loadAgentStatus = useCallback(async () => {
const response = await fetch(`/api/agent-jobs/${jobId}/agent/status`);
if (!response.ok) {
setAgentTurnActive(false);
return;
}
const data = (await response.json()) as { active?: boolean };
setAgentTurnActive(Boolean(data.active));
}, [jobId]);
const loadFile = useCallback(
async (path: string) => {
setFiles((current) => ({
@@ -132,13 +149,27 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
const timeout = window.setTimeout(() => {
void loadTree().catch((error: unknown) => {
console.error(error);
setWorkspaceError(
error instanceof Error ? error.message : String(error),
);
});
void loadDiff().catch((error: unknown) => {
console.error(error);
setWorkspaceError(
error instanceof Error ? error.message : String(error),
);
});
void loadAgentStatus();
}, 0);
return () => window.clearTimeout(timeout);
}, [job, loadDiff, loadTree]);
}, [job, loadAgentStatus, loadDiff, loadTree]);
useEffect(() => {
const interval = window.setInterval(() => {
void loadAgentStatus();
}, 5_000);
return () => window.clearInterval(interval);
}, [loadAgentStatus]);
useEffect(() => {
if (!uiState || hydratedUiState) return;
@@ -197,6 +228,23 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
}
const activeFile = activeFilePath ? files[activeFilePath] : undefined;
const recoverWorkspace = async () => {
if (!job.threadId) return;
const newJobId = await createJobForThread({
threadId: job.threadId,
jobType: job.jobType ?? 'user_change',
});
window.location.href = `/spoons/${job.spoonId}/agent/${newJobId}`;
};
const deleteStaleWorkspace = async () => {
if (!window.confirm('Delete this stale workspace record?')) return;
await markWorkspaceLost({ jobId });
await deleteWorkspace({ jobId });
window.location.href = job.threadId
? `/threads/${job.threadId}`
: `/spoons/${job.spoonId}`;
};
const saveFile = async (content: string) => {
if (!activeFilePath) return;
@@ -280,6 +328,35 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
return (
<main className='border-border bg-muted/20 flex h-[calc(100vh-8.5rem)] min-h-[720px] flex-col overflow-hidden rounded-md border'>
<JobStatusBar job={job} />
{workspaceError ? (
<div className='border-border bg-background border-b p-4'>
<div className='border-destructive/40 bg-destructive/5 rounded-md border p-4'>
<p className='font-medium'>Workspace not active on this worker</p>
<p className='text-muted-foreground mt-1 text-sm'>
{workspaceError}
</p>
<div className='mt-3 flex flex-wrap gap-2'>
{job.threadId ? (
<Button type='button' onClick={() => void recoverWorkspace()}>
Recreate workspace run
</Button>
) : null}
<Button
type='button'
variant='outline'
onClick={() => void deleteStaleWorkspace()}
>
Delete stale workspace
</Button>
{job.threadId ? (
<Button type='button' variant='outline' asChild>
<a href={`/threads/${job.threadId}`}>Open thread</a>
</Button>
) : null}
</div>
</div>
</div>
) : null}
<div className='border-border bg-background flex items-center justify-end border-b px-4 py-2'>
<WorkspaceActions job={job} disabled={workspaceDisabled} />
</div>
@@ -362,6 +439,7 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
events={events}
interactions={interactions}
disabled={workspaceDisabled}
agentTurnActive={agentTurnActive}
/>
</TabsContent>
</Tabs>
@@ -374,6 +452,7 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
events={events}
interactions={interactions}
disabled={workspaceDisabled}
agentTurnActive={agentTurnActive}
/>
</aside>
</div>
@@ -1,8 +1,12 @@
'use client';
import type { ProviderModelOption } from '@/lib/models-dev';
import { useEffect, useMemo, useState } from 'react';
import { loadModelsDevOptions } from '@/lib/models-dev';
import type { ProviderModelOption } from '@/lib/provider-model-options';
import { useMemo, useState } from 'react';
import {
modelOptionsFromIds,
suggestedModelOptions,
supportsCustomModelOptions,
} from '@/lib/provider-model-options';
import { useAction, useMutation, useQuery } from 'convex/react';
import { makeFunctionReference } from 'convex/server';
import { KeyRound, Trash2 } from 'lucide-react';
@@ -11,6 +15,7 @@ import { toast } from 'sonner';
import type { Id } from '@spoon/backend/convex/_generated/dataModel.js';
import { api } from '@spoon/backend/convex/_generated/api.js';
import {
Badge,
Button,
Card,
CardContent,
@@ -50,6 +55,7 @@ const saveProfileRef = makeFunctionReference<
secret?: string;
baseUrl?: string;
defaultModel: string;
modelOptions?: string[];
reasoningEffort: ReasoningEffort;
enabled: boolean;
},
@@ -119,33 +125,24 @@ export const AiProviderProfilesPanel = () => {
);
const [secret, setSecret] = useState('');
const [baseUrl, setBaseUrl] = useState('');
const [defaultModelValue, setDefaultModelValue] = useState('');
const [modelOptions, setModelOptions] = useState<ProviderModelOption[]>([]);
const [defaultModelValue, setDefaultModelValue] = useState(
suggestedModelOptions('openai')[0]?.id ?? '',
);
const [modelOptions, setModelOptions] = useState<ProviderModelOption[]>(
suggestedModelOptions('openai'),
);
const [customModelId, setCustomModelId] = useState('');
const [reasoningEffort, setReasoningEffort] =
useState<ReasoningEffort>('medium');
const [enabled, setEnabled] = useState(true);
const [saving, setSaving] = useState(false);
useEffect(() => {
let cancelled = false;
loadModelsDevOptions(provider)
.then((options) => {
if (cancelled) return;
setModelOptions(options);
setDefaultModelValue((current) =>
current && options.some((option) => option.id === current)
? current
: (options[0]?.id ?? ''),
);
})
.catch((error: unknown) => {
console.error(error);
if (!cancelled) setModelOptions([]);
});
return () => {
cancelled = true;
};
}, [provider]);
const resetModelOptions = (nextProvider: Provider) => {
const options = suggestedModelOptions(nextProvider);
setModelOptions(options);
setDefaultModelValue(options[0]?.id ?? '');
setCustomModelId('');
};
const reset = () => {
setProfileId(undefined);
@@ -153,6 +150,8 @@ export const AiProviderProfilesPanel = () => {
setSecret('');
setBaseUrl('');
setDefaultModelValue('');
setModelOptions(suggestedModelOptions('openai'));
setCustomModelId('');
setReasoningEffort('medium');
setEnabled(true);
setName('OpenAI');
@@ -165,6 +164,14 @@ export const AiProviderProfilesPanel = () => {
setSecret('');
setBaseUrl(profile.baseUrl ?? '');
setDefaultModelValue(profile.defaultModel);
setModelOptions(
modelOptionsFromIds(
profile.modelOptions?.length
? profile.modelOptions
: [profile.defaultModel],
),
);
setCustomModelId('');
setReasoningEffort(profile.reasoningEffort as ReasoningEffort);
setEnabled(profile.enabled);
};
@@ -181,6 +188,7 @@ export const AiProviderProfilesPanel = () => {
secret: secret.trim() ? secret : undefined,
baseUrl: baseUrl.trim() || undefined,
defaultModel: defaultModelValue,
modelOptions: modelOptions.map((model) => model.id),
reasoningEffort,
enabled,
});
@@ -310,6 +318,7 @@ export const AiProviderProfilesPanel = () => {
onValueChange={(value) => {
const nextProvider = value as Provider;
setProvider(nextProvider);
resetModelOptions(nextProvider);
setName(
providerOptions
.find((option) => option.value === nextProvider)
@@ -397,9 +406,47 @@ export const AiProviderProfilesPanel = () => {
</SelectContent>
</Select>
<p className='text-muted-foreground text-xs'>
Models are loaded from Models.dev, the catalog OpenCode uses
for provider/model metadata.
Saved model options are used by Spoons. Add custom model IDs
for compatible provider gateways.
</p>
<div className='rounded-md border p-2'>
<p className='text-muted-foreground mb-2 text-xs'>
Available model options
</p>
<div className='flex flex-wrap gap-2'>
{modelOptions.map((model) => (
<Badge key={model.id} variant='outline'>
{model.id}
</Badge>
))}
</div>
</div>
{supportsCustomModelOptions(provider) ? (
<div className='flex gap-2'>
<Input
value={customModelId}
placeholder='provider/model-id'
onChange={(event) => setCustomModelId(event.target.value)}
/>
<Button
type='button'
variant='outline'
onClick={() => {
const id = customModelId.trim();
if (!id) return;
setModelOptions((current) =>
current.some((model) => model.id === id)
? current
: [...current, ...modelOptionsFromIds([id])],
);
setDefaultModelValue((current) => current || id);
setCustomModelId('');
}}
>
Add
</Button>
</div>
) : null}
</div>
<div className='grid gap-2'>
<Label>Thinking</Label>
@@ -75,7 +75,7 @@ const features = [
{
title: 'Provider-owned AI',
description:
'Use encrypted provider profiles, OpenCode auth, or user-owned API keys rather than a shared application key.',
'Use encrypted provider profiles: API-key providers run through OpenCode, and Codex login profiles run through the Codex CLI.',
icon: KeyRound,
},
{
@@ -119,7 +119,7 @@ const ownership = [
{
title: 'Your providers',
description:
'AI provider profiles and Codex/OpenCode auth stay encrypted and selected by you.',
'AI provider profiles, API keys, and Codex auth JSON stay encrypted and selected by you.',
icon: ShieldCheck,
},
{
@@ -63,6 +63,14 @@ export default function Footer() {
Integrations
</Link>
</li>
<li>
<Link
href='/settings/worker'
className='text-muted-foreground hover:text-foreground transition-colors'
>
Worker
</Link>
</li>
<li>
<Link
href='https://git.gbrown.org/gib/spoon'
@@ -2,7 +2,7 @@
import { useEffect, useState } from 'react';
import { useMutation, useQuery } from 'convex/react';
import { RefreshCw, Trash2, Wrench } from 'lucide-react';
import { Copy, RefreshCw, Trash2, Wrench } from 'lucide-react';
import { toast } from 'sonner';
import { api } from '@spoon/backend/convex/_generated/api.js';
@@ -47,6 +47,25 @@ export const WorkerHealthPanel = () => {
useQuery(api.agentJobs.countOldWorkspaces, { olderThanDays }) ?? 0;
const deleteOldWorkspaces = useMutation(api.agentJobs.deleteOldWorkspaces);
const copy = async (value: string) => {
await navigator.clipboard.writeText(value);
toast.success('Copied.');
};
const DiagnosticValue = ({ value }: { value: string }) => (
<dd className='flex items-center gap-2 font-mono break-all'>
<span>{value}</span>
<Button
type='button'
variant='ghost'
size='icon'
onClick={() => void copy(value)}
>
<Copy className='size-3' />
</Button>
</dd>
);
const refreshHealth = async () => {
setLoadingHealth(true);
setHealthError(undefined);
@@ -151,15 +170,15 @@ export const WorkerHealthPanel = () => {
<dl className='grid gap-3 text-sm md:grid-cols-2'>
<div>
<dt className='text-muted-foreground'>Convex</dt>
<dd className='font-mono break-all'>{health.convexUrl}</dd>
<DiagnosticValue value={health.convexUrl} />
</div>
<div>
<dt className='text-muted-foreground'>Job image</dt>
<dd className='font-mono break-all'>{health.jobImage}</dd>
<DiagnosticValue value={health.jobImage} />
</div>
<div>
<dt className='text-muted-foreground'>Workdir</dt>
<dd className='font-mono break-all'>{health.workdir}</dd>
<DiagnosticValue value={health.workdir} />
</div>
<div>
<dt className='text-muted-foreground'>Network</dt>
@@ -1,8 +1,6 @@
'use client';
import type { ProviderModelOption } from '@/lib/models-dev';
import { useEffect, useState } from 'react';
import { loadModelsDevOptions } from '@/lib/models-dev';
import { useState } from 'react';
import { useMutation, useQuery } from 'convex/react';
import { Bot } from 'lucide-react';
import { toast } from 'sonner';
@@ -53,6 +51,7 @@ export const SpoonAgentSettingsForm = ({
}) => {
const update = useMutation(api.spoonAgentSettings.update);
const profiles = useQuery(api.aiProviderProfiles.listMine, {}) ?? [];
const modelCatalog = useQuery(api.aiProviderModels.listAvailableForUser);
const configuredProfiles = profiles.filter(
(profile) => profile.enabled && profile.configured,
);
@@ -99,8 +98,12 @@ export const SpoonAgentSettingsForm = ({
? defaultProfile?._id
: aiProviderProfileId),
);
const [availableModels, setAvailableModels] = useState<ProviderModelOption[]>(
[],
const selectedModelProfile = modelCatalog?.profiles.find(
(profile) =>
profile.profileId ===
(aiProviderProfileId === '__default'
? defaultProfile?._id
: aiProviderProfileId),
);
const [agentModel, setAgentModel] = useState(
settings?.aiProviderProfileId ? settings.agentModel : '',
@@ -115,42 +118,17 @@ export const SpoonAgentSettingsForm = ({
: settings.reasoningEffort,
);
useEffect(() => {
if (!selectedProfile?.configured) {
return;
}
let cancelled = false;
loadModelsDevOptions(selectedProfile.provider)
.then((models) => {
if (cancelled) return;
setAvailableModels(models);
setAgentModel((current) =>
current && models.some((model) => model.id === current)
? current
: models.some((model) => model.id === selectedProfile.defaultModel)
? selectedProfile.defaultModel
: (models[0]?.id ?? ''),
);
setReasoningEffort(
selectedProfile.reasoningEffort === 'none'
? 'minimal'
: selectedProfile.reasoningEffort,
);
})
.catch((error: unknown) => {
console.error(error);
if (!cancelled) setAvailableModels([]);
});
return () => {
cancelled = true;
};
}, [
selectedProfile?.configured,
selectedProfile?.defaultModel,
selectedProfile?.provider,
selectedProfile?.reasoningEffort,
]);
const selectableModels = selectedProfile?.configured ? availableModels : [];
const selectableModels = selectedModelProfile?.configured
? selectedModelProfile.models
: [];
const selectedAgentModel =
agentModel && selectableModels.some((model) => model.id === agentModel)
? agentModel
: selectableModels.some(
(model) => model.id === selectedModelProfile?.defaultModel,
)
? (selectedModelProfile?.defaultModel ?? '')
: (selectableModels[0]?.id ?? '');
const save = async () => {
try {
@@ -163,9 +141,7 @@ export const SpoonAgentSettingsForm = ({
installCommand: installCommand || undefined,
checkCommand: checkCommand || undefined,
testCommand: testCommand || undefined,
agentModel: agentModel.trim()
? agentModel
: (selectableModels[0]?.id ?? undefined),
agentModel: selectedAgentModel || undefined,
reasoningEffort,
envFilePath: envFilePath as
| '.env'
@@ -249,7 +225,8 @@ export const SpoonAgentSettingsForm = ({
</SelectContent>
</Select>
<p className='text-muted-foreground text-xs'>
OpenCode jobs and maintenance review threads use this profile.
Workspaces use this profile. Use default resolves to your account
default provider.
</p>
</div>
<div className='grid gap-2'>
@@ -271,7 +248,7 @@ export const SpoonAgentSettingsForm = ({
<div className='grid gap-2'>
<Label htmlFor='agentModel'>Model</Label>
<Select
value={agentModel}
value={selectedAgentModel}
onValueChange={setAgentModel}
disabled={!selectableModels.length}
>
@@ -288,8 +265,8 @@ export const SpoonAgentSettingsForm = ({
</Select>
{!selectableModels.length ? (
<p className='text-muted-foreground text-xs'>
Configure an enabled AI provider profile in Settings before
choosing a model.
Configure an enabled AI provider profile with saved model
options in Settings before choosing a model.
</p>
) : null}
</div>
@@ -423,7 +400,7 @@ export const SpoonAgentSettingsForm = ({
onClick={save}
disabled={
!selectedProfile?.configured ||
!selectableModels.some((model) => model.id === agentModel)
!selectableModels.some((model) => model.id === selectedAgentModel)
}
>
Save agent settings
+16 -3
View File
@@ -9,7 +9,13 @@ const formatDate = (value?: number) =>
? new Intl.DateTimeFormat('en', { dateStyle: 'medium' }).format(value)
: 'Never';
export const SpoonCard = ({ spoon }: { spoon: Doc<'spoons'> }) => (
type SpoonCardData = Doc<'spoons'> & {
rawUpstreamAheadBy?: number;
effectiveUpstreamAheadBy?: number;
ignoredUpstreamCount?: number;
};
export const SpoonCard = ({ spoon }: { spoon: SpoonCardData }) => (
<Link href={`/spoons/${spoon._id}`} className='group/spoon-card block'>
<Card className='group-hover/spoon-card:border-primary/50 group-hover/spoon-card:bg-muted/20 shadow-none transition-colors'>
<CardHeader className='flex-row items-start justify-between gap-4'>
@@ -45,8 +51,15 @@ export const SpoonCard = ({ spoon }: { spoon: Doc<'spoons'> }) => (
<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>
<p className='text-muted-foreground'>Actionable upstream</p>
<p className='font-medium'>
{spoon.effectiveUpstreamAheadBy ?? spoon.upstreamAheadBy ?? 0}
</p>
{spoon.ignoredUpstreamCount ? (
<p className='text-muted-foreground text-xs'>
{spoon.ignoredUpstreamCount} ignored
</p>
) : null}
</div>
<div>
<p className='text-muted-foreground'>Fork-only commits</p>