Add features & update project
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user