Move to threads based system.
This commit is contained in:
@@ -17,10 +17,9 @@
|
||||
"dependencies": {
|
||||
"@octokit/auth-app": "^8.2.0",
|
||||
"@octokit/rest": "^22.0.1",
|
||||
"@openai/agents": "latest",
|
||||
"@opencode-ai/sdk": "latest",
|
||||
"convex": "catalog:convex",
|
||||
"execa": "latest",
|
||||
"openai": "^6.44.0",
|
||||
"zod": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -1,190 +0,0 @@
|
||||
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { execa } from 'execa';
|
||||
import OpenAI from 'openai';
|
||||
|
||||
const editSchema = {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
summary: { type: 'string' },
|
||||
files: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
path: { type: 'string' },
|
||||
content: { type: 'string' },
|
||||
},
|
||||
required: ['path', 'content'],
|
||||
},
|
||||
},
|
||||
commands: {
|
||||
type: 'array',
|
||||
items: { type: 'string' },
|
||||
},
|
||||
limitations: {
|
||||
type: 'array',
|
||||
items: { type: 'string' },
|
||||
},
|
||||
},
|
||||
required: ['summary', 'files', 'commands', 'limitations'],
|
||||
} as const;
|
||||
|
||||
type AgentEdit = {
|
||||
summary: string;
|
||||
files: { path: string; content: string }[];
|
||||
commands: string[];
|
||||
limitations: string[];
|
||||
};
|
||||
|
||||
const maxContextFiles = 40;
|
||||
const maxFileBytes = 12_000;
|
||||
|
||||
const safeContextFile = (file: string) =>
|
||||
!file.includes('node_modules/') &&
|
||||
!file.includes('.git/') &&
|
||||
!file.includes('dist/') &&
|
||||
!file.includes('build/') &&
|
||||
!file.includes('.next/') &&
|
||||
!file.endsWith('.lock') &&
|
||||
!file.endsWith('.png') &&
|
||||
!file.endsWith('.jpg') &&
|
||||
!file.endsWith('.jpeg') &&
|
||||
!file.endsWith('.webp') &&
|
||||
!file.endsWith('.gif') &&
|
||||
!file.endsWith('.pdf');
|
||||
|
||||
const listFiles = async (repoDir: string) => {
|
||||
const result = await execa('git', ['ls-files'], {
|
||||
cwd: repoDir,
|
||||
all: true,
|
||||
reject: false,
|
||||
});
|
||||
return result.all
|
||||
.split('\n')
|
||||
.map((file) => file.trim())
|
||||
.filter(Boolean)
|
||||
.filter(safeContextFile);
|
||||
};
|
||||
|
||||
const chooseContextFiles = (files: string[], prompt: string) => {
|
||||
const promptWords = new Set(
|
||||
prompt
|
||||
.toLowerCase()
|
||||
.split(/[^a-z0-9]+/)
|
||||
.filter((word) => word.length > 3),
|
||||
);
|
||||
const scored = files.map((file) => {
|
||||
const lower = file.toLowerCase();
|
||||
const score = [...promptWords].reduce(
|
||||
(sum, word) => sum + (lower.includes(word) ? 2 : 0),
|
||||
/(readme|package\.json|auth|env|config|route|provider)/i.exec(file)
|
||||
? 3
|
||||
: 0,
|
||||
);
|
||||
return { file, score };
|
||||
});
|
||||
return scored
|
||||
.sort((a, b) => b.score - a.score)
|
||||
.slice(0, maxContextFiles)
|
||||
.map((item) => item.file);
|
||||
};
|
||||
|
||||
const readContext = async (repoDir: string, files: string[]) => {
|
||||
const chunks = [];
|
||||
for (const file of files) {
|
||||
try {
|
||||
const content = await readFile(path.join(repoDir, file), 'utf8');
|
||||
chunks.push({
|
||||
path: file,
|
||||
content:
|
||||
content.length > maxFileBytes
|
||||
? `${content.slice(0, maxFileBytes)}\n[truncated]`
|
||||
: content,
|
||||
});
|
||||
} catch {
|
||||
// Ignore files that disappeared while context was being gathered.
|
||||
}
|
||||
}
|
||||
return chunks;
|
||||
};
|
||||
|
||||
const parseEdit = (value: string): AgentEdit => {
|
||||
const parsed = JSON.parse(value) as AgentEdit;
|
||||
if (!Array.isArray(parsed.files)) {
|
||||
throw new Error('OpenAI returned an edit without a files array.');
|
||||
}
|
||||
return parsed;
|
||||
};
|
||||
|
||||
const safePath = (repoDir: string, filePath: string) => {
|
||||
const resolved = path.resolve(repoDir, filePath);
|
||||
if (!resolved.startsWith(path.resolve(repoDir))) {
|
||||
throw new Error(`Refusing to write outside the repository: ${filePath}`);
|
||||
}
|
||||
return resolved;
|
||||
};
|
||||
|
||||
export const runOpenAiEdit = async (args: {
|
||||
repoDir: string;
|
||||
apiKey: string;
|
||||
model: string;
|
||||
reasoningEffort: 'none' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh';
|
||||
prompt: string;
|
||||
secretNames: string[];
|
||||
spoonName: string;
|
||||
upstreamFullName: string;
|
||||
forkFullName: string;
|
||||
}) => {
|
||||
const files = await listFiles(args.repoDir);
|
||||
const selectedFiles = chooseContextFiles(files, args.prompt);
|
||||
const contextFiles = await readContext(args.repoDir, selectedFiles);
|
||||
const response = await new OpenAI({ apiKey: args.apiKey }).responses.create({
|
||||
model: args.model,
|
||||
store: false,
|
||||
reasoning:
|
||||
args.reasoningEffort === 'none'
|
||||
? undefined
|
||||
: { effort: args.reasoningEffort },
|
||||
input: [
|
||||
{
|
||||
role: 'system',
|
||||
content:
|
||||
'You are a conservative coding agent working in a fork. Return complete replacement contents only for files that must change. Keep the diff minimal. Do not include secrets. Do not claim commands passed unless they are listed for the worker to run. If the context is insufficient, make the safest small change and describe limitations.',
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: JSON.stringify(
|
||||
{
|
||||
task: args.prompt,
|
||||
spoon: args.spoonName,
|
||||
upstream: args.upstreamFullName,
|
||||
fork: args.forkFullName,
|
||||
availableSecretNames: args.secretNames,
|
||||
repositoryFiles: files.slice(0, 500),
|
||||
contextFiles,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
},
|
||||
],
|
||||
text: {
|
||||
format: {
|
||||
type: 'json_schema',
|
||||
name: 'spoon_agent_file_edits',
|
||||
strict: true,
|
||||
schema: editSchema,
|
||||
},
|
||||
},
|
||||
});
|
||||
const edit = parseEdit(response.output_text);
|
||||
for (const file of edit.files) {
|
||||
const target = safePath(args.repoDir, file.path);
|
||||
await mkdir(path.dirname(target), { recursive: true });
|
||||
await writeFile(target, file.content);
|
||||
}
|
||||
return edit;
|
||||
};
|
||||
@@ -24,6 +24,11 @@ export const env = {
|
||||
workdir: process.env.SPOON_AGENT_WORKDIR?.trim() ?? '.local/agent-work',
|
||||
network: process.env.SPOON_AGENT_NETWORK?.trim(),
|
||||
pollMs: intEnv('SPOON_AGENT_POLL_MS', 5_000),
|
||||
httpPort: intEnv('SPOON_AGENT_WORKER_HTTP_PORT', 3921),
|
||||
internalToken:
|
||||
process.env.SPOON_AGENT_WORKER_INTERNAL_TOKEN?.trim() ??
|
||||
process.env.SPOON_WORKER_TOKEN?.trim() ??
|
||||
'',
|
||||
maxConcurrentJobs: intEnv('SPOON_AGENT_MAX_CONCURRENT_JOBS', 1),
|
||||
jobTimeoutMs: intEnv('SPOON_AGENT_JOB_TIMEOUT_MS', 1_800_000),
|
||||
githubAppId: requiredEnv('GITHUB_APP_ID'),
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { startWorkerServer } from './server';
|
||||
import { startWorker } from './worker';
|
||||
|
||||
startWorkerServer();
|
||||
await startWorker();
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
import { createServer } from 'node:http';
|
||||
import type { IncomingMessage, ServerResponse } from 'node:http';
|
||||
|
||||
import { env } from './env';
|
||||
import {
|
||||
getWorkspaceDiff,
|
||||
listWorkspaceTree,
|
||||
openWorkspacePullRequest,
|
||||
readWorkspaceFile,
|
||||
runWorkspaceCommand,
|
||||
sendWorkspaceMessage,
|
||||
stopWorkspace,
|
||||
writeWorkspaceFile,
|
||||
} from './worker';
|
||||
|
||||
const sendJson = (response: ServerResponse, status: number, body: unknown) => {
|
||||
response.writeHead(status, { 'content-type': 'application/json' });
|
||||
response.end(JSON.stringify(body));
|
||||
};
|
||||
|
||||
const readBody = async (request: IncomingMessage) =>
|
||||
await new Promise<string>((resolve, reject) => {
|
||||
let body = '';
|
||||
request.on('data', (chunk: Buffer) => {
|
||||
body += chunk.toString('utf8');
|
||||
});
|
||||
request.on('end', () => resolve(body));
|
||||
request.on('error', reject);
|
||||
});
|
||||
|
||||
const parseJson = async <T>(request: IncomingMessage) => {
|
||||
const body = await readBody(request);
|
||||
if (!body.trim()) return {} as T;
|
||||
return JSON.parse(body) as T;
|
||||
};
|
||||
|
||||
const requireAuth = (request: IncomingMessage) => {
|
||||
const header = request.headers.authorization;
|
||||
const token = header?.startsWith('Bearer ') ? header.slice(7) : '';
|
||||
if (!env.internalToken || token !== env.internalToken) {
|
||||
throw new Error('Unauthorized');
|
||||
}
|
||||
};
|
||||
|
||||
const jobRoute = (pathname: string) => {
|
||||
const match = /^\/jobs\/([^/]+)\/([^/]+)$/.exec(pathname);
|
||||
if (!match?.[1] || !match[2]) return null;
|
||||
return { jobId: decodeURIComponent(match[1]), action: match[2] };
|
||||
};
|
||||
|
||||
export const startWorkerServer = () => {
|
||||
const server = createServer((request, response) => {
|
||||
void (async () => {
|
||||
try {
|
||||
requireAuth(request);
|
||||
const url = new URL(
|
||||
request.url ?? '/',
|
||||
`http://localhost:${env.httpPort}`,
|
||||
);
|
||||
if (url.pathname === '/health') {
|
||||
sendJson(response, 200, { ok: true, workerId: env.workerId });
|
||||
return;
|
||||
}
|
||||
const route = jobRoute(url.pathname);
|
||||
if (!route) {
|
||||
sendJson(response, 404, { error: 'Not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.method === 'GET' && route.action === 'tree') {
|
||||
sendJson(response, 200, {
|
||||
tree: await listWorkspaceTree(route.jobId),
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (request.method === 'GET' && route.action === 'file') {
|
||||
const filePath = url.searchParams.get('path') ?? '';
|
||||
sendJson(response, 200, {
|
||||
path: filePath,
|
||||
content: await readWorkspaceFile(route.jobId, filePath),
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (request.method === 'PUT' && route.action === 'file') {
|
||||
const body = await parseJson<{ path?: string; content?: string }>(
|
||||
request,
|
||||
);
|
||||
sendJson(
|
||||
response,
|
||||
200,
|
||||
await writeWorkspaceFile(
|
||||
route.jobId,
|
||||
body.path ?? '',
|
||||
body.content ?? '',
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (request.method === 'GET' && route.action === 'diff') {
|
||||
sendJson(response, 200, {
|
||||
diff: await getWorkspaceDiff(route.jobId),
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (request.method === 'POST' && route.action === 'message') {
|
||||
const body = await parseJson<{ content?: string }>(request);
|
||||
await sendWorkspaceMessage(route.jobId, body.content ?? '');
|
||||
sendJson(response, 200, { success: true });
|
||||
return;
|
||||
}
|
||||
if (request.method === 'POST' && route.action === 'run-command') {
|
||||
const body = await parseJson<{ command?: string }>(request);
|
||||
sendJson(
|
||||
response,
|
||||
200,
|
||||
await runWorkspaceCommand(route.jobId, body.command ?? ''),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (request.method === 'POST' && route.action === 'open-pr') {
|
||||
sendJson(response, 200, await openWorkspacePullRequest(route.jobId));
|
||||
return;
|
||||
}
|
||||
if (request.method === 'POST' && route.action === 'stop') {
|
||||
sendJson(response, 200, await stopWorkspace(route.jobId));
|
||||
return;
|
||||
}
|
||||
sendJson(response, 404, { error: 'Not found' });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
sendJson(response, message === 'Unauthorized' ? 401 : 500, {
|
||||
error: message,
|
||||
});
|
||||
}
|
||||
})();
|
||||
});
|
||||
server.listen(env.httpPort, () => {
|
||||
console.log(
|
||||
`Spoon agent worker HTTP server listening on port ${env.httpPort}`,
|
||||
);
|
||||
});
|
||||
};
|
||||
+675
-113
@@ -1,11 +1,18 @@
|
||||
import { access, readFile, rm } from 'node:fs/promises';
|
||||
import {
|
||||
access,
|
||||
mkdir,
|
||||
readdir,
|
||||
readFile,
|
||||
rm,
|
||||
stat,
|
||||
writeFile,
|
||||
} from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { ConvexHttpClient } from 'convex/browser';
|
||||
|
||||
import type { Id } from '@spoon/backend/convex/_generated/dataModel.js';
|
||||
import { api } from '@spoon/backend/convex/_generated/api.js';
|
||||
|
||||
import { runOpenAiEdit } from './agent';
|
||||
import { env } from './env';
|
||||
import {
|
||||
cloneRepository,
|
||||
@@ -22,6 +29,10 @@ type Claim = {
|
||||
job: {
|
||||
_id: Id<'agentJobs'>;
|
||||
prompt: string;
|
||||
runtime?: 'openai_direct' | 'opencode';
|
||||
jobType?: 'user_change' | 'maintenance_review' | 'conflict_resolution';
|
||||
envFilePath?: string;
|
||||
materializeEnvFile?: boolean;
|
||||
baseBranch: string;
|
||||
workBranch: string;
|
||||
forkOwner: string;
|
||||
@@ -31,7 +42,26 @@ type Claim = {
|
||||
};
|
||||
spoon: { name: string };
|
||||
openai: {
|
||||
apiKey: string;
|
||||
apiKey?: string;
|
||||
model: string;
|
||||
reasoningEffort: 'none' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh';
|
||||
};
|
||||
aiProviderProfile?: {
|
||||
id: string;
|
||||
name: string;
|
||||
provider:
|
||||
| 'openai'
|
||||
| 'anthropic'
|
||||
| 'google'
|
||||
| 'openrouter'
|
||||
| 'requesty'
|
||||
| 'litellm'
|
||||
| 'cloudflare_ai_gateway'
|
||||
| 'custom_openai_compatible'
|
||||
| 'opencode_openai_login';
|
||||
authType: 'api_key' | 'opencode_auth_json' | 'none';
|
||||
secret?: string;
|
||||
baseUrl?: string;
|
||||
model: string;
|
||||
reasoningEffort: 'none' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh';
|
||||
};
|
||||
@@ -40,13 +70,30 @@ type Claim = {
|
||||
installCommand?: string;
|
||||
checkCommand?: string;
|
||||
testCommand?: string;
|
||||
autoDetectCommands?: boolean;
|
||||
} | null;
|
||||
secrets: { name: string; value: string }[];
|
||||
};
|
||||
|
||||
type ActiveWorkspace = {
|
||||
claim: Claim;
|
||||
workdir: string;
|
||||
repoDir: string;
|
||||
githubToken: string;
|
||||
redact: (value: string) => string;
|
||||
};
|
||||
|
||||
type FileTreeNode = {
|
||||
name: string;
|
||||
path: string;
|
||||
type: 'file' | 'directory';
|
||||
children?: FileTreeNode[];
|
||||
};
|
||||
|
||||
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
const client = new ConvexHttpClient(env.convexUrl);
|
||||
const activeWorkspaces = new Map<string, ActiveWorkspace>();
|
||||
|
||||
const appendEvent = async (
|
||||
jobId: Id<'agentJobs'>,
|
||||
@@ -129,8 +176,232 @@ const completeWithDraftPr = async (args: {
|
||||
...args,
|
||||
});
|
||||
|
||||
const applyMaintenanceDecision = async (
|
||||
jobId: Id<'agentJobs'>,
|
||||
decision: MaintenanceDecision,
|
||||
) =>
|
||||
await client.mutation(api.agentJobs.applyMaintenanceDecision, {
|
||||
workerToken: env.workerToken,
|
||||
workerId: env.workerId,
|
||||
jobId,
|
||||
...decision,
|
||||
});
|
||||
|
||||
const markWorkspaceActive = async (args: {
|
||||
jobId: Id<'agentJobs'>;
|
||||
opencodeSessionId?: string;
|
||||
containerId?: string;
|
||||
workspaceUrl?: string;
|
||||
}) =>
|
||||
await client.mutation(api.agentJobs.markWorkspaceActive, {
|
||||
workerToken: env.workerToken,
|
||||
workerId: env.workerId,
|
||||
workspaceExpiresAt: Date.now() + 2 * 60 * 60 * 1000,
|
||||
...args,
|
||||
});
|
||||
|
||||
const markWorkspaceStopped = async (
|
||||
jobId: Id<'agentJobs'>,
|
||||
workspaceStatus: 'stopped' | 'expired' | 'failed' = 'stopped',
|
||||
) =>
|
||||
await client.mutation(api.agentJobs.markWorkspaceStopped, {
|
||||
workerToken: env.workerToken,
|
||||
workerId: env.workerId,
|
||||
jobId,
|
||||
workspaceStatus,
|
||||
});
|
||||
|
||||
const appendMessage = async (args: {
|
||||
jobId: Id<'agentJobs'>;
|
||||
role: 'user' | 'assistant' | 'system' | 'tool';
|
||||
content: string;
|
||||
status: 'queued' | 'streaming' | 'completed' | 'failed';
|
||||
metadata?: string;
|
||||
}) =>
|
||||
await client.mutation(api.agentJobs.appendMessage, {
|
||||
workerToken: env.workerToken,
|
||||
workerId: env.workerId,
|
||||
...args,
|
||||
});
|
||||
|
||||
const recordWorkspaceChange = async (args: {
|
||||
jobId: Id<'agentJobs'>;
|
||||
path: string;
|
||||
source: 'user' | 'agent' | 'command';
|
||||
changeType: 'added' | 'modified' | 'deleted' | 'renamed';
|
||||
diff?: string;
|
||||
}) =>
|
||||
await client.mutation(api.agentJobs.recordWorkspaceChange, {
|
||||
workerToken: env.workerToken,
|
||||
workerId: env.workerId,
|
||||
...args,
|
||||
});
|
||||
|
||||
const commandToShell = (command: string) => ['bash', '-lc', command];
|
||||
|
||||
const providerEnvironment = (claim: Claim): Record<string, string> => {
|
||||
const profile = claim.aiProviderProfile;
|
||||
const secret = profile?.secret ?? claim.openai.apiKey;
|
||||
if (!secret) {
|
||||
throw new Error('No AI provider credential is configured for this job.');
|
||||
}
|
||||
const baseUrl: Record<string, string> = profile?.baseUrl
|
||||
? { OPENAI_BASE_URL: profile.baseUrl }
|
||||
: {};
|
||||
if (!profile || profile.provider === 'openai') {
|
||||
return { OPENAI_API_KEY: secret, ...baseUrl };
|
||||
}
|
||||
if (profile.provider === 'anthropic') return { ANTHROPIC_API_KEY: secret };
|
||||
if (profile.provider === 'google') return { GOOGLE_API_KEY: secret };
|
||||
if (profile.provider === 'openrouter') {
|
||||
return { OPENROUTER_API_KEY: secret, ...baseUrl };
|
||||
}
|
||||
if (profile.provider === 'requesty') {
|
||||
return { REQUESTY_API_KEY: secret, ...baseUrl };
|
||||
}
|
||||
if (profile.provider === 'cloudflare_ai_gateway') {
|
||||
return { CLOUDFLARE_API_KEY: secret, ...baseUrl };
|
||||
}
|
||||
if (
|
||||
profile.provider === 'litellm' ||
|
||||
profile.provider === 'custom_openai_compatible'
|
||||
) {
|
||||
return { OPENAI_API_KEY: secret, ...baseUrl };
|
||||
}
|
||||
throw new Error(
|
||||
'OpenCode login profiles are saved but need auth-file injection before execution.',
|
||||
);
|
||||
};
|
||||
|
||||
const opencodeModel = (claim: Claim) => {
|
||||
const profile = claim.aiProviderProfile;
|
||||
const model = profile?.model ?? claim.openai.model;
|
||||
if (model.includes('/')) return model;
|
||||
if (!profile) return `openai/${model}`;
|
||||
if (
|
||||
profile.provider === 'custom_openai_compatible' ||
|
||||
profile.provider === 'cloudflare_ai_gateway'
|
||||
) {
|
||||
return model;
|
||||
}
|
||||
if (profile.provider === 'opencode_openai_login') return `openai/${model}`;
|
||||
return `${profile.provider}/${model}`;
|
||||
};
|
||||
|
||||
const systemPromptForJob = (claim: Claim) => {
|
||||
const base = [
|
||||
`Spoon: ${claim.spoon.name}`,
|
||||
`Fork: ${claim.job.forkOwner}/${claim.job.forkRepo}`,
|
||||
`Upstream: ${claim.job.upstreamOwner}/${claim.job.upstreamRepo}`,
|
||||
`Selected secret names: ${claim.secrets.map((secret) => secret.name).join(', ') || 'none'}`,
|
||||
].join('\n');
|
||||
if (claim.job.jobType === 'maintenance_review') {
|
||||
return `${base}
|
||||
|
||||
You are reviewing upstream changes for a maintained fork.
|
||||
Determine whether the upstream commits can be safely applied.
|
||||
If the fork has no relevant customizations, recommend sync.
|
||||
If upstream changes are irrelevant to this fork, recommend ignore and list commit SHAs.
|
||||
If changes may affect custom fork commits, explain risks and recommend review PR or manual review.
|
||||
Do not claim tests passed unless commands were run.
|
||||
End with a JSON maintenance decision in this exact shape:
|
||||
{
|
||||
"decision": "sync" | "ignore" | "open_review_pr" | "manual_review" | "conflict_resolution" | "unknown",
|
||||
"risk": "low" | "medium" | "high" | "unknown",
|
||||
"summary": "string",
|
||||
"ignoredCommitShas": ["string"],
|
||||
"ignoredReason": "string",
|
||||
"recommendedAction": "string",
|
||||
"requiresUserApproval": true
|
||||
}
|
||||
|
||||
User/system request:
|
||||
${claim.job.prompt}`;
|
||||
}
|
||||
if (claim.job.jobType === 'conflict_resolution') {
|
||||
return `${base}
|
||||
|
||||
You are resolving upstream merge conflicts in a maintained fork.
|
||||
Preserve fork customizations unless the user explicitly removed that upstream behavior.
|
||||
Prefer small, reviewable changes.
|
||||
Produce a draft PR rather than committing to main.
|
||||
|
||||
Request:
|
||||
${claim.job.prompt}`;
|
||||
}
|
||||
return `${base}
|
||||
|
||||
You are working on a maintained fork managed by Spoon.
|
||||
Make the requested change only.
|
||||
Preserve fork-specific customizations.
|
||||
Do not commit secrets.
|
||||
Use selected environment variables only for running/building/testing.
|
||||
Open a draft PR only when instructed by Spoon.
|
||||
|
||||
Request:
|
||||
${claim.job.prompt}`;
|
||||
};
|
||||
|
||||
type MaintenanceDecision = {
|
||||
decision:
|
||||
| 'sync'
|
||||
| 'ignore'
|
||||
| 'open_review_pr'
|
||||
| 'manual_review'
|
||||
| 'conflict_resolution'
|
||||
| 'unknown';
|
||||
risk: 'low' | 'medium' | 'high' | 'unknown';
|
||||
summary: string;
|
||||
ignoredCommitShas: string[];
|
||||
ignoredReason: string;
|
||||
recommendedAction: string;
|
||||
requiresUserApproval: boolean;
|
||||
};
|
||||
|
||||
const parseMaintenanceDecision = (
|
||||
output: string,
|
||||
): MaintenanceDecision | null => {
|
||||
const fenced = /```(?:json)?\s*([\s\S]*?)```/.exec(output)?.[1];
|
||||
const candidates = [fenced, output.slice(output.indexOf('{'))].filter(
|
||||
Boolean,
|
||||
) as string[];
|
||||
for (const candidate of candidates) {
|
||||
try {
|
||||
const parsed = JSON.parse(
|
||||
candidate.trim(),
|
||||
) as Partial<MaintenanceDecision>;
|
||||
if (
|
||||
parsed.decision &&
|
||||
parsed.risk &&
|
||||
typeof parsed.summary === 'string'
|
||||
) {
|
||||
return {
|
||||
decision: parsed.decision,
|
||||
risk: parsed.risk,
|
||||
summary: parsed.summary,
|
||||
ignoredCommitShas: Array.isArray(parsed.ignoredCommitShas)
|
||||
? parsed.ignoredCommitShas.filter(
|
||||
(sha): sha is string => typeof sha === 'string',
|
||||
)
|
||||
: [],
|
||||
ignoredReason:
|
||||
typeof parsed.ignoredReason === 'string'
|
||||
? parsed.ignoredReason
|
||||
: '',
|
||||
recommendedAction:
|
||||
typeof parsed.recommendedAction === 'string'
|
||||
? parsed.recommendedAction
|
||||
: parsed.summary,
|
||||
requiresUserApproval: parsed.requiresUserApproval ?? true,
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// Try the next candidate.
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const fileExists = async (filePath: string) => {
|
||||
try {
|
||||
await access(filePath);
|
||||
@@ -180,28 +451,91 @@ const runProjectCommand = async (args: {
|
||||
}
|
||||
};
|
||||
|
||||
const quoteShell = (value: string) => `'${value.replaceAll("'", "'\\''")}'`;
|
||||
|
||||
const resolveWorkspace = (jobId: string) => {
|
||||
const workspace = activeWorkspaces.get(jobId);
|
||||
if (!workspace) {
|
||||
throw new Error('Agent workspace is not active on this worker.');
|
||||
}
|
||||
return workspace;
|
||||
};
|
||||
|
||||
const safeWorkspacePath = (repoDir: string, filePath: string) => {
|
||||
const resolved = path.resolve(repoDir, filePath);
|
||||
const root = path.resolve(repoDir);
|
||||
if (resolved !== root && !resolved.startsWith(`${root}${path.sep}`)) {
|
||||
throw new Error(`Refusing to access path outside repository: ${filePath}`);
|
||||
}
|
||||
return resolved;
|
||||
};
|
||||
|
||||
const fileChangedType = async (repoDir: string, filePath: string) => {
|
||||
const status = await run('git', ['status', '--short', '--', filePath], {
|
||||
cwd: repoDir,
|
||||
redact: (value) => value,
|
||||
timeoutMs: 60_000,
|
||||
});
|
||||
const code = status.output.trim().slice(0, 2);
|
||||
if (code.includes('D')) return 'deleted' as const;
|
||||
if (code.includes('A') || code.includes('?')) return 'added' as const;
|
||||
if (code.includes('R')) return 'renamed' as const;
|
||||
return 'modified' as const;
|
||||
};
|
||||
|
||||
const materializeEnvFile = async (workspace: ActiveWorkspace) => {
|
||||
const { claim, repoDir } = workspace;
|
||||
if (!claim.job.materializeEnvFile || !claim.job.envFilePath) return;
|
||||
const envPath = safeWorkspacePath(repoDir, claim.job.envFilePath);
|
||||
await mkdir(path.dirname(envPath), { recursive: true });
|
||||
const content = `${claim.secrets
|
||||
.map((secret) => `${secret.name}=${JSON.stringify(secret.value)}`)
|
||||
.join('\n')}\n`;
|
||||
await writeFile(envPath, content);
|
||||
await appendEvent(
|
||||
claim.job._id,
|
||||
'info',
|
||||
'clone',
|
||||
`Materialized selected secrets into ${claim.job.envFilePath}.`,
|
||||
);
|
||||
};
|
||||
|
||||
const detectPackageCommands = async (
|
||||
repoDir: string,
|
||||
): Promise<{ install?: string; check?: string; test?: string }> => {
|
||||
): Promise<{
|
||||
packageManager?: string;
|
||||
scripts?: string[];
|
||||
install?: string;
|
||||
check?: string;
|
||||
test?: string;
|
||||
build?: string;
|
||||
}> => {
|
||||
const packageJsonPath = path.join(repoDir, 'package.json');
|
||||
try {
|
||||
const packageJson = JSON.parse(await readFile(packageJsonPath, 'utf8')) as {
|
||||
packageManager?: string;
|
||||
scripts?: Record<string, string>;
|
||||
};
|
||||
const scripts = packageJson.scripts ?? {};
|
||||
const packageManager = (await fileExists(path.join(repoDir, 'bun.lock')))
|
||||
const declaredPackageManager = packageJson.packageManager?.split('@')[0];
|
||||
const detectedPackageManager = (await fileExists(
|
||||
path.join(repoDir, 'bun.lock'),
|
||||
))
|
||||
? 'bun'
|
||||
: (await fileExists(path.join(repoDir, 'pnpm-lock.yaml')))
|
||||
? 'pnpm'
|
||||
: (await fileExists(path.join(repoDir, 'yarn.lock')))
|
||||
? 'yarn'
|
||||
: 'npm';
|
||||
const packageManager = declaredPackageManager ?? detectedPackageManager;
|
||||
const runScript = (script: string) =>
|
||||
packageManager === 'npm'
|
||||
? `npm run ${script}`
|
||||
: `${packageManager} run ${script}`;
|
||||
|
||||
return {
|
||||
packageManager,
|
||||
scripts: Object.keys(scripts).sort(),
|
||||
install: `${packageManager} install`,
|
||||
check: scripts.typecheck
|
||||
? runScript('typecheck')
|
||||
@@ -213,6 +547,7 @@ const detectPackageCommands = async (
|
||||
? 'npm test'
|
||||
: `${packageManager} test`
|
||||
: undefined,
|
||||
build: scripts.build ? runScript('build') : undefined,
|
||||
};
|
||||
} catch {
|
||||
return {};
|
||||
@@ -250,13 +585,28 @@ ${
|
||||
|
||||
Generated by Spoon.`;
|
||||
|
||||
const ensureNoEnvFilesStaged = async (workspace: ActiveWorkspace) => {
|
||||
const status = await run('git', ['status', '--short'], {
|
||||
cwd: workspace.repoDir,
|
||||
redact: workspace.redact,
|
||||
timeoutMs: 60_000,
|
||||
});
|
||||
const envLine = status.output
|
||||
.split('\n')
|
||||
.find((line) => /\s\.env(?:$|[./-])/.test(line));
|
||||
if (envLine) {
|
||||
throw new Error(`Refusing to commit env file changes: ${envLine.trim()}`);
|
||||
}
|
||||
};
|
||||
|
||||
const runClaim = async (claim: Claim) => {
|
||||
const jobId = claim.job._id;
|
||||
const workdir = path.resolve(env.workdir, jobId);
|
||||
const secretValues = [
|
||||
claim.openai.apiKey,
|
||||
claim.openai.apiKey ?? '',
|
||||
claim.aiProviderProfile?.secret ?? '',
|
||||
...claim.secrets.map((secret) => secret.value),
|
||||
];
|
||||
].filter(Boolean);
|
||||
const redact = createRedactor(secretValues);
|
||||
try {
|
||||
await updateStatus(jobId, 'preparing');
|
||||
@@ -275,118 +625,37 @@ const runClaim = async (claim: Claim) => {
|
||||
redact,
|
||||
timeoutMs: env.jobTimeoutMs,
|
||||
});
|
||||
await updateStatus(jobId, 'running');
|
||||
await appendEvent(jobId, 'info', 'plan', 'Gathering repo context.');
|
||||
const edit = await runOpenAiEdit({
|
||||
const workspace: ActiveWorkspace = {
|
||||
claim,
|
||||
workdir,
|
||||
repoDir,
|
||||
apiKey: claim.openai.apiKey,
|
||||
model: claim.openai.model,
|
||||
reasoningEffort: claim.openai.reasoningEffort,
|
||||
prompt: claim.job.prompt,
|
||||
secretNames: claim.secrets.map((secret) => secret.name),
|
||||
spoonName: claim.spoon.name,
|
||||
upstreamFullName: `${claim.job.upstreamOwner}/${claim.job.upstreamRepo}`,
|
||||
forkFullName: `${claim.job.forkOwner}/${claim.job.forkRepo}`,
|
||||
});
|
||||
await addArtifact({
|
||||
jobId,
|
||||
kind: 'plan',
|
||||
title: 'Agent plan',
|
||||
content: edit.summary,
|
||||
contentType: 'text/markdown',
|
||||
});
|
||||
const status = await getStatus(repoDir, redact);
|
||||
if (!status.output.trim()) {
|
||||
throw new Error('No changes produced by the agent.');
|
||||
}
|
||||
const diff = await getWorktreeDiff(repoDir, redact);
|
||||
await addArtifact({
|
||||
jobId,
|
||||
kind: 'diff',
|
||||
title: 'Git diff',
|
||||
content: truncate(diff.output, 200_000),
|
||||
contentType: 'text/x-diff',
|
||||
});
|
||||
await updateStatus(jobId, 'checks_running');
|
||||
const detected = await detectPackageCommands(repoDir);
|
||||
const settings = claim.agentSettings;
|
||||
const installCommand = settings?.installCommand ?? detected.install;
|
||||
const checkCommand = settings?.checkCommand ?? detected.check;
|
||||
const testCommand = settings?.testCommand ?? detected.test;
|
||||
if (installCommand) {
|
||||
await runProjectCommand({
|
||||
command: installCommand,
|
||||
phase: 'install',
|
||||
claim,
|
||||
workdir,
|
||||
repoDir,
|
||||
redact,
|
||||
});
|
||||
}
|
||||
if (checkCommand) {
|
||||
await runProjectCommand({
|
||||
command: checkCommand,
|
||||
phase: 'check',
|
||||
claim,
|
||||
workdir,
|
||||
repoDir,
|
||||
redact,
|
||||
});
|
||||
}
|
||||
if (testCommand) {
|
||||
await runProjectCommand({
|
||||
command: testCommand,
|
||||
phase: 'test',
|
||||
claim,
|
||||
workdir,
|
||||
repoDir,
|
||||
redact,
|
||||
});
|
||||
}
|
||||
await appendEvent(jobId, 'info', 'commit', 'Committing changes.');
|
||||
const commitSha = await commitAndPush({
|
||||
repoDir,
|
||||
workBranch: claim.job.workBranch,
|
||||
message: `Agent: ${claim.job.prompt.slice(0, 72)}`,
|
||||
githubToken,
|
||||
redact,
|
||||
timeoutMs: env.jobTimeoutMs,
|
||||
});
|
||||
const prBody = buildPrBody({
|
||||
prompt: claim.job.prompt,
|
||||
summary: edit.summary,
|
||||
commands: [
|
||||
installCommand,
|
||||
checkCommand,
|
||||
testCommand,
|
||||
...edit.commands,
|
||||
].filter((command): command is string => Boolean(command)),
|
||||
limitations: edit.limitations,
|
||||
});
|
||||
};
|
||||
await materializeEnvFile(workspace);
|
||||
const detected = await detectPackageCommands(repoDir);
|
||||
await addArtifact({
|
||||
jobId,
|
||||
kind: 'pr_body',
|
||||
title: 'Draft PR body',
|
||||
content: prBody,
|
||||
contentType: 'text/markdown',
|
||||
kind: 'summary',
|
||||
title: 'Detected project commands',
|
||||
content: JSON.stringify(detected, null, 2),
|
||||
contentType: 'application/json',
|
||||
});
|
||||
await appendEvent(jobId, 'info', 'pr', 'Opening draft pull request.');
|
||||
const pullRequest = await openDraftPullRequest({
|
||||
installationId: claim.github.installationId,
|
||||
forkOwner: claim.job.forkOwner,
|
||||
forkRepo: claim.job.forkRepo,
|
||||
baseBranch: claim.job.baseBranch,
|
||||
workBranch: claim.job.workBranch,
|
||||
title: `Agent: ${claim.job.prompt.slice(0, 64)}`,
|
||||
body: prBody,
|
||||
activeWorkspaces.set(jobId, workspace);
|
||||
await markWorkspaceActive({ jobId });
|
||||
await updateStatus(jobId, 'running', {
|
||||
summary: 'Workspace is active.',
|
||||
});
|
||||
await completeWithDraftPr({
|
||||
await appendMessage({
|
||||
jobId,
|
||||
commitSha,
|
||||
pullRequestUrl: pullRequest.html_url,
|
||||
pullRequestNumber: pullRequest.number,
|
||||
summary: edit.summary,
|
||||
role: 'system',
|
||||
status: 'completed',
|
||||
content:
|
||||
'Workspace is ready. You can browse files, edit manually, run commands, or send messages to the agent.',
|
||||
});
|
||||
await appendEvent(jobId, 'info', 'cleanup', 'Agent job completed.');
|
||||
await appendEvent(jobId, 'info', 'plan', 'Interactive workspace is ready.');
|
||||
|
||||
await sendWorkspaceMessage(jobId, systemPromptForJob(claim));
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
await appendEvent(
|
||||
@@ -407,11 +676,304 @@ const runClaim = async (claim: Claim) => {
|
||||
message.toLowerCase().includes('timed out') ? 'timed_out' : 'failed',
|
||||
{ error: truncate(redact(message), 10_000) },
|
||||
);
|
||||
} finally {
|
||||
await rm(workdir, { recursive: true, force: true });
|
||||
await markWorkspaceStopped(
|
||||
jobId,
|
||||
message.toLowerCase().includes('timed out') ? 'expired' : 'failed',
|
||||
).catch((stopError: unknown) => {
|
||||
console.error(stopError);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const listWorkspaceTree = async (jobId: string) => {
|
||||
const workspace = resolveWorkspace(jobId);
|
||||
const buildNode = async (
|
||||
absolutePath: string,
|
||||
relativePath: string,
|
||||
): Promise<FileTreeNode | null> => {
|
||||
const basename = path.basename(absolutePath);
|
||||
if (['.git', 'node_modules', '.next', 'dist', 'build'].includes(basename)) {
|
||||
return null;
|
||||
}
|
||||
const stats = await stat(absolutePath);
|
||||
if (stats.isDirectory()) {
|
||||
const entries = await readdir(absolutePath);
|
||||
const children = (
|
||||
await Promise.all(
|
||||
entries
|
||||
.sort((a, b) => a.localeCompare(b))
|
||||
.map((entry) =>
|
||||
buildNode(
|
||||
path.join(absolutePath, entry),
|
||||
path.join(relativePath, entry),
|
||||
),
|
||||
),
|
||||
)
|
||||
).filter((node): node is FileTreeNode => Boolean(node));
|
||||
return {
|
||||
name: relativePath ? basename : workspace.claim.job.forkRepo,
|
||||
path: relativePath,
|
||||
type: 'directory',
|
||||
children,
|
||||
};
|
||||
}
|
||||
return { name: basename, path: relativePath, type: 'file' };
|
||||
};
|
||||
return await buildNode(workspace.repoDir, '');
|
||||
};
|
||||
|
||||
export const readWorkspaceFile = async (jobId: string, filePath: string) => {
|
||||
const workspace = resolveWorkspace(jobId);
|
||||
const target = safeWorkspacePath(workspace.repoDir, filePath);
|
||||
return await readFile(target, 'utf8');
|
||||
};
|
||||
|
||||
export const writeWorkspaceFile = async (
|
||||
jobId: string,
|
||||
filePath: string,
|
||||
content: string,
|
||||
) => {
|
||||
const workspace = resolveWorkspace(jobId);
|
||||
const target = safeWorkspacePath(workspace.repoDir, filePath);
|
||||
await mkdir(path.dirname(target), { recursive: true });
|
||||
await writeFile(target, content);
|
||||
const diff = await getWorktreeDiff(workspace.repoDir, workspace.redact);
|
||||
await recordWorkspaceChange({
|
||||
jobId: workspace.claim.job._id,
|
||||
path: filePath,
|
||||
source: 'user',
|
||||
changeType: await fileChangedType(workspace.repoDir, filePath),
|
||||
diff: truncate(diff.output, 50_000),
|
||||
});
|
||||
await appendEvent(
|
||||
workspace.claim.job._id,
|
||||
'info',
|
||||
'edit',
|
||||
`Saved ${filePath}.`,
|
||||
);
|
||||
return { success: true };
|
||||
};
|
||||
|
||||
export const getWorkspaceDiff = async (jobId: string) => {
|
||||
const workspace = resolveWorkspace(jobId);
|
||||
const diff = await getWorktreeDiff(workspace.repoDir, workspace.redact);
|
||||
return diff.output;
|
||||
};
|
||||
|
||||
export const runWorkspaceCommand = async (jobId: string, command: string) => {
|
||||
const workspace = resolveWorkspace(jobId);
|
||||
await updateStatus(workspace.claim.job._id, 'checks_running');
|
||||
await runProjectCommand({
|
||||
command,
|
||||
phase: command.includes('test') ? 'test' : 'check',
|
||||
claim: workspace.claim,
|
||||
workdir: workspace.workdir,
|
||||
repoDir: workspace.repoDir,
|
||||
redact: workspace.redact,
|
||||
});
|
||||
await updateStatus(workspace.claim.job._id, 'running');
|
||||
await recordWorkspaceChange({
|
||||
jobId: workspace.claim.job._id,
|
||||
path: '.',
|
||||
source: 'command',
|
||||
changeType: 'modified',
|
||||
diff: truncate(
|
||||
(await getWorktreeDiff(workspace.repoDir, workspace.redact)).output,
|
||||
50_000,
|
||||
),
|
||||
});
|
||||
return { success: true };
|
||||
};
|
||||
|
||||
export const sendWorkspaceMessage = async (jobId: string, prompt: string) => {
|
||||
const workspace = resolveWorkspace(jobId);
|
||||
const { claim, repoDir, redact, workdir } = workspace;
|
||||
await appendMessage({
|
||||
jobId: claim.job._id,
|
||||
role: 'user',
|
||||
status: 'completed',
|
||||
content: prompt,
|
||||
});
|
||||
await appendEvent(claim.job._id, 'info', 'plan', 'Sending message to agent.');
|
||||
|
||||
try {
|
||||
if ((claim.job.runtime ?? 'opencode') !== 'opencode') {
|
||||
throw new Error('Legacy OpenAI direct jobs are no longer supported.');
|
||||
}
|
||||
const model = opencodeModel(claim);
|
||||
const aiEnv = providerEnvironment(claim);
|
||||
const secretEnv = Object.fromEntries(
|
||||
claim.secrets.map((secret) => [secret.name, secret.value]),
|
||||
);
|
||||
const result =
|
||||
env.runtime === 'docker'
|
||||
? await runInJobContainer({
|
||||
workdir,
|
||||
command: commandToShell(
|
||||
`opencode run --model ${quoteShell(model)} ${quoteShell(prompt)}`,
|
||||
),
|
||||
environment: {
|
||||
...aiEnv,
|
||||
...secretEnv,
|
||||
},
|
||||
redact,
|
||||
timeoutMs: env.jobTimeoutMs,
|
||||
})
|
||||
: await run(
|
||||
'bash',
|
||||
[
|
||||
'-lc',
|
||||
`opencode run --model ${quoteShell(model)} ${quoteShell(prompt)}`,
|
||||
],
|
||||
{
|
||||
cwd: repoDir,
|
||||
env: {
|
||||
...aiEnv,
|
||||
...secretEnv,
|
||||
},
|
||||
redact,
|
||||
timeoutMs: env.jobTimeoutMs,
|
||||
},
|
||||
);
|
||||
await appendMessage({
|
||||
jobId: claim.job._id,
|
||||
role: 'assistant',
|
||||
status: result.exitCode === 0 ? 'completed' : 'failed',
|
||||
content: truncate(result.output, 40_000),
|
||||
});
|
||||
if (result.exitCode !== 0) {
|
||||
throw new Error(`opencode failed:\n${result.output}`);
|
||||
}
|
||||
if (claim.job.jobType === 'maintenance_review') {
|
||||
const decision = parseMaintenanceDecision(result.output);
|
||||
if (decision) {
|
||||
await addArtifact({
|
||||
jobId: claim.job._id,
|
||||
kind: 'summary',
|
||||
title: 'Maintenance decision',
|
||||
content: JSON.stringify(decision, null, 2),
|
||||
contentType: 'application/json',
|
||||
});
|
||||
await applyMaintenanceDecision(claim.job._id, decision);
|
||||
} else {
|
||||
await updateStatus(claim.job._id, 'changes_ready', {
|
||||
summary:
|
||||
'OpenCode completed the review, but Spoon could not parse a structured maintenance decision.',
|
||||
});
|
||||
}
|
||||
}
|
||||
const diff = await getWorktreeDiff(repoDir, redact);
|
||||
await addArtifact({
|
||||
jobId: claim.job._id,
|
||||
kind: 'diff',
|
||||
title: 'Git diff',
|
||||
content: truncate(diff.output, 200_000),
|
||||
contentType: 'text/x-diff',
|
||||
});
|
||||
await recordWorkspaceChange({
|
||||
jobId: claim.job._id,
|
||||
path: '.',
|
||||
source: 'agent',
|
||||
changeType: 'modified',
|
||||
diff: truncate(diff.output, 50_000),
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
await appendEvent(
|
||||
claim.job._id,
|
||||
'error',
|
||||
'cleanup',
|
||||
truncate(redact(message), 20_000),
|
||||
);
|
||||
await appendMessage({
|
||||
jobId: claim.job._id,
|
||||
role: 'assistant',
|
||||
status: 'failed',
|
||||
content: truncate(redact(message), 40_000),
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const openWorkspacePullRequest = async (jobId: string) => {
|
||||
const workspace = resolveWorkspace(jobId);
|
||||
const { claim, repoDir, redact } = workspace;
|
||||
await ensureNoEnvFilesStaged(workspace);
|
||||
const status = await getStatus(repoDir, redact);
|
||||
if (!status.output.trim()) {
|
||||
throw new Error('No changes are ready for a draft PR.');
|
||||
}
|
||||
const diff = await getWorktreeDiff(repoDir, redact);
|
||||
const settings = claim.agentSettings;
|
||||
const detected = await detectPackageCommands(repoDir);
|
||||
const installCommand = settings?.installCommand ?? detected.install;
|
||||
const checkCommand = settings?.checkCommand ?? detected.check;
|
||||
const testCommand = settings?.testCommand ?? detected.test;
|
||||
const prBody = buildPrBody({
|
||||
prompt: claim.job.prompt,
|
||||
summary: 'Interactive Spoon agent workspace changes.',
|
||||
commands: [installCommand, checkCommand, testCommand].filter(
|
||||
(command): command is string => Boolean(command),
|
||||
),
|
||||
limitations: ['Review the draft PR before merging.'],
|
||||
});
|
||||
await addArtifact({
|
||||
jobId: claim.job._id,
|
||||
kind: 'diff',
|
||||
title: 'Final Git diff',
|
||||
content: truncate(diff.output, 200_000),
|
||||
contentType: 'text/x-diff',
|
||||
});
|
||||
await addArtifact({
|
||||
jobId: claim.job._id,
|
||||
kind: 'pr_body',
|
||||
title: 'Draft PR body',
|
||||
content: prBody,
|
||||
contentType: 'text/markdown',
|
||||
});
|
||||
const commitSha = await commitAndPush({
|
||||
repoDir,
|
||||
workBranch: claim.job.workBranch,
|
||||
message: `Agent: ${claim.job.prompt.slice(0, 72)}`,
|
||||
redact,
|
||||
timeoutMs: env.jobTimeoutMs,
|
||||
});
|
||||
if (!claim.github.installationId) {
|
||||
throw new Error('GitHub installation ID is missing.');
|
||||
}
|
||||
const pullRequest = await openDraftPullRequest({
|
||||
installationId: claim.github.installationId,
|
||||
forkOwner: claim.job.forkOwner,
|
||||
forkRepo: claim.job.forkRepo,
|
||||
baseBranch: claim.job.baseBranch,
|
||||
workBranch: claim.job.workBranch,
|
||||
title: `Agent: ${claim.job.prompt.slice(0, 64)}`,
|
||||
body: prBody,
|
||||
});
|
||||
await completeWithDraftPr({
|
||||
jobId: claim.job._id,
|
||||
commitSha,
|
||||
pullRequestUrl: pullRequest.html_url,
|
||||
pullRequestNumber: pullRequest.number,
|
||||
summary: 'Draft PR opened from interactive workspace.',
|
||||
});
|
||||
await markWorkspaceStopped(claim.job._id);
|
||||
activeWorkspaces.delete(jobId);
|
||||
await rm(workspace.workdir, { recursive: true, force: true });
|
||||
return {
|
||||
pullRequestUrl: pullRequest.html_url,
|
||||
pullRequestNumber: pullRequest.number,
|
||||
};
|
||||
};
|
||||
|
||||
export const stopWorkspace = async (jobId: string) => {
|
||||
const workspace = resolveWorkspace(jobId);
|
||||
await markWorkspaceStopped(workspace.claim.job._id);
|
||||
activeWorkspaces.delete(jobId);
|
||||
await rm(workspace.workdir, { recursive: true, force: true });
|
||||
return { success: true };
|
||||
};
|
||||
|
||||
export const startWorker = async () => {
|
||||
console.log(`Spoon agent worker ${env.workerId} polling ${env.convexUrl}`);
|
||||
for (;;) {
|
||||
|
||||
@@ -29,11 +29,9 @@ const Index = () => {
|
||||
api.syncRuns.listRecent,
|
||||
isAuthenticated ? { limit: 5 } : 'skip',
|
||||
) ?? [];
|
||||
const agentRequests =
|
||||
useQuery(
|
||||
api.agentRequests.listRecent,
|
||||
isAuthenticated ? { limit: 5 } : 'skip',
|
||||
) ?? [];
|
||||
const threads =
|
||||
useQuery(api.threads.listMine, isAuthenticated ? { limit: 5 } : 'skip') ??
|
||||
[];
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
@@ -101,8 +99,8 @@ const Index = () => {
|
||||
</View>
|
||||
<View className='flex-row gap-3'>
|
||||
<Stat label='Spoons' value={spoons.length} />
|
||||
<Stat label='Updates' value={syncRuns.length} />
|
||||
<Stat label='Agents' value={agentRequests.length} />
|
||||
<Stat label='Checks' value={syncRuns.length} />
|
||||
<Stat label='Threads' value={threads.length} />
|
||||
</View>
|
||||
<View className='border-border bg-card rounded-lg border p-4'>
|
||||
<Text className='text-foreground font-semibold'>
|
||||
|
||||
@@ -21,11 +21,14 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@convex-dev/auth": "catalog:convex",
|
||||
"@monaco-editor/react": "latest",
|
||||
"@sentry/nextjs": "^10.46.0",
|
||||
"@spoon/backend": "workspace:*",
|
||||
"@spoon/ui": "workspace:*",
|
||||
"@t3-oss/env-nextjs": "^0.13.11",
|
||||
"convex": "catalog:convex",
|
||||
"monaco-editor": "latest",
|
||||
"monaco-vim": "latest",
|
||||
"next": "^16.2.1",
|
||||
"next-plausible": "^3.12.5",
|
||||
"react": "catalog:react19",
|
||||
|
||||
@@ -1,146 +1,7 @@
|
||||
'use client';
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useMutation, useQuery } from 'convex/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,
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
Textarea,
|
||||
} from '@spoon/ui';
|
||||
|
||||
const AgentsPage = () => {
|
||||
const spoons = useQuery(api.spoons.listMine, {}) ?? [];
|
||||
const requests = useQuery(api.agentRequests.listRecent, { limit: 50 }) ?? [];
|
||||
const createRequest = useMutation(api.agentRequests.create);
|
||||
const [spoonId, setSpoonId] = useState('');
|
||||
const [targetBranch, setTargetBranch] = useState('');
|
||||
const [prompt, setPrompt] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
if (!spoonId) {
|
||||
toast.error('Choose a Spoon first.');
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await createRequest({
|
||||
spoonId: spoonId as Id<'spoons'>,
|
||||
prompt,
|
||||
targetBranch: targetBranch || undefined,
|
||||
});
|
||||
setPrompt('');
|
||||
setTargetBranch('');
|
||||
toast.success('Agent request queued.');
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error('Could not queue agent request.');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<main className='space-y-6'>
|
||||
<div>
|
||||
<h1 className='text-3xl font-semibold tracking-normal'>Agents</h1>
|
||||
<p className='text-muted-foreground mt-2'>
|
||||
Queue prompt-driven work for future AI merge request automation.
|
||||
</p>
|
||||
</div>
|
||||
<div className='grid gap-6 xl:grid-cols-[0.9fr_1.1fr]'>
|
||||
<Card className='shadow-none'>
|
||||
<CardHeader>
|
||||
<CardTitle>Request work</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className='space-y-4'>
|
||||
<div className='grid gap-2'>
|
||||
<Label>Spoon</Label>
|
||||
<Select value={spoonId} onValueChange={setSpoonId}>
|
||||
<SelectTrigger className='w-full'>
|
||||
<SelectValue placeholder='Choose a Spoon' />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{spoons.map((spoon) => (
|
||||
<SelectItem key={spoon._id} value={spoon._id}>
|
||||
{spoon.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className='grid gap-2'>
|
||||
<Label htmlFor='targetBranch'>Target branch</Label>
|
||||
<Input
|
||||
id='targetBranch'
|
||||
value={targetBranch}
|
||||
placeholder='feature/my-change'
|
||||
onChange={(event) => setTargetBranch(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className='grid gap-2'>
|
||||
<Label htmlFor='prompt'>Prompt</Label>
|
||||
<Textarea
|
||||
id='prompt'
|
||||
value={prompt}
|
||||
required
|
||||
onChange={(event) => setPrompt(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Button type='submit' disabled={submitting || !spoons.length}>
|
||||
{submitting ? 'Queueing...' : 'Queue request'}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className='shadow-none'>
|
||||
<CardHeader>
|
||||
<CardTitle>Recent requests</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{requests.length ? (
|
||||
<div className='space-y-3'>
|
||||
{requests.map((request) => (
|
||||
<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.requestType ?? 'future_code_change').replaceAll(
|
||||
'_',
|
||||
' ',
|
||||
)}{' '}
|
||||
· {request.source ?? 'user'}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className='text-muted-foreground'>
|
||||
Agent requests will appear here after you create a Spoon and
|
||||
queue work.
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
const AgentsRedirectPage = () => {
|
||||
redirect('/threads?source=user_request');
|
||||
};
|
||||
|
||||
export default AgentsPage;
|
||||
export default AgentsRedirectPage;
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
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 { MaintenanceQueue } from '@/components/threads/maintenance-queue';
|
||||
import { useQuery } from 'convex/react';
|
||||
import { Bot, GitBranch, RefreshCw, ShieldCheck } from 'lucide-react';
|
||||
import { GitBranch, MessageSquare, RefreshCw, ShieldCheck } from 'lucide-react';
|
||||
|
||||
import { api } from '@spoon/backend/convex/_generated/api.js';
|
||||
import { Button, Card, CardContent, CardHeader, CardTitle } from '@spoon/ui';
|
||||
@@ -13,9 +13,7 @@ import { Button, Card, CardContent, CardHeader, CardTitle } from '@spoon/ui';
|
||||
const DashboardPage = () => {
|
||||
const spoons = useQuery(api.spoons.listMine, {}) ?? [];
|
||||
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 threads = useQuery(api.threads.listMine, { limit: 25 }) ?? [];
|
||||
const activeSpoons = spoons.filter(
|
||||
(spoon) => spoon.status === 'active',
|
||||
).length;
|
||||
@@ -34,7 +32,8 @@ const DashboardPage = () => {
|
||||
<div>
|
||||
<h1 className='text-3xl font-semibold tracking-normal'>Dashboard</h1>
|
||||
<p className='text-muted-foreground mt-2'>
|
||||
Monitor managed forks, upstream activity, and queued agent work.
|
||||
Monitor managed forks, upstream activity, and open maintenance
|
||||
threads.
|
||||
</p>
|
||||
</div>
|
||||
<Button asChild>
|
||||
@@ -56,10 +55,17 @@ const DashboardPage = () => {
|
||||
icon={RefreshCw}
|
||||
/>
|
||||
<MetricCard
|
||||
label='Agent requests'
|
||||
value={agentRequests.length}
|
||||
note='Queued and recent'
|
||||
icon={Bot}
|
||||
label='Open threads'
|
||||
value={
|
||||
threads.filter(
|
||||
(thread) =>
|
||||
!['resolved', 'ignored', 'failed', 'cancelled'].includes(
|
||||
thread.status,
|
||||
),
|
||||
).length
|
||||
}
|
||||
note='Across all Spoons'
|
||||
icon={MessageSquare}
|
||||
/>
|
||||
<MetricCard
|
||||
label='Upstream commits'
|
||||
@@ -71,7 +77,7 @@ const DashboardPage = () => {
|
||||
|
||||
<section className='space-y-3'>
|
||||
<h2 className='text-lg font-semibold'>Maintenance queue</h2>
|
||||
<MaintenanceQueue spoons={spoons} />
|
||||
<MaintenanceQueue threads={threads} />
|
||||
</section>
|
||||
|
||||
<div className='grid gap-6 xl:grid-cols-2'>
|
||||
@@ -126,29 +132,28 @@ const DashboardPage = () => {
|
||||
</Card>
|
||||
<Card className='mt-4 shadow-none'>
|
||||
<CardHeader>
|
||||
<CardTitle className='text-base'>AI reviews</CardTitle>
|
||||
<CardTitle className='text-base'>Recent threads</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{aiReviews.length ? (
|
||||
{threads.length ? (
|
||||
<div className='space-y-3'>
|
||||
{aiReviews.map((review) => (
|
||||
{threads.slice(0, 5).map((thread) => (
|
||||
<div
|
||||
key={review._id}
|
||||
key={thread._id}
|
||||
className='border-border border p-3 text-sm'
|
||||
>
|
||||
<p className='font-medium capitalize'>
|
||||
{review.risk} risk
|
||||
</p>
|
||||
<p className='font-medium'>{thread.title}</p>
|
||||
<p className='text-muted-foreground'>
|
||||
{review.outputSummary ?? review.inputSummary}
|
||||
{thread.status.replaceAll('_', ' ')} ·{' '}
|
||||
{thread.source.replaceAll('_', ' ')}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className='text-muted-foreground text-sm'>
|
||||
OpenAI compatibility reviews will appear here after you run
|
||||
them on a Spoon.
|
||||
Threads appear when you request work or upstream changes need
|
||||
review.
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { AiProviderProfilesPanel } from '@/components/integrations/ai-provider-profiles-panel';
|
||||
|
||||
const AiProvidersPage = () => (
|
||||
<section className='max-w-5xl space-y-4'>
|
||||
<div>
|
||||
<h2 className='text-xl font-semibold'>AI providers</h2>
|
||||
<p className='text-muted-foreground mt-1 text-sm'>
|
||||
Configure encrypted API-key profiles and OpenCode auth profiles for
|
||||
agent workspaces.
|
||||
</p>
|
||||
</div>
|
||||
<AiProviderProfilesPanel />
|
||||
</section>
|
||||
);
|
||||
|
||||
export default AiProvidersPage;
|
||||
@@ -1,16 +1,5 @@
|
||||
import { OpenAiStatusPanel } from '@/components/integrations/openai-status-panel';
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
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>
|
||||
);
|
||||
const AiSettingsPage = () => redirect('/settings/ai-providers');
|
||||
|
||||
export default AiSettingsPage;
|
||||
|
||||
@@ -10,7 +10,7 @@ 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/ai-providers', label: 'AI providers', icon: Brain },
|
||||
{ href: '/settings/security', label: 'Security', icon: Shield },
|
||||
];
|
||||
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useParams } from 'next/navigation';
|
||||
import { AgentWorkspaceShell } from '@/components/agent-workspace/agent-workspace-shell';
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
|
||||
import type { Id } from '@spoon/backend/convex/_generated/dataModel.js';
|
||||
import { Button } from '@spoon/ui';
|
||||
|
||||
const AgentWorkspacePage = () => {
|
||||
const params = useParams<{ spoonId: string; jobId: string }>();
|
||||
|
||||
return (
|
||||
<main className='space-y-4'>
|
||||
<Button asChild variant='ghost' size='sm'>
|
||||
<Link href={`/spoons/${params.spoonId}`}>
|
||||
<ArrowLeft className='size-4' />
|
||||
Back to Spoon
|
||||
</Link>
|
||||
</Button>
|
||||
<AgentWorkspaceShell jobId={params.jobId as Id<'agentJobs'>} />
|
||||
</main>
|
||||
);
|
||||
};
|
||||
|
||||
export default AgentWorkspacePage;
|
||||
@@ -1,11 +1,11 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
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';
|
||||
@@ -46,12 +46,10 @@ const SpoonDetailPage = () => {
|
||||
}) ?? [];
|
||||
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 threads =
|
||||
useQuery(api.threads.listForSpoon, { spoonId, limit: 25 }) ?? [];
|
||||
const agentSettings = useQuery(api.spoonAgentSettings.getForSpoon, {
|
||||
spoonId,
|
||||
});
|
||||
@@ -68,7 +66,7 @@ const SpoonDetailPage = () => {
|
||||
<SpoonMetrics
|
||||
spoon={details.spoon}
|
||||
state={details.state}
|
||||
latestReview={details.latestReview}
|
||||
latestThread={threads[0]}
|
||||
/>
|
||||
{details.spoon.lastError ? (
|
||||
<Card className='border-destructive shadow-none'>
|
||||
@@ -95,11 +93,8 @@ const SpoonDetailPage = () => {
|
||||
<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 className='h-9 flex-none px-3' value='threads'>
|
||||
Threads
|
||||
</TabsTrigger>
|
||||
<TabsTrigger className='h-9 flex-none px-3' value='activity'>
|
||||
Activity
|
||||
@@ -125,6 +120,17 @@ const SpoonDetailPage = () => {
|
||||
'unknown'
|
||||
).replaceAll('_', ' ')}
|
||||
</p>
|
||||
{details.effectiveUpstreamAheadBy === 0 &&
|
||||
(details.state?.upstreamAheadBy ??
|
||||
details.spoon.upstreamAheadBy ??
|
||||
0) > 0 ? (
|
||||
<p className='text-muted-foreground mt-1 text-xs'>
|
||||
Up to date after ignored upstream changes. Raw upstream
|
||||
ahead:{' '}
|
||||
{details.state?.upstreamAheadBy ??
|
||||
details.spoon.upstreamAheadBy}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div>
|
||||
<p className='text-muted-foreground'>Default branches</p>
|
||||
@@ -155,37 +161,34 @@ const SpoonDetailPage = () => {
|
||||
</Card>
|
||||
<Card className='shadow-none'>
|
||||
<CardHeader className='pb-3'>
|
||||
<CardTitle className='text-base'>Latest AI review</CardTitle>
|
||||
<CardTitle className='text-base'>Latest thread</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className='space-y-3 text-sm'>
|
||||
{details.latestReview ? (
|
||||
{threads[0] ? (
|
||||
<>
|
||||
<div className='grid grid-cols-2 gap-3'>
|
||||
<div>
|
||||
<p className='text-muted-foreground'>Risk</p>
|
||||
<p className='text-muted-foreground'>Status</p>
|
||||
<p className='mt-1 font-semibold capitalize'>
|
||||
{details.latestReview.risk}
|
||||
{threads[0].status.replaceAll('_', ' ')}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className='text-muted-foreground'>Action</p>
|
||||
<p className='text-muted-foreground'>Source</p>
|
||||
<p className='mt-1 font-semibold capitalize'>
|
||||
{details.latestReview.recommendedAction.replaceAll(
|
||||
'_',
|
||||
' ',
|
||||
)}
|
||||
{threads[0].source.replaceAll('_', ' ')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className='text-muted-foreground'>
|
||||
{details.latestReview.outputSummary ??
|
||||
details.latestReview.inputSummary}
|
||||
{threads[0].summary ??
|
||||
'Open the thread to continue maintenance work.'}
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<p className='text-muted-foreground'>
|
||||
Run a refresh and AI review to get a compatibility summary
|
||||
for upstream changes.
|
||||
Refresh GitHub state or create a thread to start maintenance
|
||||
work for this Spoon.
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
@@ -239,26 +242,45 @@ const SpoonDetailPage = () => {
|
||||
<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'>
|
||||
<TabsContent value='threads' className='space-y-4'>
|
||||
<AgentRequestForm
|
||||
spoon={details.spoon}
|
||||
agentSettings={agentSettings}
|
||||
/>
|
||||
<Card className='shadow-none'>
|
||||
<CardHeader>
|
||||
<CardTitle className='text-base'>Spoon threads</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className='space-y-3'>
|
||||
{threads.length ? (
|
||||
threads.map((thread) => (
|
||||
<Link
|
||||
key={thread._id}
|
||||
href={`/threads/${thread._id}`}
|
||||
className='border-border hover:border-primary/50 block rounded-md border p-3 transition-colors'
|
||||
>
|
||||
<p className='font-medium'>{thread.title}</p>
|
||||
<p className='text-muted-foreground mt-1 text-sm'>
|
||||
{thread.status.replaceAll('_', ' ')} ·{' '}
|
||||
{thread.source.replaceAll('_', ' ')}
|
||||
</p>
|
||||
</Link>
|
||||
))
|
||||
) : (
|
||||
<p className='text-muted-foreground text-sm'>
|
||||
No threads exist for this Spoon yet.
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<AgentJobList jobs={agentJobs} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value='activity'>
|
||||
<SpoonActivityTimeline
|
||||
syncRuns={syncRuns}
|
||||
reviews={reviews}
|
||||
requests={agentRequests}
|
||||
threads={threads}
|
||||
jobs={agentJobs}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
|
||||
@@ -1,33 +1,185 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { SpoonCard } from '@/components/spoons/spoon-card';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { SpoonStatusBadge } from '@/components/spoons/spoon-status-badge';
|
||||
import { useQuery } from 'convex/react';
|
||||
import {
|
||||
ArrowUpRight,
|
||||
GitBranch,
|
||||
MessageSquare,
|
||||
RefreshCw,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { api } from '@spoon/backend/convex/_generated/api.js';
|
||||
import { Button, Card, CardContent } from '@spoon/ui';
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@spoon/ui';
|
||||
|
||||
const formatDate = (value?: number) =>
|
||||
value
|
||||
? new Intl.DateTimeFormat('en', { dateStyle: 'medium' }).format(value)
|
||||
: 'Never';
|
||||
|
||||
const SpoonsPage = () => {
|
||||
const router = useRouter();
|
||||
const spoons = useQuery(api.spoons.listMine, {}) ?? [];
|
||||
const threads = useQuery(api.threads.listMine, { limit: 100 }) ?? [];
|
||||
const active = spoons.filter((spoon) => spoon.status === 'active').length;
|
||||
const needsReview = threads.filter(
|
||||
(thread) =>
|
||||
thread.spoonId &&
|
||||
!['resolved', 'ignored', 'failed', 'cancelled'].includes(thread.status),
|
||||
).length;
|
||||
const upstreamWaiting = spoons.reduce(
|
||||
(total, spoon) => total + (spoon.upstreamAheadBy ?? 0),
|
||||
0,
|
||||
);
|
||||
|
||||
return (
|
||||
<main className='space-y-6'>
|
||||
<div className='flex flex-col justify-between gap-4 md:flex-row md:items-end'>
|
||||
<div>
|
||||
<h1 className='text-3xl font-semibold tracking-normal'>Spoons</h1>
|
||||
<p className='text-muted-foreground mt-2'>
|
||||
Managed forks you want to keep close to their upstream projects.
|
||||
Managed forks, upstream drift, active maintenance threads, and fork
|
||||
metadata in one place.
|
||||
</p>
|
||||
</div>
|
||||
<Button asChild>
|
||||
<Link href='/spoons/new'>New Spoon</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className='grid gap-3 md:grid-cols-3'>
|
||||
<Card className='shadow-none'>
|
||||
<CardContent className='flex items-center justify-between p-4'>
|
||||
<div>
|
||||
<p className='text-muted-foreground text-sm'>Managed</p>
|
||||
<p className='text-2xl font-semibold'>{spoons.length}</p>
|
||||
</div>
|
||||
<GitBranch className='text-muted-foreground size-5' />
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className='shadow-none'>
|
||||
<CardContent className='flex items-center justify-between p-4'>
|
||||
<div>
|
||||
<p className='text-muted-foreground text-sm'>Active</p>
|
||||
<p className='text-2xl font-semibold'>{active}</p>
|
||||
</div>
|
||||
<RefreshCw className='text-muted-foreground size-5' />
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className='shadow-none'>
|
||||
<CardContent className='flex items-center justify-between p-4'>
|
||||
<div>
|
||||
<p className='text-muted-foreground text-sm'>Open threads</p>
|
||||
<p className='text-2xl font-semibold'>{needsReview}</p>
|
||||
</div>
|
||||
<MessageSquare className='text-muted-foreground size-5' />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{spoons.length ? (
|
||||
<div className='grid gap-4 xl:grid-cols-2'>
|
||||
{spoons.map((spoon) => (
|
||||
<SpoonCard key={spoon._id} spoon={spoon} />
|
||||
))}
|
||||
</div>
|
||||
<Card className='shadow-none'>
|
||||
<CardContent className='p-0'>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className='pl-4'>Spoon</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Fork</TableHead>
|
||||
<TableHead>Drift</TableHead>
|
||||
<TableHead>Cadence</TableHead>
|
||||
<TableHead>Last checked</TableHead>
|
||||
<TableHead className='pr-4 text-right'>Action</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{spoons.map((spoon) => {
|
||||
const href = `/spoons/${spoon._id}`;
|
||||
return (
|
||||
<TableRow
|
||||
key={spoon._id}
|
||||
role='link'
|
||||
tabIndex={0}
|
||||
className='hover:bg-muted/50 cursor-pointer'
|
||||
onClick={() => router.push(href)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
router.push(href);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<TableCell className='pl-4'>
|
||||
<Link
|
||||
href={href}
|
||||
className='group inline-flex min-w-0 flex-col'
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<span className='group-hover:text-primary font-medium transition-colors'>
|
||||
{spoon.name}
|
||||
</span>
|
||||
<span className='text-muted-foreground text-xs'>
|
||||
{spoon.upstreamOwner}/{spoon.upstreamRepo}
|
||||
</span>
|
||||
</Link>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<SpoonStatusBadge
|
||||
status={spoon.syncStatus ?? spoon.status}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{spoon.forkOwner && spoon.forkRepo ? (
|
||||
<span className='font-medium'>
|
||||
{spoon.forkOwner}/{spoon.forkRepo}
|
||||
</span>
|
||||
) : (
|
||||
<Badge variant='outline'>Missing metadata</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className='text-sm'>
|
||||
<p>{spoon.upstreamAheadBy ?? 0} upstream</p>
|
||||
<p className='text-muted-foreground'>
|
||||
{spoon.forkAheadBy ?? 0} fork-only
|
||||
</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className='capitalize'>
|
||||
{spoon.syncCadence}
|
||||
</TableCell>
|
||||
<TableCell>{formatDate(spoon.lastCheckedAt)}</TableCell>
|
||||
<TableCell className='pr-4 text-right'>
|
||||
<Button size='sm' variant='outline' asChild>
|
||||
<Link
|
||||
href={href}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
Open
|
||||
<ArrowUpRight className='size-3' />
|
||||
</Link>
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<Card className='shadow-none'>
|
||||
<CardContent className='p-8'>
|
||||
@@ -42,6 +194,12 @@ const SpoonsPage = () => {
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{spoons.length ? (
|
||||
<p className='text-muted-foreground text-sm'>
|
||||
Raw upstream commits waiting across all Spoons: {upstreamWaiting}
|
||||
</p>
|
||||
) : null}
|
||||
</main>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useParams } from 'next/navigation';
|
||||
import { useMutation, useQuery } from 'convex/react';
|
||||
import { ArrowUpRight, CheckCircle2, XCircle } 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 {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
Textarea,
|
||||
} from '@spoon/ui';
|
||||
|
||||
const ThreadDetailPage = () => {
|
||||
const params = useParams<{ threadId: string }>();
|
||||
const threadId = params.threadId as Id<'threads'>;
|
||||
const details = useQuery(api.threads.get, { threadId });
|
||||
const messages = useQuery(api.threads.listMessages, { threadId }) ?? [];
|
||||
const appendMessage = useMutation(api.threads.appendUserMessage);
|
||||
const markResolved = useMutation(api.threads.markResolved);
|
||||
const cancel = useMutation(api.threads.cancel);
|
||||
|
||||
if (details === undefined) {
|
||||
return <main className='text-muted-foreground p-6'>Loading thread...</main>;
|
||||
}
|
||||
|
||||
const { thread, spoon, latestJob } = details;
|
||||
|
||||
const submit = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
const form = new FormData(event.currentTarget);
|
||||
const value = form.get('message');
|
||||
const content = typeof value === 'string' ? value : '';
|
||||
try {
|
||||
await appendMessage({ threadId, content });
|
||||
event.currentTarget.reset();
|
||||
toast.success('Message added.');
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error('Could not add message.');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<main className='space-y-6'>
|
||||
<div className='flex flex-col justify-between gap-4 md:flex-row md:items-start'>
|
||||
<div>
|
||||
<div className='flex flex-wrap items-center gap-2'>
|
||||
<h1 className='text-3xl font-semibold tracking-normal'>
|
||||
{thread.title}
|
||||
</h1>
|
||||
<Badge>{thread.status.replaceAll('_', ' ')}</Badge>
|
||||
<Badge variant='outline'>
|
||||
{thread.source.replaceAll('_', ' ')}
|
||||
</Badge>
|
||||
{thread.maintenanceOutcome ? (
|
||||
<Badge variant='secondary'>
|
||||
{thread.maintenanceOutcome.replaceAll('_', ' ')}
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
<p className='text-muted-foreground mt-2 max-w-3xl'>
|
||||
{thread.summary ?? 'No summary has been recorded yet.'}
|
||||
</p>
|
||||
{spoon ? (
|
||||
<Button variant='link' className='mt-2 h-auto p-0' asChild>
|
||||
<Link href={`/spoons/${spoon._id}`}>
|
||||
{spoon.name}
|
||||
<ArrowUpRight className='size-3' />
|
||||
</Link>
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
<div className='flex flex-wrap gap-2'>
|
||||
{latestJob ? (
|
||||
<Button variant='outline' asChild>
|
||||
<Link
|
||||
href={`/spoons/${latestJob.spoonId}/agent/${latestJob._id}`}
|
||||
>
|
||||
Open workspace
|
||||
</Link>
|
||||
</Button>
|
||||
) : null}
|
||||
{latestJob?.pullRequestUrl ? (
|
||||
<Button asChild>
|
||||
<a
|
||||
href={latestJob.pullRequestUrl}
|
||||
target='_blank'
|
||||
rel='noreferrer'
|
||||
>
|
||||
Open PR
|
||||
</a>
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
variant='outline'
|
||||
onClick={() =>
|
||||
markResolved({ threadId }).then(() =>
|
||||
toast.success('Thread resolved.'),
|
||||
)
|
||||
}
|
||||
>
|
||||
<CheckCircle2 className='size-4' />
|
||||
Resolve
|
||||
</Button>
|
||||
<Button
|
||||
variant='outline'
|
||||
onClick={() =>
|
||||
cancel({ threadId }).then(() =>
|
||||
toast.success('Thread cancelled.'),
|
||||
)
|
||||
}
|
||||
>
|
||||
<XCircle className='size-4' />
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='grid gap-6 xl:grid-cols-[1fr_320px]'>
|
||||
<Card className='shadow-none'>
|
||||
<CardHeader>
|
||||
<CardTitle>Conversation</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className='space-y-4'>
|
||||
{messages.map((message) => (
|
||||
<div
|
||||
key={message._id}
|
||||
className='border-border rounded-md border p-3'
|
||||
>
|
||||
<div className='mb-2 flex items-center justify-between gap-2'>
|
||||
<Badge variant='outline'>{message.role}</Badge>
|
||||
<span className='text-muted-foreground text-xs'>
|
||||
{new Date(message.createdAt).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
<p className='text-sm whitespace-pre-wrap'>{message.content}</p>
|
||||
</div>
|
||||
))}
|
||||
<form onSubmit={submit} className='space-y-3'>
|
||||
<Textarea
|
||||
name='message'
|
||||
required
|
||||
minLength={2}
|
||||
placeholder='Add context or instructions for this thread.'
|
||||
/>
|
||||
<Button type='submit'>Add message</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className='shadow-none'>
|
||||
<CardHeader>
|
||||
<CardTitle>Thread state</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className='space-y-3 text-sm'>
|
||||
<div>
|
||||
<p className='text-muted-foreground'>Priority</p>
|
||||
<p className='font-medium capitalize'>{thread.priority}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className='text-muted-foreground'>Upstream range</p>
|
||||
<p className='font-mono text-xs break-all'>
|
||||
{thread.upstreamFrom ?? 'unknown'} →{' '}
|
||||
{thread.upstreamTo ?? 'unknown'}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className='text-muted-foreground'>Latest job</p>
|
||||
<p className='font-medium'>
|
||||
{latestJob?.status.replaceAll('_', ' ') ?? 'No job queued'}
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
};
|
||||
|
||||
export default ThreadDetailPage;
|
||||
@@ -0,0 +1,128 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { useQuery } from 'convex/react';
|
||||
import { MessageSquare, Plus } from 'lucide-react';
|
||||
|
||||
import { api } from '@spoon/backend/convex/_generated/api.js';
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@spoon/ui';
|
||||
|
||||
const formatTime = (value: number) => new Date(value).toLocaleString();
|
||||
|
||||
const ThreadsPage = () => {
|
||||
const params = useSearchParams();
|
||||
const source = params.get('source') ?? 'all';
|
||||
const threads =
|
||||
useQuery(api.threads.listMine, {
|
||||
source: source as
|
||||
| 'all'
|
||||
| 'user_request'
|
||||
| 'upstream_update'
|
||||
| 'merge_conflict'
|
||||
| 'manual_review'
|
||||
| 'system',
|
||||
limit: 100,
|
||||
}) ?? [];
|
||||
|
||||
return (
|
||||
<main className='space-y-6'>
|
||||
<div className='flex flex-col justify-between gap-4 md:flex-row md:items-end'>
|
||||
<div>
|
||||
<h1 className='text-3xl font-semibold tracking-normal'>Threads</h1>
|
||||
<p className='text-muted-foreground mt-2'>
|
||||
Maintenance reviews, upstream decisions, and user-requested fork
|
||||
work across all Spoons.
|
||||
</p>
|
||||
</div>
|
||||
<Button asChild>
|
||||
<Link href='/spoons'>
|
||||
<Plus className='size-4' />
|
||||
New thread from Spoon
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className='flex flex-col gap-3 md:flex-row'>
|
||||
<Select
|
||||
value={source}
|
||||
onValueChange={(value) => {
|
||||
window.location.href =
|
||||
value === 'all' ? '/threads' : `/threads?source=${value}`;
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className='w-full md:w-56'>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value='all'>All sources</SelectItem>
|
||||
<SelectItem value='user_request'>User requests</SelectItem>
|
||||
<SelectItem value='upstream_update'>Upstream updates</SelectItem>
|
||||
<SelectItem value='merge_conflict'>Merge conflicts</SelectItem>
|
||||
<SelectItem value='manual_review'>Manual review</SelectItem>
|
||||
<SelectItem value='system'>System</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className='space-y-3'>
|
||||
{threads.length ? (
|
||||
threads.map((thread) => (
|
||||
<Link
|
||||
key={thread._id}
|
||||
href={`/threads/${thread._id}`}
|
||||
className='block'
|
||||
>
|
||||
<Card className='hover:border-primary/50 shadow-none transition-colors'>
|
||||
<CardContent className='grid gap-3 p-4 md:grid-cols-[1fr_auto] md:items-center'>
|
||||
<div className='min-w-0'>
|
||||
<div className='flex flex-wrap items-center gap-2'>
|
||||
<h2 className='truncate font-medium'>{thread.title}</h2>
|
||||
<Badge variant='outline'>
|
||||
{thread.source.replaceAll('_', ' ')}
|
||||
</Badge>
|
||||
<Badge>{thread.status.replaceAll('_', ' ')}</Badge>
|
||||
{thread.maintenanceOutcome ? (
|
||||
<Badge variant='secondary'>
|
||||
{thread.maintenanceOutcome.replaceAll('_', ' ')}
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
<p className='text-muted-foreground mt-1 line-clamp-2 text-sm'>
|
||||
{thread.summary ??
|
||||
'No summary has been recorded for this thread yet.'}
|
||||
</p>
|
||||
</div>
|
||||
<div className='text-muted-foreground text-xs md:text-right'>
|
||||
<p>{formatTime(thread.updatedAt)}</p>
|
||||
<p className='capitalize'>{thread.priority} priority</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
))
|
||||
) : (
|
||||
<Card className='shadow-none'>
|
||||
<CardContent className='text-muted-foreground flex items-center gap-3 p-6 text-sm'>
|
||||
<MessageSquare className='size-4' />
|
||||
Threads appear when you ask Spoon to change a fork or when
|
||||
upstream changes need review.
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
};
|
||||
|
||||
export default ThreadsPage;
|
||||
@@ -1,88 +1,7 @@
|
||||
'use client';
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
import { MaintenanceQueue } from '@/components/updates/maintenance-queue';
|
||||
import { useQuery } from 'convex/react';
|
||||
|
||||
import { api } from '@spoon/backend/convex/_generated/api.js';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@spoon/ui';
|
||||
|
||||
const UpdatesPage = () => {
|
||||
const runs = useQuery(api.syncRuns.listRecent, { limit: 50 }) ?? [];
|
||||
const spoons = useQuery(api.spoons.listMine, {}) ?? [];
|
||||
return (
|
||||
<main className='space-y-6'>
|
||||
<div>
|
||||
<h1 className='text-3xl font-semibold tracking-normal'>Updates</h1>
|
||||
<p className='text-muted-foreground mt-2'>
|
||||
Upstream checks, merge attempts, and AI reviews will appear here.
|
||||
</p>
|
||||
</div>
|
||||
<div className='flex flex-col gap-3 md:flex-row'>
|
||||
<Select defaultValue='all'>
|
||||
<SelectTrigger className='w-full md:w-48'>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value='all'>All statuses</SelectItem>
|
||||
<SelectItem value='needs_review'>Needs review</SelectItem>
|
||||
<SelectItem value='conflict'>Conflict</SelectItem>
|
||||
<SelectItem value='clean'>Clean</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select defaultValue='all'>
|
||||
<SelectTrigger className='w-full md:w-64'>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value='all'>All Spoons</SelectItem>
|
||||
{spoons.map((spoon) => (
|
||||
<SelectItem key={spoon._id} value={spoon._id}>
|
||||
{spoon.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Card className='shadow-none'>
|
||||
<CardHeader>
|
||||
<CardTitle>Recent sync runs</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{runs.length ? (
|
||||
<div className='space-y-3'>
|
||||
{runs.map((run) => (
|
||||
<div key={run._id} className='border-border border p-4'>
|
||||
<p className='font-medium'>{run.kind.replaceAll('_', ' ')}</p>
|
||||
<p className='text-muted-foreground text-sm'>
|
||||
{run.status.replaceAll('_', ' ')}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className='text-muted-foreground'>
|
||||
Scheduled upstream checks will appear here once provider
|
||||
connections and workers are added.
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<section className='space-y-3'>
|
||||
<h2 className='text-lg font-semibold'>Maintenance queue</h2>
|
||||
<MaintenanceQueue spoons={spoons} />
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
const UpdatesRedirectPage = () => {
|
||||
redirect('/threads?source=upstream_update');
|
||||
};
|
||||
|
||||
export default UpdatesPage;
|
||||
export default UpdatesRedirectPage;
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { proxyWorker, withOwnedJob } from '@/lib/agent-worker-proxy';
|
||||
|
||||
export const POST = async (
|
||||
request: Request,
|
||||
context: { params: Promise<{ jobId: string }> },
|
||||
) =>
|
||||
await withOwnedJob(
|
||||
context,
|
||||
async (jobId) =>
|
||||
await proxyWorker(jobId, 'run-command', {
|
||||
method: 'POST',
|
||||
body: await request.text(),
|
||||
}),
|
||||
);
|
||||
@@ -0,0 +1,10 @@
|
||||
import { proxyWorker, withOwnedJob } from '@/lib/agent-worker-proxy';
|
||||
|
||||
export const GET = async (
|
||||
_request: Request,
|
||||
context: { params: Promise<{ jobId: string }> },
|
||||
) =>
|
||||
await withOwnedJob(
|
||||
context,
|
||||
async (jobId) => await proxyWorker(jobId, 'diff', { method: 'GET' }),
|
||||
);
|
||||
@@ -0,0 +1,28 @@
|
||||
import { proxyWorker, withOwnedJob } from '@/lib/agent-worker-proxy';
|
||||
|
||||
export const GET = async (
|
||||
request: Request,
|
||||
context: { params: Promise<{ jobId: string }> },
|
||||
) =>
|
||||
await withOwnedJob(context, async (jobId) => {
|
||||
const url = new URL(request.url);
|
||||
return await proxyWorker(
|
||||
jobId,
|
||||
'file',
|
||||
{ method: 'GET' },
|
||||
new URLSearchParams({ path: url.searchParams.get('path') ?? '' }),
|
||||
);
|
||||
});
|
||||
|
||||
export const PUT = async (
|
||||
request: Request,
|
||||
context: { params: Promise<{ jobId: string }> },
|
||||
) =>
|
||||
await withOwnedJob(
|
||||
context,
|
||||
async (jobId) =>
|
||||
await proxyWorker(jobId, 'file', {
|
||||
method: 'PUT',
|
||||
body: await request.text(),
|
||||
}),
|
||||
);
|
||||
@@ -0,0 +1,14 @@
|
||||
import { proxyWorker, withOwnedJob } from '@/lib/agent-worker-proxy';
|
||||
|
||||
export const POST = async (
|
||||
request: Request,
|
||||
context: { params: Promise<{ jobId: string }> },
|
||||
) =>
|
||||
await withOwnedJob(
|
||||
context,
|
||||
async (jobId) =>
|
||||
await proxyWorker(jobId, 'message', {
|
||||
method: 'POST',
|
||||
body: await request.text(),
|
||||
}),
|
||||
);
|
||||
@@ -0,0 +1,10 @@
|
||||
import { proxyWorker, withOwnedJob } from '@/lib/agent-worker-proxy';
|
||||
|
||||
export const POST = async (
|
||||
_request: Request,
|
||||
context: { params: Promise<{ jobId: string }> },
|
||||
) =>
|
||||
await withOwnedJob(
|
||||
context,
|
||||
async (jobId) => await proxyWorker(jobId, 'open-pr', { method: 'POST' }),
|
||||
);
|
||||
@@ -0,0 +1,10 @@
|
||||
import { proxyWorker, withOwnedJob } from '@/lib/agent-worker-proxy';
|
||||
|
||||
export const POST = async (
|
||||
_request: Request,
|
||||
context: { params: Promise<{ jobId: string }> },
|
||||
) =>
|
||||
await withOwnedJob(
|
||||
context,
|
||||
async (jobId) => await proxyWorker(jobId, 'stop', { method: 'POST' }),
|
||||
);
|
||||
@@ -0,0 +1,10 @@
|
||||
import { proxyWorker, withOwnedJob } from '@/lib/agent-worker-proxy';
|
||||
|
||||
export const GET = async (
|
||||
_request: Request,
|
||||
context: { params: Promise<{ jobId: string }> },
|
||||
) =>
|
||||
await withOwnedJob(
|
||||
context,
|
||||
async (jobId) => await proxyWorker(jobId, 'tree', { method: 'GET' }),
|
||||
);
|
||||
@@ -1,10 +1,12 @@
|
||||
import {
|
||||
Agents,
|
||||
CTA,
|
||||
Features,
|
||||
Hero,
|
||||
MaintenanceDecisions,
|
||||
Security,
|
||||
ThreadedWork,
|
||||
Workflow,
|
||||
WorkspaceShowcase,
|
||||
} from '@/components/landing';
|
||||
|
||||
const Home = () => (
|
||||
@@ -12,7 +14,9 @@ const Home = () => (
|
||||
<Hero />
|
||||
<Workflow />
|
||||
<Features />
|
||||
<Agents />
|
||||
<MaintenanceDecisions />
|
||||
<ThreadedWork />
|
||||
<WorkspaceShowcase />
|
||||
<Security />
|
||||
<CTA />
|
||||
</main>
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Send } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import type { Doc } from '@spoon/backend/convex/_generated/dataModel.js';
|
||||
import { Button, Textarea } from '@spoon/ui';
|
||||
|
||||
export const AgentThread = ({
|
||||
jobId,
|
||||
messages,
|
||||
disabled,
|
||||
}: {
|
||||
jobId: string;
|
||||
messages: Doc<'agentJobMessages'>[];
|
||||
disabled: boolean;
|
||||
}) => {
|
||||
const [content, setContent] = useState('');
|
||||
const [sending, setSending] = useState(false);
|
||||
|
||||
const send = async () => {
|
||||
if (!content.trim()) return;
|
||||
setSending(true);
|
||||
try {
|
||||
const response = await fetch(`/api/agent-jobs/${jobId}/message`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ content }),
|
||||
});
|
||||
if (!response.ok) throw new Error(await response.text());
|
||||
setContent('');
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error('Could not send message.');
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='flex h-full min-h-[520px] flex-col'>
|
||||
<div className='border-border border-b p-3'>
|
||||
<h2 className='text-sm font-semibold'>Agent thread</h2>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
Messages persist with this workspace.
|
||||
</p>
|
||||
</div>
|
||||
<div className='min-h-0 flex-1 space-y-3 overflow-auto p-3'>
|
||||
{messages.map((message) => (
|
||||
<article
|
||||
key={message._id}
|
||||
className='border-border bg-background rounded-md border p-3 text-sm'
|
||||
>
|
||||
<div className='mb-2 flex items-center justify-between gap-2'>
|
||||
<span className='font-medium capitalize'>{message.role}</span>
|
||||
<span className='text-muted-foreground text-xs capitalize'>
|
||||
{message.status}
|
||||
</span>
|
||||
</div>
|
||||
<p className='whitespace-pre-wrap'>{message.content}</p>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
<div className='border-border space-y-2 border-t p-3'>
|
||||
<Textarea
|
||||
value={content}
|
||||
placeholder='Ask the agent to inspect, explain, or change this fork.'
|
||||
disabled={disabled || sending}
|
||||
onChange={(event) => setContent(event.target.value)}
|
||||
/>
|
||||
<Button
|
||||
type='button'
|
||||
className='w-full'
|
||||
disabled={disabled || sending || !content.trim()}
|
||||
onClick={send}
|
||||
>
|
||||
<Send className='size-4' />
|
||||
{sending ? 'Sending...' : 'Send'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,153 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useQuery } from 'convex/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 { Tabs, TabsContent, TabsList, TabsTrigger } from '@spoon/ui';
|
||||
|
||||
import type { DiffResponse, FileResponse, FileTreeNode } from './types';
|
||||
import { AgentThread } from './agent-thread';
|
||||
import { CodeEditor } from './code-editor';
|
||||
import { CommandPanel } from './command-panel';
|
||||
import { DiffViewer } from './diff-viewer';
|
||||
import { FileTree } from './file-tree';
|
||||
import { JobStatusBar } from './job-status-bar';
|
||||
import { WorkspaceActions } from './workspace-actions';
|
||||
|
||||
export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
|
||||
const job = useQuery(api.agentJobs.get, { jobId });
|
||||
const messages =
|
||||
useQuery(api.agentJobs.listMessages, { jobId, limit: 200 }) ?? [];
|
||||
const [tree, setTree] = useState<FileTreeNode | null>(null);
|
||||
const [selectedPath, setSelectedPath] = useState<string>();
|
||||
const [fileContent, setFileContent] = useState('');
|
||||
const [diff, setDiff] = useState('');
|
||||
|
||||
const workspaceDisabled =
|
||||
!job ||
|
||||
['draft_pr_opened', 'failed', 'cancelled', 'timed_out'].includes(
|
||||
job.status,
|
||||
) ||
|
||||
['stopped', 'expired', 'failed'].includes(job.workspaceStatus ?? '');
|
||||
|
||||
const loadTree = useCallback(async () => {
|
||||
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 };
|
||||
setTree(data.tree);
|
||||
}, [jobId]);
|
||||
|
||||
const loadDiff = useCallback(async () => {
|
||||
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;
|
||||
setDiff(data.diff);
|
||||
}, [jobId]);
|
||||
|
||||
const loadFile = useCallback(
|
||||
async (path: string) => {
|
||||
const response = await fetch(
|
||||
`/api/agent-jobs/${jobId}/file?path=${encodeURIComponent(path)}`,
|
||||
);
|
||||
if (!response.ok) throw new Error(await response.text());
|
||||
const data = (await response.json()) as FileResponse;
|
||||
setSelectedPath(data.path);
|
||||
setFileContent(data.content);
|
||||
},
|
||||
[jobId],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!job) return;
|
||||
const timeout = window.setTimeout(() => {
|
||||
void loadTree().catch((error: unknown) => {
|
||||
console.error(error);
|
||||
});
|
||||
void loadDiff().catch((error: unknown) => {
|
||||
console.error(error);
|
||||
});
|
||||
}, 0);
|
||||
return () => window.clearTimeout(timeout);
|
||||
}, [job, loadDiff, loadTree]);
|
||||
|
||||
if (job === undefined) {
|
||||
return (
|
||||
<main className='text-muted-foreground p-6'>Loading workspace...</main>
|
||||
);
|
||||
}
|
||||
|
||||
const saveFile = async (content: string) => {
|
||||
if (!selectedPath) return;
|
||||
const response = await fetch(`/api/agent-jobs/${jobId}/file`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ path: selectedPath, content }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
toast.error('Could not save file.');
|
||||
throw new Error(await response.text());
|
||||
}
|
||||
setFileContent(content);
|
||||
await loadDiff();
|
||||
toast.success('File saved.');
|
||||
};
|
||||
|
||||
return (
|
||||
<main className='border-border bg-muted/20 min-h-[calc(100vh-5rem)] overflow-hidden rounded-md border'>
|
||||
<JobStatusBar job={job} />
|
||||
<div className='border-border bg-background flex items-center justify-end border-b px-4 py-2'>
|
||||
<WorkspaceActions job={job} disabled={workspaceDisabled} />
|
||||
</div>
|
||||
<div className='grid min-h-[680px] grid-cols-1 xl:grid-cols-[260px_minmax(0,1fr)_360px]'>
|
||||
<aside className='border-border bg-background min-h-[260px] border-r'>
|
||||
<div className='border-border border-b p-3'>
|
||||
<h2 className='text-sm font-semibold'>Files</h2>
|
||||
<p className='text-muted-foreground text-xs'>Current workspace</p>
|
||||
</div>
|
||||
<FileTree
|
||||
tree={tree}
|
||||
selectedPath={selectedPath}
|
||||
onSelect={(path) => {
|
||||
void loadFile(path).catch((error) => {
|
||||
console.error(error);
|
||||
toast.error('Could not load file.');
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</aside>
|
||||
<section className='bg-background min-w-0'>
|
||||
<Tabs defaultValue='editor' className='h-full'>
|
||||
<TabsList
|
||||
variant='line'
|
||||
className='border-border h-11 w-full justify-start rounded-none border-b px-3'
|
||||
>
|
||||
<TabsTrigger value='editor'>Editor</TabsTrigger>
|
||||
<TabsTrigger value='diff'>Diff</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value='editor' className='m-0'>
|
||||
<CodeEditor
|
||||
path={selectedPath}
|
||||
content={fileContent}
|
||||
readOnly={workspaceDisabled}
|
||||
onSave={saveFile}
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value='diff' className='m-0'>
|
||||
<DiffViewer diff={diff} onRefresh={loadDiff} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
<CommandPanel jobId={jobId} disabled={workspaceDisabled} />
|
||||
</section>
|
||||
<aside className='border-border bg-muted/20 min-w-0 border-l'>
|
||||
<AgentThread
|
||||
jobId={jobId}
|
||||
messages={messages}
|
||||
disabled={workspaceDisabled}
|
||||
/>
|
||||
</aside>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,133 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import dynamic from 'next/dynamic';
|
||||
|
||||
import { Button, Switch } from '@spoon/ui';
|
||||
|
||||
const MonacoEditor = dynamic(async () => await import('@monaco-editor/react'), {
|
||||
ssr: false,
|
||||
});
|
||||
|
||||
type MonacoEditorInstance = {
|
||||
getModel?: () => unknown;
|
||||
};
|
||||
|
||||
type VimMode = {
|
||||
dispose: () => void;
|
||||
};
|
||||
|
||||
export const CodeEditor = ({
|
||||
path,
|
||||
content,
|
||||
readOnly,
|
||||
onSave,
|
||||
}: {
|
||||
path?: string;
|
||||
content: string;
|
||||
readOnly: boolean;
|
||||
onSave: (content: string) => Promise<void>;
|
||||
}) => {
|
||||
const [value, setValue] = useState(content);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [vimEnabled, setVimEnabled] = useState(false);
|
||||
const [dirty, setDirty] = useState(false);
|
||||
const editorRef = useRef<MonacoEditorInstance | null>(null);
|
||||
const vimRef = useRef<VimMode | null>(null);
|
||||
const statusRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setValue(content);
|
||||
setDirty(false);
|
||||
}, [content, path]);
|
||||
|
||||
useEffect(() => {
|
||||
const editor = editorRef.current;
|
||||
if (!editor) return;
|
||||
vimRef.current?.dispose();
|
||||
vimRef.current = null;
|
||||
if (!vimEnabled) return;
|
||||
void import('monaco-vim').then((module) => {
|
||||
const initVimMode = module.initVimMode as unknown as (
|
||||
editor: MonacoEditorInstance,
|
||||
statusNode?: HTMLElement | null,
|
||||
) => VimMode;
|
||||
vimRef.current = initVimMode(editor, statusRef.current);
|
||||
});
|
||||
return () => {
|
||||
vimRef.current?.dispose();
|
||||
vimRef.current = null;
|
||||
};
|
||||
}, [vimEnabled, path]);
|
||||
|
||||
if (!path) {
|
||||
return (
|
||||
<div className='text-muted-foreground flex h-full items-center justify-center text-sm'>
|
||||
Select a file to inspect or edit.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const save = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
await onSave(value);
|
||||
setDirty(false);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='flex h-full min-h-[520px] flex-col'>
|
||||
<div className='border-border flex h-11 items-center justify-between gap-3 border-b px-3'>
|
||||
<div className='min-w-0'>
|
||||
<p className='truncate font-mono text-xs'>{path}</p>
|
||||
{dirty ? (
|
||||
<p className='text-muted-foreground text-xs'>Unsaved changes</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className='flex items-center gap-3'>
|
||||
<label className='flex items-center gap-2 text-xs'>
|
||||
Vim
|
||||
<Switch checked={vimEnabled} onCheckedChange={setVimEnabled} />
|
||||
</label>
|
||||
<Button
|
||||
type='button'
|
||||
size='sm'
|
||||
disabled={readOnly || saving || !dirty}
|
||||
onClick={save}
|
||||
>
|
||||
{saving ? 'Saving...' : 'Save'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className='min-h-0 flex-1'>
|
||||
<MonacoEditor
|
||||
height='520px'
|
||||
path={path}
|
||||
value={value}
|
||||
theme='vs-dark'
|
||||
options={{
|
||||
readOnly,
|
||||
minimap: { enabled: false },
|
||||
fontSize: 13,
|
||||
scrollBeyondLastLine: false,
|
||||
wordWrap: 'on',
|
||||
}}
|
||||
onMount={(editor) => {
|
||||
editorRef.current = editor as MonacoEditorInstance;
|
||||
}}
|
||||
onChange={(next) => {
|
||||
setValue(next ?? '');
|
||||
setDirty((next ?? '') !== content);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
ref={statusRef}
|
||||
className='border-border text-muted-foreground h-6 border-t px-3 py-1 font-mono text-xs'
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,56 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Terminal } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { Button, Input } from '@spoon/ui';
|
||||
|
||||
export const CommandPanel = ({
|
||||
jobId,
|
||||
disabled,
|
||||
}: {
|
||||
jobId: string;
|
||||
disabled: boolean;
|
||||
}) => {
|
||||
const [command, setCommand] = useState('');
|
||||
const [running, setRunning] = useState(false);
|
||||
|
||||
const run = async () => {
|
||||
setRunning(true);
|
||||
try {
|
||||
const response = await fetch(`/api/agent-jobs/${jobId}/command`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ command }),
|
||||
});
|
||||
if (!response.ok) throw new Error(await response.text());
|
||||
toast.success('Command completed.');
|
||||
setCommand('');
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error('Command failed.');
|
||||
} finally {
|
||||
setRunning(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='border-border flex items-center gap-2 border-t p-3'>
|
||||
<Terminal className='text-muted-foreground size-4' />
|
||||
<Input
|
||||
value={command}
|
||||
placeholder='bun test, pnpm lint, npm run typecheck...'
|
||||
disabled={disabled || running}
|
||||
onChange={(event) => setCommand(event.target.value)}
|
||||
/>
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
disabled={disabled || running || !command.trim()}
|
||||
onClick={run}
|
||||
>
|
||||
{running ? 'Running...' : 'Run'}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,47 @@
|
||||
'use client';
|
||||
|
||||
import dynamic from 'next/dynamic';
|
||||
|
||||
import { Button } from '@spoon/ui';
|
||||
|
||||
const MonacoEditor = dynamic(async () => await import('@monaco-editor/react'), {
|
||||
ssr: false,
|
||||
});
|
||||
|
||||
export const DiffViewer = ({
|
||||
diff,
|
||||
onRefresh,
|
||||
}: {
|
||||
diff: string;
|
||||
onRefresh: () => Promise<void>;
|
||||
}) => (
|
||||
<div className='flex h-full min-h-[520px] flex-col'>
|
||||
<div className='border-border flex h-11 items-center justify-between border-b px-3'>
|
||||
<div>
|
||||
<p className='text-sm font-medium'>Workspace diff</p>
|
||||
<p className='text-muted-foreground text-xs'>Current git diff</p>
|
||||
</div>
|
||||
<Button type='button' variant='outline' size='sm' onClick={onRefresh}>
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
{diff.trim() ? (
|
||||
<MonacoEditor
|
||||
height='520px'
|
||||
language='diff'
|
||||
theme='vs-dark'
|
||||
value={diff}
|
||||
options={{
|
||||
readOnly: true,
|
||||
minimap: { enabled: false },
|
||||
fontSize: 13,
|
||||
scrollBeyondLastLine: false,
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className='text-muted-foreground flex flex-1 items-center justify-center text-sm'>
|
||||
No workspace diff yet.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@@ -0,0 +1,83 @@
|
||||
'use client';
|
||||
|
||||
import { ChevronRight, FileCode, Folder } from 'lucide-react';
|
||||
|
||||
import { Button } from '@spoon/ui';
|
||||
|
||||
import type { FileTreeNode } from './types';
|
||||
|
||||
const TreeNode = ({
|
||||
node,
|
||||
selectedPath,
|
||||
onSelect,
|
||||
depth = 0,
|
||||
}: {
|
||||
node: FileTreeNode;
|
||||
selectedPath?: string;
|
||||
onSelect: (path: string) => void;
|
||||
depth?: number;
|
||||
}) => {
|
||||
if (node.type === 'directory') {
|
||||
return (
|
||||
<div>
|
||||
{node.path ? (
|
||||
<div
|
||||
className='text-muted-foreground flex h-7 items-center gap-1 px-2 text-xs font-medium'
|
||||
style={{ paddingLeft: depth * 12 + 8 }}
|
||||
>
|
||||
<ChevronRight className='size-3' />
|
||||
<Folder className='size-3' />
|
||||
<span className='truncate'>{node.name}</span>
|
||||
</div>
|
||||
) : null}
|
||||
<div>
|
||||
{node.children?.map((child) => (
|
||||
<TreeNode
|
||||
key={`${child.type}:${child.path}`}
|
||||
node={child}
|
||||
selectedPath={selectedPath}
|
||||
onSelect={onSelect}
|
||||
depth={node.path ? depth + 1 : depth}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
type='button'
|
||||
variant={selectedPath === node.path ? 'secondary' : 'ghost'}
|
||||
className='h-7 w-full justify-start gap-2 rounded-none px-2 text-left text-xs font-normal'
|
||||
style={{ paddingLeft: depth * 12 + 8 }}
|
||||
onClick={() => onSelect(node.path)}
|
||||
>
|
||||
<FileCode className='size-3 flex-none' />
|
||||
<span className='truncate'>{node.name}</span>
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
export const FileTree = ({
|
||||
tree,
|
||||
selectedPath,
|
||||
onSelect,
|
||||
}: {
|
||||
tree: FileTreeNode | null;
|
||||
selectedPath?: string;
|
||||
onSelect: (path: string) => void;
|
||||
}) => {
|
||||
if (!tree) {
|
||||
return (
|
||||
<p className='text-muted-foreground p-3 text-sm'>
|
||||
Workspace files are not available yet.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className='overflow-auto py-2'>
|
||||
<TreeNode node={tree} selectedPath={selectedPath} onSelect={onSelect} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,24 @@
|
||||
'use client';
|
||||
|
||||
import type { Doc } from '@spoon/backend/convex/_generated/dataModel.js';
|
||||
import { Badge } from '@spoon/ui';
|
||||
|
||||
export const JobStatusBar = ({ job }: { job: Doc<'agentJobs'> }) => (
|
||||
<div className='border-border bg-background flex flex-wrap items-center justify-between gap-3 border-b px-4 py-3'>
|
||||
<div className='min-w-0'>
|
||||
<h1 className='truncate text-base font-semibold'>{job.forkRepo}</h1>
|
||||
<p className='text-muted-foreground truncate font-mono text-xs'>
|
||||
{job.baseBranch} {'->'} {job.workBranch}
|
||||
</p>
|
||||
</div>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Badge variant='outline' className='capitalize'>
|
||||
{job.status.replaceAll('_', ' ')}
|
||||
</Badge>
|
||||
<Badge variant='secondary' className='capitalize'>
|
||||
{(job.workspaceStatus ?? 'not_started').replaceAll('_', ' ')}
|
||||
</Badge>
|
||||
<Badge variant='outline'>{job.runtime ?? 'opencode'}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -0,0 +1,15 @@
|
||||
export type FileTreeNode = {
|
||||
name: string;
|
||||
path: string;
|
||||
type: 'file' | 'directory';
|
||||
children?: FileTreeNode[];
|
||||
};
|
||||
|
||||
export type FileResponse = {
|
||||
path: string;
|
||||
content: string;
|
||||
};
|
||||
|
||||
export type DiffResponse = {
|
||||
diff: string;
|
||||
};
|
||||
@@ -0,0 +1,68 @@
|
||||
'use client';
|
||||
|
||||
import { ExternalLink, GitPullRequestDraft, Square } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import type { Doc } from '@spoon/backend/convex/_generated/dataModel.js';
|
||||
import { Button } from '@spoon/ui';
|
||||
|
||||
export const WorkspaceActions = ({
|
||||
job,
|
||||
disabled,
|
||||
}: {
|
||||
job: Doc<'agentJobs'>;
|
||||
disabled: boolean;
|
||||
}) => {
|
||||
const openPr = async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/agent-jobs/${job._id}/open-pr`, {
|
||||
method: 'POST',
|
||||
});
|
||||
if (!response.ok) throw new Error(await response.text());
|
||||
toast.success('Draft PR opened.');
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error('Could not open draft PR.');
|
||||
}
|
||||
};
|
||||
|
||||
const stop = async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/agent-jobs/${job._id}/stop`, {
|
||||
method: 'POST',
|
||||
});
|
||||
if (!response.ok) throw new Error(await response.text());
|
||||
toast.success('Workspace stopped.');
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error('Could not stop workspace.');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='flex flex-wrap items-center gap-2'>
|
||||
{job.pullRequestUrl ? (
|
||||
<Button asChild variant='outline' size='sm'>
|
||||
<a href={job.pullRequestUrl} target='_blank' rel='noreferrer'>
|
||||
<ExternalLink className='size-4' />
|
||||
Open PR
|
||||
</a>
|
||||
</Button>
|
||||
) : null}
|
||||
<Button type='button' size='sm' disabled={disabled} onClick={openPr}>
|
||||
<GitPullRequestDraft className='size-4' />
|
||||
Open draft PR
|
||||
</Button>
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
size='sm'
|
||||
disabled={disabled}
|
||||
onClick={stop}
|
||||
>
|
||||
<Square className='size-4' />
|
||||
Stop
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,8 +1,9 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useMutation } from 'convex/react';
|
||||
import { ExternalLink, XCircle } from 'lucide-react';
|
||||
import { ExternalLink, MonitorUp, XCircle } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import type { Doc } from '@spoon/backend/convex/_generated/dataModel.js';
|
||||
@@ -101,6 +102,14 @@ export const AgentJobList = ({ jobs }: { jobs: Doc<'agentJobs'>[] }) => {
|
||||
Cancel job
|
||||
</Button>
|
||||
) : null}
|
||||
<Button asChild>
|
||||
<Link
|
||||
href={`/spoons/${selectedJob.spoonId}/agent/${selectedJob._id}`}
|
||||
>
|
||||
<MonitorUp className='size-4' />
|
||||
Open workspace
|
||||
</Link>
|
||||
</Button>
|
||||
<AgentJobDetail job={selectedJob} />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -15,15 +15,24 @@ import {
|
||||
CardTitle,
|
||||
Input,
|
||||
Label,
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
Switch,
|
||||
Textarea,
|
||||
} from '@spoon/ui';
|
||||
|
||||
import { SecretSelector } from './secret-selector';
|
||||
|
||||
type AgentSettings = {
|
||||
defaultBaseBranch?: string;
|
||||
runtime?: 'opencode' | 'openai_direct';
|
||||
agentModel: string;
|
||||
reasoningEffort: string;
|
||||
envFilePath?: string;
|
||||
customEnvFilePath?: string;
|
||||
materializeEnvFileByDefault?: boolean;
|
||||
aiProviderProfileId?: Id<'aiProviderProfiles'>;
|
||||
};
|
||||
|
||||
export const AgentRequestForm = ({
|
||||
@@ -33,11 +42,16 @@ export const AgentRequestForm = ({
|
||||
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 secrets =
|
||||
useQuery(api.spoonSecrets.listForSpoon, {
|
||||
spoonId: spoon._id,
|
||||
}) ?? [];
|
||||
const profiles =
|
||||
useQuery(api.aiProviderProfiles.listMine, {})?.filter(
|
||||
(profile) => profile.enabled && profile.configured,
|
||||
) ?? [];
|
||||
const defaultProfile = profiles.find((profile) => profile.isDefault);
|
||||
const createThread = useMutation(api.threads.createUserThread);
|
||||
const [prompt, setPrompt] = useState('');
|
||||
const [baseBranch, setBaseBranch] = useState(
|
||||
agentSettings?.defaultBaseBranch ??
|
||||
@@ -45,30 +59,52 @@ export const AgentRequestForm = ({
|
||||
spoon.upstreamDefaultBranch,
|
||||
);
|
||||
const [requestedBranchName, setRequestedBranchName] = useState('');
|
||||
const [selectedSecretIds, setSelectedSecretIds] = useState<
|
||||
Id<'spoonSecrets'>[]
|
||||
>([]);
|
||||
const [materializeEnvFile, setMaterializeEnvFile] = useState(
|
||||
agentSettings?.materializeEnvFileByDefault ?? false,
|
||||
);
|
||||
const [envFilePath, setEnvFilePath] = useState(
|
||||
agentSettings?.envFilePath === 'custom'
|
||||
? (agentSettings.customEnvFilePath ?? '.env.local')
|
||||
: (agentSettings?.envFilePath ?? '.env.local'),
|
||||
);
|
||||
const [aiProviderProfileId, setAiProviderProfileId] = useState(
|
||||
agentSettings?.aiProviderProfileId ?? '__settings',
|
||||
);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const effectiveProviderProfileId =
|
||||
aiProviderProfileId === '__settings'
|
||||
? (agentSettings?.aiProviderProfileId ?? defaultProfile?._id)
|
||||
: aiProviderProfileId;
|
||||
const hasProvider = Boolean(
|
||||
effectiveProviderProfileId &&
|
||||
profiles.some((profile) => profile._id === effectiveProviderProfileId),
|
||||
);
|
||||
const selectedProfile = profiles.find((profile) =>
|
||||
aiProviderProfileId === '__settings'
|
||||
? profile._id ===
|
||||
(agentSettings?.aiProviderProfileId ?? defaultProfile?._id)
|
||||
: profile._id === aiProviderProfileId,
|
||||
);
|
||||
|
||||
const submit = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const requestId = await createRequest({
|
||||
await createThread({
|
||||
spoonId: spoon._id,
|
||||
prompt,
|
||||
targetBranch: baseBranch,
|
||||
});
|
||||
await createJob({
|
||||
requestId,
|
||||
selectedSecretIds,
|
||||
baseBranch,
|
||||
requestedBranchName: requestedBranchName || undefined,
|
||||
materializeEnvFile,
|
||||
envFilePath: materializeEnvFile ? envFilePath : undefined,
|
||||
aiProviderProfileId:
|
||||
aiProviderProfileId === '__settings'
|
||||
? undefined
|
||||
: (aiProviderProfileId as Id<'aiProviderProfiles'>),
|
||||
});
|
||||
setPrompt('');
|
||||
setRequestedBranchName('');
|
||||
setSelectedSecretIds([]);
|
||||
toast.success('Agent job queued.');
|
||||
toast.success('Thread created.');
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error('Could not queue agent job.');
|
||||
@@ -99,6 +135,32 @@ export const AgentRequestForm = ({
|
||||
/>
|
||||
</div>
|
||||
<div className='grid gap-3 md:grid-cols-2'>
|
||||
<div className='grid gap-2'>
|
||||
<Label>Workspace runtime</Label>
|
||||
<Input value='OpenCode workspace' disabled />
|
||||
</div>
|
||||
<div className='grid gap-2'>
|
||||
<Label>AI provider</Label>
|
||||
<Select
|
||||
value={aiProviderProfileId}
|
||||
onValueChange={setAiProviderProfileId}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value='__settings'>
|
||||
Use default
|
||||
{defaultProfile ? ` (${defaultProfile.name})` : ''}
|
||||
</SelectItem>
|
||||
{profiles.map((profile) => (
|
||||
<SelectItem key={profile._id} value={profile._id}>
|
||||
{profile.name} · {profile.provider.replaceAll('_', ' ')}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className='grid gap-2'>
|
||||
<Label htmlFor='baseBranch'>Base branch</Label>
|
||||
<Input
|
||||
@@ -117,26 +179,45 @@ export const AgentRequestForm = ({
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className='grid gap-2'>
|
||||
<Label>Secrets exposed to this job</Label>
|
||||
<SecretSelector
|
||||
secrets={secrets ?? []}
|
||||
selectedSecretIds={selectedSecretIds}
|
||||
onChange={setSelectedSecretIds}
|
||||
/>
|
||||
<div className='grid gap-3 md:grid-cols-[1fr_1fr]'>
|
||||
<div className='flex items-center justify-between gap-4 rounded-md border p-3'>
|
||||
<div>
|
||||
<Label>Write Spoon secrets to env file</Label>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
All {secrets.length} Spoon secret(s) are available as process
|
||||
env. When enabled, Spoon also writes them to this file and
|
||||
refuses to commit .env files.
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={materializeEnvFile}
|
||||
onCheckedChange={setMaterializeEnvFile}
|
||||
/>
|
||||
</div>
|
||||
<div className='grid gap-2'>
|
||||
<Label htmlFor='envFilePath'>Env file path</Label>
|
||||
<Input
|
||||
id='envFilePath'
|
||||
value={envFilePath}
|
||||
disabled={!materializeEnvFile}
|
||||
onChange={(event) => setEnvFilePath(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</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>
|
||||
<strong>
|
||||
{selectedProfile?.defaultModel ?? 'Configure an AI provider'}
|
||||
</strong>
|
||||
</span>
|
||||
<span>
|
||||
Reasoning:{' '}
|
||||
<strong>{agentSettings?.reasoningEffort ?? 'high'}</strong>
|
||||
<strong>{selectedProfile?.reasoningEffort ?? 'medium'}</strong>
|
||||
</span>
|
||||
</div>
|
||||
<Button type='submit' disabled={submitting}>
|
||||
{submitting ? 'Queueing...' : 'Queue agent job'}
|
||||
<Button type='submit' disabled={submitting || !hasProvider}>
|
||||
{submitting ? 'Creating...' : 'Create thread'}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
'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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,451 @@
|
||||
'use client';
|
||||
|
||||
import type { ProviderModelOption } from '@/lib/models-dev';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { loadModelsDevOptions } from '@/lib/models-dev';
|
||||
import { useAction, useMutation, useQuery } from 'convex/react';
|
||||
import { makeFunctionReference } from 'convex/server';
|
||||
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,
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
Switch,
|
||||
Textarea,
|
||||
} from '@spoon/ui';
|
||||
|
||||
type Provider =
|
||||
| 'openai'
|
||||
| 'anthropic'
|
||||
| 'google'
|
||||
| 'openrouter'
|
||||
| 'requesty'
|
||||
| 'litellm'
|
||||
| 'cloudflare_ai_gateway'
|
||||
| 'custom_openai_compatible'
|
||||
| 'opencode_openai_login';
|
||||
type AuthType = 'api_key' | 'opencode_auth_json' | 'none';
|
||||
type ReasoningEffort = 'none' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh';
|
||||
|
||||
const saveProfileRef = makeFunctionReference<
|
||||
'action',
|
||||
{
|
||||
profileId?: Id<'aiProviderProfiles'>;
|
||||
name: string;
|
||||
provider: Provider;
|
||||
authType: AuthType;
|
||||
secret?: string;
|
||||
baseUrl?: string;
|
||||
defaultModel: string;
|
||||
reasoningEffort: ReasoningEffort;
|
||||
enabled: boolean;
|
||||
},
|
||||
Id<'aiProviderProfiles'>
|
||||
>('aiProviderProfilesNode:save');
|
||||
|
||||
const setDefaultProfileRef = makeFunctionReference<
|
||||
'mutation',
|
||||
{ profileId: Id<'aiProviderProfiles'> },
|
||||
{ success: true }
|
||||
>('aiProviderProfiles:setDefault');
|
||||
|
||||
const providerOptions: {
|
||||
value: Provider;
|
||||
label: string;
|
||||
authType: AuthType;
|
||||
}[] = [
|
||||
{ value: 'openai', label: 'OpenAI API key', authType: 'api_key' },
|
||||
{ value: 'anthropic', label: 'Anthropic API key', authType: 'api_key' },
|
||||
{ value: 'google', label: 'Google Gemini API key', authType: 'api_key' },
|
||||
{ value: 'openrouter', label: 'OpenRouter', authType: 'api_key' },
|
||||
{ value: 'requesty', label: 'Requesty', authType: 'api_key' },
|
||||
{ value: 'litellm', label: 'LiteLLM', authType: 'api_key' },
|
||||
{
|
||||
value: 'cloudflare_ai_gateway',
|
||||
label: 'Cloudflare AI Gateway',
|
||||
authType: 'api_key',
|
||||
},
|
||||
{
|
||||
value: 'custom_openai_compatible',
|
||||
label: 'Custom OpenAI-compatible',
|
||||
authType: 'api_key',
|
||||
},
|
||||
{
|
||||
value: 'opencode_openai_login',
|
||||
label: 'OpenCode OpenAI login',
|
||||
authType: 'opencode_auth_json',
|
||||
},
|
||||
];
|
||||
|
||||
const reasoningOptions: ReasoningEffort[] = [
|
||||
'none',
|
||||
'minimal',
|
||||
'low',
|
||||
'medium',
|
||||
'high',
|
||||
'xhigh',
|
||||
];
|
||||
|
||||
export const AiProviderProfilesPanel = () => {
|
||||
const profiles = useQuery(api.aiProviderProfiles.listMine, {}) ?? [];
|
||||
const saveProfile = useAction(saveProfileRef);
|
||||
const setDefaultProfile = useMutation(setDefaultProfileRef);
|
||||
const removeProfile = useMutation(api.aiProviderProfiles.remove);
|
||||
const [profileId, setProfileId] = useState<Id<'aiProviderProfiles'>>();
|
||||
const [name, setName] = useState('OpenAI');
|
||||
const [provider, setProvider] = useState<Provider>('openai');
|
||||
const selectedProvider = useMemo(
|
||||
() =>
|
||||
providerOptions.find((option) => option.value === provider) ??
|
||||
({
|
||||
value: 'openai',
|
||||
label: 'OpenAI API key',
|
||||
authType: 'api_key',
|
||||
} satisfies (typeof providerOptions)[number]),
|
||||
[provider],
|
||||
);
|
||||
const [secret, setSecret] = useState('');
|
||||
const [baseUrl, setBaseUrl] = useState('');
|
||||
const [defaultModelValue, setDefaultModelValue] = useState('');
|
||||
const [modelOptions, setModelOptions] = useState<ProviderModelOption[]>([]);
|
||||
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 reset = () => {
|
||||
setProfileId(undefined);
|
||||
setProvider('openai');
|
||||
setSecret('');
|
||||
setBaseUrl('');
|
||||
setDefaultModelValue('');
|
||||
setReasoningEffort('medium');
|
||||
setEnabled(true);
|
||||
setName('OpenAI');
|
||||
};
|
||||
|
||||
const edit = (profile: (typeof profiles)[number]) => {
|
||||
setProfileId(profile._id);
|
||||
setName(profile.name);
|
||||
setProvider(profile.provider as Provider);
|
||||
setSecret('');
|
||||
setBaseUrl(profile.baseUrl ?? '');
|
||||
setDefaultModelValue(profile.defaultModel);
|
||||
setReasoningEffort(profile.reasoningEffort as ReasoningEffort);
|
||||
setEnabled(profile.enabled);
|
||||
};
|
||||
|
||||
const save = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
setSaving(true);
|
||||
try {
|
||||
await saveProfile({
|
||||
profileId,
|
||||
name,
|
||||
provider,
|
||||
authType: selectedProvider.authType,
|
||||
secret: secret.trim() ? secret : undefined,
|
||||
baseUrl: baseUrl.trim() || undefined,
|
||||
defaultModel: defaultModelValue,
|
||||
reasoningEffort,
|
||||
enabled,
|
||||
});
|
||||
toast.success('AI provider saved.');
|
||||
reset();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error('Could not save AI provider.');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const selectedProfile = profileId
|
||||
? profiles.find((profile) => profile._id === profileId)
|
||||
: undefined;
|
||||
const hasCredential =
|
||||
selectedProvider.authType === 'none' ||
|
||||
Boolean(secret.trim()) ||
|
||||
Boolean(selectedProfile?.configured);
|
||||
const canSelectModel = hasCredential && modelOptions.length > 0;
|
||||
const configuredProfiles = profiles.filter(
|
||||
(profile) => profile.enabled && profile.configured,
|
||||
);
|
||||
const defaultProfile = configuredProfiles.find(
|
||||
(profile) => profile.isDefault,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className='grid gap-4 xl:grid-cols-[1fr_0.9fr]'>
|
||||
<Card className='shadow-none'>
|
||||
<CardHeader>
|
||||
<CardTitle className='flex items-center gap-2 text-base'>
|
||||
<KeyRound className='size-4' />
|
||||
Provider profiles
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className='space-y-3'>
|
||||
{configuredProfiles.length > 1 ? (
|
||||
<div className='grid gap-2 rounded-md border p-3'>
|
||||
<Label>Default provider</Label>
|
||||
<Select
|
||||
value={defaultProfile?._id ?? ''}
|
||||
onValueChange={async (value) => {
|
||||
await setDefaultProfile({
|
||||
profileId: value as Id<'aiProviderProfiles'>,
|
||||
});
|
||||
toast.success('Default AI provider updated.');
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder='Choose default provider' />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{configuredProfiles.map((profile) => (
|
||||
<SelectItem key={profile._id} value={profile._id}>
|
||||
{profile.name} · {profile.defaultModel}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
Spoons using account default will use this provider.
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
{profiles.length ? (
|
||||
profiles.map((profile) => (
|
||||
<div
|
||||
key={profile._id}
|
||||
className='border-border flex items-center justify-between gap-3 rounded-md border p-3'
|
||||
>
|
||||
<button
|
||||
type='button'
|
||||
className='min-w-0 text-left'
|
||||
onClick={() => edit(profile)}
|
||||
>
|
||||
<p className='truncate text-sm font-medium'>{profile.name}</p>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
{profile.provider.replaceAll('_', ' ')} ·{' '}
|
||||
{profile.secretPreview ?? 'not configured'} ·{' '}
|
||||
{profile.defaultModel}
|
||||
{profile.isDefault ? ' · default' : ''}
|
||||
</p>
|
||||
</button>
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
aria-label='Remove provider'
|
||||
onClick={async () => {
|
||||
await removeProfile({ profileId: profile._id });
|
||||
toast.success('AI provider removed.');
|
||||
}}
|
||||
>
|
||||
<Trash2 className='size-4' />
|
||||
</Button>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<p className='text-muted-foreground text-sm'>
|
||||
Add API-key providers for OpenCode, or store an OpenCode OpenAI
|
||||
login profile for the next auth-file injection pass.
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className='shadow-none'>
|
||||
<CardHeader>
|
||||
<CardTitle className='text-base'>
|
||||
{profileId ? 'Edit provider' : 'Add provider'}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={save} className='space-y-4'>
|
||||
<div className='grid gap-2'>
|
||||
<Label>Name</Label>
|
||||
<Input
|
||||
value={name}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className='grid gap-2'>
|
||||
<Label>Provider</Label>
|
||||
<Select
|
||||
value={provider}
|
||||
onValueChange={(value) => {
|
||||
const nextProvider = value as Provider;
|
||||
setProvider(nextProvider);
|
||||
setName(
|
||||
providerOptions
|
||||
.find((option) => option.value === nextProvider)
|
||||
?.label.replace(' API key', '') ?? 'AI provider',
|
||||
);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{providerOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className='grid gap-2'>
|
||||
<Label>
|
||||
{selectedProvider.authType === 'opencode_auth_json'
|
||||
? 'OpenCode auth JSON'
|
||||
: 'API key'}
|
||||
</Label>
|
||||
{selectedProvider.authType === 'opencode_auth_json' ? (
|
||||
<>
|
||||
<Textarea
|
||||
value={secret}
|
||||
placeholder='Paste the full auth.json contents.'
|
||||
onChange={(event) => setSecret(event.target.value)}
|
||||
/>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
Copy your Codex auth file from{' '}
|
||||
<code className='bg-muted rounded px-1 py-0.5'>
|
||||
~/.codex/auth.json
|
||||
</code>
|
||||
. It is stored encrypted and should be treated like a
|
||||
password.
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<Input
|
||||
type='password'
|
||||
value={secret}
|
||||
placeholder={
|
||||
profileId ? 'Leave blank to keep current secret' : 'sk-...'
|
||||
}
|
||||
onChange={(event) => setSecret(event.target.value)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className='grid gap-2'>
|
||||
<Label>Base URL</Label>
|
||||
<Input
|
||||
value={baseUrl}
|
||||
placeholder='Optional for LiteLLM, Requesty, custom providers'
|
||||
onChange={(event) => setBaseUrl(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className='grid gap-2 md:grid-cols-2'>
|
||||
<div className='grid gap-2'>
|
||||
<Label>Default model</Label>
|
||||
<Select
|
||||
value={defaultModelValue}
|
||||
onValueChange={setDefaultModelValue}
|
||||
disabled={!canSelectModel}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue
|
||||
placeholder={
|
||||
hasCredential
|
||||
? 'Choose a model'
|
||||
: 'Add credentials first'
|
||||
}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{modelOptions.map((model) => (
|
||||
<SelectItem key={model.id} value={model.id}>
|
||||
{model.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
Models are loaded from Models.dev, the catalog OpenCode uses
|
||||
for provider/model metadata.
|
||||
</p>
|
||||
</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={option}>
|
||||
{option}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex items-center justify-between gap-4 rounded-md border p-3'>
|
||||
<div>
|
||||
<Label>Enabled</Label>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
Disabled profiles cannot be selected for new jobs.
|
||||
</p>
|
||||
</div>
|
||||
<Switch checked={enabled} onCheckedChange={setEnabled} />
|
||||
</div>
|
||||
<div className='flex flex-wrap gap-2'>
|
||||
<Button
|
||||
type='submit'
|
||||
disabled={saving || !hasCredential || !defaultModelValue}
|
||||
>
|
||||
{saving ? 'Saving...' : 'Save provider'}
|
||||
</Button>
|
||||
{profileId ? (
|
||||
<Button type='button' variant='outline' onClick={reset}>
|
||||
New provider
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,197 +0,0 @@
|
||||
'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>
|
||||
);
|
||||
};
|
||||
@@ -15,16 +15,17 @@ export const CTA = () => {
|
||||
<div className='flex flex-col items-start justify-between gap-8 md:flex-row md:items-center'>
|
||||
<div>
|
||||
<h2 className='text-2xl font-semibold tracking-normal md:text-3xl'>
|
||||
Keep the fork. Lose the maintenance dread.
|
||||
Fork the project. Keep the relationship.
|
||||
</h2>
|
||||
<p className='text-primary-foreground/80 mt-3 max-w-2xl leading-7'>
|
||||
Create your first Spoon, connect GitHub, and make upstream drift
|
||||
something you can see, review, and act on.
|
||||
Create your first Spoon, connect GitHub, and let upstream
|
||||
maintenance become a visible thread instead of a lonely recurring
|
||||
chore.
|
||||
</p>
|
||||
</div>
|
||||
<Button variant='secondary' size='lg' asChild>
|
||||
<Link href={isAuthenticated ? '/spoons/new' : '/sign-in'}>
|
||||
{isAuthenticated ? 'New Spoon' : 'Start with Spoon'}
|
||||
{isAuthenticated ? 'Create a Spoon' : 'Start with Spoon'}
|
||||
<ArrowRight className='size-4' />
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
@@ -4,107 +4,135 @@ import {
|
||||
GitBranch,
|
||||
GitCompare,
|
||||
GitPullRequest,
|
||||
History,
|
||||
KeyRound,
|
||||
LockKeyhole,
|
||||
MessagesSquare,
|
||||
RefreshCw,
|
||||
ServerCog,
|
||||
Sparkles,
|
||||
ShieldCheck,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { Badge } from '@spoon/ui';
|
||||
|
||||
const workflow = [
|
||||
{
|
||||
title: 'Connect GitHub',
|
||||
title: 'Create the Spoon',
|
||||
description:
|
||||
'Install the Spoon GitHub App so Spoon can read forks, compare branches, push agent branches, and open draft pull requests.',
|
||||
'Register the upstream project, your GitHub fork, default branches, clone URLs, and any extra remotes you want visible.',
|
||||
},
|
||||
{
|
||||
title: 'Create a Spoon',
|
||||
title: 'Watch upstream',
|
||||
description:
|
||||
'Register a managed fork with its upstream project, fork repository, default branches, sync cadence, and additional remote URLs.',
|
||||
'Spoon intermittently checks the upstream default branch and compares it against the current fork state.',
|
||||
},
|
||||
{
|
||||
title: 'Watch drift',
|
||||
title: 'Auto-sync clean drift',
|
||||
description:
|
||||
'Refresh GitHub state to see upstream commits waiting, fork-only commits, open pull requests, sync health, and merge history.',
|
||||
'If the fork has no custom commits and upstream moved, Spoon can fast-forward the fork without turning it into a chore.',
|
||||
},
|
||||
{
|
||||
title: 'Review safely',
|
||||
title: 'Open a thread when it matters',
|
||||
description:
|
||||
'Use AI compatibility reviews to summarize upstream changes, flag important files, and decide when a manual review is needed.',
|
||||
'If your fork has custom commits, Spoon creates a durable maintenance thread instead of guessing.',
|
||||
},
|
||||
{
|
||||
title: 'Ship through PRs',
|
||||
title: 'Resolve with context',
|
||||
description:
|
||||
'Queue agent jobs that work on fresh branches and open draft pull requests, while GitHub remains the source of truth.',
|
||||
'Review commits, changed files, pull requests, fork-only work, ignored upstream changes, and workspace output together.',
|
||||
},
|
||||
{
|
||||
title: 'Ship through draft PRs',
|
||||
description:
|
||||
'When code is needed, OpenCode works in an isolated workspace and hands changes back as a draft PR.',
|
||||
},
|
||||
];
|
||||
|
||||
const features = [
|
||||
{
|
||||
title: 'Project dashboards',
|
||||
title: 'Spoon dashboards',
|
||||
description:
|
||||
'Each Spoon gets a focused dashboard with upstream drift, fork-only changes, pull requests, AI reviews, activity, settings, clone URLs, and extra remotes.',
|
||||
'See drift, fork-only commits, pull requests, clone URLs, extra remotes, threads, activity, and settings for each managed fork.',
|
||||
icon: GitCompare,
|
||||
},
|
||||
{
|
||||
title: 'Upstream maintenance queue',
|
||||
title: 'Thread-first maintenance',
|
||||
description:
|
||||
'The global dashboard makes it obvious which forks are up to date, behind, ahead, diverged, or waiting for review.',
|
||||
'Every review, conflict, ignore decision, and requested code change has a durable conversation attached to it.',
|
||||
icon: MessagesSquare,
|
||||
},
|
||||
{
|
||||
title: 'Effective drift',
|
||||
description:
|
||||
'Spoon shows raw upstream state and the effective maintenance state after intentional ignore decisions.',
|
||||
icon: RefreshCw,
|
||||
},
|
||||
{
|
||||
title: 'Pull request visibility',
|
||||
title: 'OpenCode workspaces',
|
||||
description:
|
||||
'Spoon caches fork pull requests and relevant upstream pull requests so maintenance decisions are tied to real GitHub activity.',
|
||||
icon: GitPullRequest,
|
||||
},
|
||||
{
|
||||
title: 'AI compatibility review',
|
||||
description:
|
||||
'OpenAI reviews upstream changes against fork-only commits and returns structured risk, summary, recommended action, and conflict signals.',
|
||||
icon: Sparkles,
|
||||
},
|
||||
{
|
||||
title: 'Per-user AI settings',
|
||||
description:
|
||||
'Users bring their own OpenAI API key, choose a review model, and set reasoning effort. Keys are encrypted before storage.',
|
||||
icon: KeyRound,
|
||||
},
|
||||
{
|
||||
title: 'Agent job foundation',
|
||||
description:
|
||||
'Spoon can queue coding-agent work per project with selected secrets, job settings, event logs, artifacts, and draft PR targets.',
|
||||
icon: Bot,
|
||||
},
|
||||
];
|
||||
|
||||
const builtFor = [
|
||||
{
|
||||
title: 'Self-hosted by design',
|
||||
description:
|
||||
'Run the app, Convex backend, Postgres, and optional agent worker on your own server so code automation stays under your control.',
|
||||
icon: ServerCog,
|
||||
},
|
||||
{
|
||||
title: 'Secrets stay deliberate',
|
||||
description:
|
||||
'Project secrets are per Spoon and selected per job. The agent runtime never receives every secret by default.',
|
||||
icon: LockKeyhole,
|
||||
},
|
||||
{
|
||||
title: 'Outside work is expected',
|
||||
description:
|
||||
'Spoon assumes you may push to GitHub directly, edit locally, or use another CI system. Refresh always starts from current GitHub state.',
|
||||
'Open a file tree, browser editor, diff view, job logs, command panel, and thread context when a fork needs code.',
|
||||
icon: Code2,
|
||||
},
|
||||
{
|
||||
title: 'History stays inspectable',
|
||||
title: 'Provider-owned AI',
|
||||
description:
|
||||
'Sync runs, AI reviews, job logs, PR URLs, errors, and artifacts are stored so maintenance work is reviewable after the fact.',
|
||||
icon: History,
|
||||
'Use encrypted provider profiles, OpenCode auth, or user-owned API keys rather than a shared application key.',
|
||||
icon: KeyRound,
|
||||
},
|
||||
{
|
||||
title: 'Draft PR handoff',
|
||||
description:
|
||||
'Agent work becomes a branch and draft pull request. Spoon does not auto-merge custom forks behind your back.',
|
||||
icon: GitPullRequest,
|
||||
},
|
||||
];
|
||||
|
||||
const decisions = [
|
||||
{
|
||||
condition: 'No fork-only commits + upstream ahead',
|
||||
action: 'Auto-sync',
|
||||
explanation: 'The fork is still close enough to fast-forward.',
|
||||
},
|
||||
{
|
||||
condition: 'Fork-only commits + upstream ahead',
|
||||
action: 'Create thread',
|
||||
explanation: 'Spoon reviews whether upstream affects custom work.',
|
||||
},
|
||||
{
|
||||
condition: 'Merge conflicts',
|
||||
action: 'Open workspace',
|
||||
explanation: 'Resolve in an isolated worker and ship a draft PR.',
|
||||
},
|
||||
{
|
||||
condition: 'Irrelevant upstream changes',
|
||||
action: 'Ignore intentionally',
|
||||
explanation: 'Record why those commits no longer matter to this fork.',
|
||||
},
|
||||
];
|
||||
|
||||
const ownership = [
|
||||
{
|
||||
title: 'Your GitHub App',
|
||||
description:
|
||||
'GitHub remains the active source of truth for forks, branches, compares, and draft PRs.',
|
||||
icon: GitBranch,
|
||||
},
|
||||
{
|
||||
title: 'Your providers',
|
||||
description:
|
||||
'AI provider profiles and Codex/OpenCode auth stay encrypted and selected by you.',
|
||||
icon: ShieldCheck,
|
||||
},
|
||||
{
|
||||
title: 'Your secrets',
|
||||
description:
|
||||
'Project secrets are per Spoon, redacted in logs, and refused from commits when materialized.',
|
||||
icon: LockKeyhole,
|
||||
},
|
||||
{
|
||||
title: 'Your workflow',
|
||||
description:
|
||||
'Local commits, Gitea mirrors, CI changes, and direct GitHub edits are expected parts of the loop.',
|
||||
icon: ServerCog,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -116,44 +144,42 @@ export const Workflow = () => (
|
||||
Workflow
|
||||
</Badge>
|
||||
<h2 className='text-3xl font-semibold tracking-normal md:text-4xl'>
|
||||
Forking should not mean drifting alone.
|
||||
Forking should start a relationship, not a support burden.
|
||||
</h2>
|
||||
<p className='text-muted-foreground mt-4 text-lg leading-8'>
|
||||
Spoon treats a fork as an ongoing relationship with upstream. The
|
||||
product keeps the original project, your custom work, and future
|
||||
automation visible in one place.
|
||||
Spoon keeps watching the upstream project after the fork. When
|
||||
upstream moves, it decides whether your fork can fast-forward, needs a
|
||||
maintenance thread, or should ignore changes that no longer matter to
|
||||
your version. Forking a project should not mean supporting it alone.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className='grid gap-8 lg:grid-cols-[0.8fr_1.2fr]'>
|
||||
<div className='grid gap-8 lg:grid-cols-[0.75fr_1.25fr]'>
|
||||
<div className='border-border bg-background rounded-lg border p-6'>
|
||||
<GitBranch className='text-primary size-6' />
|
||||
<MessagesSquare className='text-primary size-6' />
|
||||
<h3 className='mt-5 text-xl font-semibold'>
|
||||
A Spoon is a managed fork
|
||||
Spoon keeps the conversation going
|
||||
</h3>
|
||||
<p className='text-muted-foreground mt-3 leading-7'>
|
||||
It knows where upstream lives, where your fork lives, which branch
|
||||
matters, what extra remotes you care about, and what rules should
|
||||
govern updates. That gives maintenance a durable home instead of a
|
||||
pile of one-off Git commands.
|
||||
A fork is not a one-time split. Spoon keeps the fork and upstream in
|
||||
conversation by turning maintenance into visible, reviewable threads
|
||||
instead of surprise drift.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ol className='grid gap-3'>
|
||||
<ol className='grid gap-3 md:grid-cols-2'>
|
||||
{workflow.map((step, index) => (
|
||||
<li
|
||||
key={step.title}
|
||||
className='border-border bg-background grid gap-4 rounded-lg border p-5 sm:grid-cols-[4rem_1fr]'
|
||||
className='border-border bg-background rounded-lg border p-5'
|
||||
>
|
||||
<span className='text-primary text-sm font-semibold'>
|
||||
{String(index + 1).padStart(2, '0')}
|
||||
</span>
|
||||
<div>
|
||||
<h3 className='font-semibold'>{step.title}</h3>
|
||||
<p className='text-muted-foreground mt-1 text-sm leading-6'>
|
||||
{step.description}
|
||||
</p>
|
||||
</div>
|
||||
<h3 className='mt-3 font-semibold'>{step.title}</h3>
|
||||
<p className='text-muted-foreground mt-2 text-sm leading-6'>
|
||||
{step.description}
|
||||
</p>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
@@ -170,12 +196,12 @@ export const Features = () => (
|
||||
Product surface
|
||||
</Badge>
|
||||
<h2 className='text-3xl font-semibold tracking-normal md:text-4xl'>
|
||||
Everything important about a fork, without opening six tabs.
|
||||
The maintenance cockpit for forks you actually care about.
|
||||
</h2>
|
||||
</div>
|
||||
<p className='text-muted-foreground max-w-xl leading-7'>
|
||||
Spoon is not trying to replace GitHub. It is the layer that explains how
|
||||
your fork relates to upstream and what should happen next.
|
||||
Custom work should not mean permanent drift. Spoon keeps the operational
|
||||
picture clear from the first upstream check to the final draft PR.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -195,58 +221,112 @@ export const Features = () => (
|
||||
</section>
|
||||
);
|
||||
|
||||
export const Agents = () => (
|
||||
<section id='agents' className='border-border/60 bg-muted/30 border-y'>
|
||||
<div className='container mx-auto grid gap-10 px-4 py-24 lg:grid-cols-[0.95fr_1.05fr]'>
|
||||
<div>
|
||||
export const MaintenanceDecisions = () => (
|
||||
<section id='maintenance' className='border-border/60 bg-muted/30 border-y'>
|
||||
<div className='container mx-auto px-4 py-24'>
|
||||
<div className='mb-10 max-w-3xl'>
|
||||
<Badge variant='outline' className='mb-4'>
|
||||
Agent work
|
||||
Maintenance decisions
|
||||
</Badge>
|
||||
<h2 className='text-3xl font-semibold tracking-normal md:text-4xl'>
|
||||
The agent belongs inside the fork dashboard.
|
||||
Spoon knows when to sync, when to thread, and when to stay out of the
|
||||
way.
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className='overflow-hidden rounded-lg border'>
|
||||
{decisions.map((decision) => (
|
||||
<div
|
||||
key={decision.condition}
|
||||
className='bg-background border-border grid gap-4 border-b p-5 last:border-b-0 lg:grid-cols-[1fr_12rem_1.3fr]'
|
||||
>
|
||||
<p className='font-medium'>{decision.condition}</p>
|
||||
<Badge
|
||||
variant='outline'
|
||||
className='bg-primary/10 text-primary w-fit'
|
||||
>
|
||||
{decision.action}
|
||||
</Badge>
|
||||
<p className='text-muted-foreground text-sm leading-6'>
|
||||
{decision.explanation}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
export const ThreadedWork = () => (
|
||||
<section id='threads' className='container mx-auto px-4 py-24'>
|
||||
<div className='grid gap-10 lg:grid-cols-[0.9fr_1.1fr]'>
|
||||
<div>
|
||||
<Badge variant='outline' className='mb-4'>
|
||||
Threads
|
||||
</Badge>
|
||||
<h2 className='text-3xl font-semibold tracking-normal md:text-4xl'>
|
||||
Threads keep the whole maintenance conversation in one place.
|
||||
</h2>
|
||||
<p className='text-muted-foreground mt-4 text-lg leading-8'>
|
||||
The goal is simple: ask for a change, let a worker clone the current
|
||||
fork, expose only the secrets you selected, run checks, push a branch,
|
||||
and open a draft pull request. The first pieces are already modeled:
|
||||
encrypted Spoon secrets, agent settings, queued jobs, logs, and
|
||||
artifacts.
|
||||
Upstream changed, a fork drifted, a conflict appeared, or you asked
|
||||
for a code change. Spoon puts the reasoning, messages, workspace,
|
||||
artifacts, and draft PR handoff in the same thread.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className='border-border bg-background rounded-lg border'>
|
||||
<div className='border-border border-b p-5'>
|
||||
<div className='flex items-center gap-3'>
|
||||
<span className='bg-primary/10 text-primary flex size-9 items-center justify-center rounded-md'>
|
||||
<Bot className='size-4' />
|
||||
</span>
|
||||
<div className='border-border bg-card overflow-hidden rounded-lg border shadow-sm'>
|
||||
<div className='border-border flex flex-wrap items-center justify-between gap-3 border-b p-5'>
|
||||
<div>
|
||||
<p className='font-medium'>Thread: Upstream auth changes landed</p>
|
||||
<p className='text-muted-foreground text-sm'>
|
||||
Source: upstream update
|
||||
</p>
|
||||
</div>
|
||||
<Badge className='bg-amber-500/10 text-amber-700 hover:bg-amber-500/10'>
|
||||
Waiting for review
|
||||
</Badge>
|
||||
</div>
|
||||
<div className='bg-border grid gap-px lg:grid-cols-[1.35fr_0.65fr]'>
|
||||
<div className='bg-background space-y-3 p-5'>
|
||||
{[
|
||||
['system', 'Spoon found 3 upstream commits after 8f3a2c1.'],
|
||||
[
|
||||
'assistant',
|
||||
'These touch auth callback handling and package scripts. Your fork has Authentik-only changes in the same area.',
|
||||
],
|
||||
[
|
||||
'user',
|
||||
'Open a review PR and preserve Authentik as the only provider.',
|
||||
],
|
||||
].map(([role, message]) => (
|
||||
<div key={role} className='rounded-md border p-3 text-sm'>
|
||||
<p className='text-muted-foreground text-xs'>{role}</p>
|
||||
<p className='mt-1 leading-6'>{message}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className='bg-background space-y-4 p-5 text-sm'>
|
||||
<div>
|
||||
<p className='font-medium'>Draft PR agent flow</p>
|
||||
<p className='text-muted-foreground text-sm'>
|
||||
Built for review, not automatic merge.
|
||||
<p className='text-muted-foreground text-xs'>Latest job</p>
|
||||
<p className='mt-1 font-medium'>OpenCode workspace active</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className='text-muted-foreground text-xs'>Model/provider</p>
|
||||
<p className='mt-1 font-medium'>Codex profile</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className='text-muted-foreground text-xs'>PR target</p>
|
||||
<p className='mt-1 font-medium'>fork:main</p>
|
||||
</div>
|
||||
<div className='bg-muted/60 rounded-md p-3'>
|
||||
<Bot className='text-primary mb-2 size-4' />
|
||||
<p className='text-muted-foreground leading-6'>
|
||||
Workspace logs, diffs, checks, and the final PR stay attached to
|
||||
the thread.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className='divide-border divide-y'>
|
||||
{[
|
||||
['Clone', 'Start from the current GitHub fork state.'],
|
||||
['Branch', 'Create a short-lived agent branch.'],
|
||||
['Edit', 'Apply focused changes with selected project context.'],
|
||||
[
|
||||
'Check',
|
||||
'Run configured install, lint, typecheck, or test steps.',
|
||||
],
|
||||
['Review', 'Open a draft pull request with logs and artifacts.'],
|
||||
].map(([phase, detail]) => (
|
||||
<div key={phase} className='grid gap-3 p-5 sm:grid-cols-[8rem_1fr]'>
|
||||
<p className='text-sm font-semibold'>{phase}</p>
|
||||
<p className='text-muted-foreground text-sm leading-6'>
|
||||
{detail}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -260,18 +340,18 @@ export const Security = () => (
|
||||
Ownership
|
||||
</Badge>
|
||||
<h2 className='text-3xl font-semibold tracking-normal md:text-4xl'>
|
||||
Useful because it respects how forks are really maintained.
|
||||
Self-hosted because the fork is yours.
|
||||
</h2>
|
||||
<p className='text-muted-foreground mt-4 text-lg leading-8'>
|
||||
A fork can have local experiments, CI changes, private deployment
|
||||
settings, and emergency upstream fixes all happening at once. Spoon
|
||||
keeps those threads visible without pretending every change must come
|
||||
through the app.
|
||||
Your fork can have local commits, private deploy settings, Gitea
|
||||
mirrors, CI experiments, and emergency GitHub edits. Spoon keeps that
|
||||
relationship with upstream visible without taking ownership away from
|
||||
you.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className='grid gap-4 sm:grid-cols-2'>
|
||||
{builtFor.map(({ title, description, icon: Icon }) => (
|
||||
{ownership.map(({ title, description, icon: Icon }) => (
|
||||
<div key={title} className='border-border rounded-lg border p-5'>
|
||||
<div className='flex items-center gap-3'>
|
||||
<Icon className='text-primary size-5 shrink-0' />
|
||||
|
||||
@@ -2,42 +2,11 @@
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useConvexAuth } from 'convex/react';
|
||||
import {
|
||||
ArrowRight,
|
||||
Bot,
|
||||
CheckCircle2,
|
||||
CircleDot,
|
||||
GitBranch,
|
||||
GitPullRequest,
|
||||
KeyRound,
|
||||
ShieldCheck,
|
||||
} from 'lucide-react';
|
||||
import { ArrowRight, CircleDot, ShieldCheck } from 'lucide-react';
|
||||
|
||||
import { Badge, Button } from '@spoon/ui';
|
||||
|
||||
const previewRows = [
|
||||
{
|
||||
name: 'gibsend',
|
||||
upstream: 'usesend/usesend',
|
||||
status: '3 upstream commits',
|
||||
icon: CheckCircle2,
|
||||
tone: 'text-emerald-600',
|
||||
},
|
||||
{
|
||||
name: 'internal-docs',
|
||||
upstream: 'platform/docs',
|
||||
status: 'AI review ready',
|
||||
icon: Bot,
|
||||
tone: 'text-teal-600',
|
||||
},
|
||||
{
|
||||
name: 'ops-console',
|
||||
upstream: 'console/main',
|
||||
status: 'fork-only changes',
|
||||
icon: GitPullRequest,
|
||||
tone: 'text-amber-600',
|
||||
},
|
||||
];
|
||||
import { ProductStoryDemo } from './product-story-demo';
|
||||
|
||||
export const Hero = () => {
|
||||
const { isAuthenticated } = useConvexAuth();
|
||||
@@ -47,17 +16,17 @@ export const Hero = () => {
|
||||
<div className='max-w-3xl'>
|
||||
<Badge variant='outline' className='mb-5 gap-2'>
|
||||
<ShieldCheck className='size-3.5 text-emerald-600' />
|
||||
Self-hostable fork maintenance cockpit
|
||||
Self-hostable fork maintenance with Threads
|
||||
</Badge>
|
||||
<h1 className='max-w-4xl text-4xl font-semibold tracking-normal text-balance sm:text-5xl md:text-6xl'>
|
||||
Make your forks <em className='text-primary'>intimately</em> close
|
||||
to upstream.
|
||||
Fork freely & keep them all{' '}
|
||||
<em className='text-primary'>intimately</em> close to upstream.
|
||||
</h1>
|
||||
<p className='text-muted-foreground mt-6 max-w-2xl text-lg leading-8'>
|
||||
Spoon gives every important fork a living maintenance dashboard.
|
||||
Track upstream drift, preserve your custom commits, review pull
|
||||
requests, and queue AI-assisted work without losing sight of the
|
||||
project you forked from.
|
||||
Spoon is a self-hostable maintenance cockpit for forks. It watches
|
||||
upstream, understands your fork-only changes, opens threads when
|
||||
decisions are needed, and helps keep your managed forks close
|
||||
without asking you to support them alone.
|
||||
</p>
|
||||
<div className='mt-8 flex flex-col gap-3 sm:flex-row'>
|
||||
<Button size='lg' asChild>
|
||||
@@ -67,13 +36,14 @@ export const Hero = () => {
|
||||
</Link>
|
||||
</Button>
|
||||
<Button size='lg' variant='outline' asChild>
|
||||
<Link href='#workflow'>See how it works</Link>
|
||||
<Link href='#demo'>Watch the flow</Link>
|
||||
</Button>
|
||||
</div>
|
||||
<div className='text-muted-foreground mt-8 grid max-w-xl gap-3 text-sm sm:grid-cols-3'>
|
||||
<div className='text-muted-foreground mt-8 grid max-w-2xl gap-3 text-sm sm:grid-cols-2'>
|
||||
{[
|
||||
'GitHub App backed',
|
||||
'OpenAI key per user',
|
||||
'Thread-first maintenance',
|
||||
'OpenCode workspaces',
|
||||
'Draft PR workflow',
|
||||
].map((item) => (
|
||||
<span key={item} className='flex items-center gap-2'>
|
||||
@@ -84,71 +54,7 @@ export const Hero = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='border-border bg-card overflow-hidden rounded-lg border shadow-sm'>
|
||||
<div className='border-border flex items-center justify-between border-b px-5 py-4'>
|
||||
<div>
|
||||
<p className='text-sm font-medium'>Fork health</p>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
Current state across managed Spoons
|
||||
</p>
|
||||
</div>
|
||||
<Badge className='bg-primary/10 text-primary hover:bg-primary/10'>
|
||||
Live GitHub sync
|
||||
</Badge>
|
||||
</div>
|
||||
<div className='grid gap-4 p-5 md:grid-cols-3'>
|
||||
{[
|
||||
['Behind', '3', 'upstream commits'],
|
||||
['Fork-only', '12', 'custom changes'],
|
||||
['AI risk', 'Low', 'reviewed'],
|
||||
].map(([label, value, note]) => (
|
||||
<div
|
||||
key={label}
|
||||
className='border-border bg-background rounded-md border p-4'
|
||||
>
|
||||
<p className='text-muted-foreground text-xs'>{label}</p>
|
||||
<p className='mt-2 text-2xl font-semibold'>{value}</p>
|
||||
<p className='text-muted-foreground mt-1 text-xs'>{note}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className='space-y-3 px-5 pb-5'>
|
||||
{previewRows.map(({ name, upstream, status, icon: Icon, tone }) => (
|
||||
<div
|
||||
key={name}
|
||||
className='border-border bg-background flex items-center justify-between gap-4 rounded-md border p-4'
|
||||
>
|
||||
<div className='flex items-center gap-3'>
|
||||
<span className='bg-muted flex size-9 items-center justify-center rounded-md'>
|
||||
<GitBranch className='size-4' />
|
||||
</span>
|
||||
<div>
|
||||
<p className='text-sm font-medium'>{name}</p>
|
||||
<p className='text-muted-foreground text-xs'>{upstream}</p>
|
||||
</div>
|
||||
</div>
|
||||
<span className='flex items-center gap-2 text-sm'>
|
||||
<Icon className={`size-4 ${tone}`} />
|
||||
{status}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className='border-border bg-muted/30 grid gap-3 border-t p-5 text-sm sm:grid-cols-2'>
|
||||
<div className='flex items-start gap-3'>
|
||||
<KeyRound className='text-primary mt-0.5 size-4' />
|
||||
<p className='text-muted-foreground'>
|
||||
User-owned OpenAI keys stay encrypted and selectable.
|
||||
</p>
|
||||
</div>
|
||||
<div className='flex items-start gap-3'>
|
||||
<GitPullRequest className='text-primary mt-0.5 size-4' />
|
||||
<p className='text-muted-foreground'>
|
||||
Agent jobs are shaped around draft pull requests.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ProductStoryDemo />
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -1,3 +1,11 @@
|
||||
export { Hero } from './hero';
|
||||
export { Agents, Features, Security, Workflow } from './features';
|
||||
export {
|
||||
Features,
|
||||
MaintenanceDecisions,
|
||||
Security,
|
||||
ThreadedWork,
|
||||
Workflow,
|
||||
} from './features';
|
||||
export { ProductStoryDemo } from './product-story-demo';
|
||||
export { WorkspaceShowcase } from './workspace-showcase';
|
||||
export { CTA } from './cta';
|
||||
|
||||
@@ -0,0 +1,370 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
AlertTriangle,
|
||||
CheckCircle2,
|
||||
Code2,
|
||||
GitBranch,
|
||||
GitPullRequest,
|
||||
MessagesSquare,
|
||||
RefreshCw,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { Badge, Button } from '@spoon/ui';
|
||||
|
||||
type DemoStep = 'check' | 'thread' | 'workspace' | 'decision' | 'pr';
|
||||
|
||||
type DemoStepConfig = {
|
||||
id: DemoStep;
|
||||
label: string;
|
||||
title: string;
|
||||
description: string;
|
||||
};
|
||||
|
||||
const defaultStep: DemoStepConfig = {
|
||||
id: 'check',
|
||||
label: 'Check',
|
||||
title: 'Upstream moved',
|
||||
description: 'Spoon checks default branches and compares the fork network.',
|
||||
};
|
||||
|
||||
const steps: DemoStepConfig[] = [
|
||||
defaultStep,
|
||||
{
|
||||
id: 'thread',
|
||||
label: 'Thread',
|
||||
title: 'Custom work needs context',
|
||||
description:
|
||||
'Fork-only commits turn an upstream update into a durable thread.',
|
||||
},
|
||||
{
|
||||
id: 'workspace',
|
||||
label: 'Workspace',
|
||||
title: 'OpenCode gets a sandbox',
|
||||
description:
|
||||
'The worker opens a repo workspace with files, thread context, and checks.',
|
||||
},
|
||||
{
|
||||
id: 'decision',
|
||||
label: 'Decision',
|
||||
title: 'Review the maintenance call',
|
||||
description:
|
||||
'Spoon records risk, conflicts, and the recommended next step.',
|
||||
},
|
||||
{
|
||||
id: 'pr',
|
||||
label: 'Draft PR',
|
||||
title: 'Ship as reviewable work',
|
||||
description:
|
||||
'Code changes leave the workspace as a branch and draft pull request.',
|
||||
},
|
||||
];
|
||||
|
||||
const usePrefersReducedMotion = () => {
|
||||
const [reducedMotion, setReducedMotion] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const query = window.matchMedia('(prefers-reduced-motion: reduce)');
|
||||
const update = () => setReducedMotion(query.matches);
|
||||
update();
|
||||
query.addEventListener('change', update);
|
||||
return () => query.removeEventListener('change', update);
|
||||
}, []);
|
||||
|
||||
return reducedMotion;
|
||||
};
|
||||
|
||||
const Metric = ({
|
||||
label,
|
||||
value,
|
||||
tone = 'default',
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
tone?: 'default' | 'warning' | 'good';
|
||||
}) => (
|
||||
<div className='border-border bg-background rounded-md border p-3'>
|
||||
<p className='text-muted-foreground text-xs'>{label}</p>
|
||||
<p
|
||||
className={
|
||||
tone === 'warning'
|
||||
? 'mt-1 text-lg font-semibold text-amber-600'
|
||||
: tone === 'good'
|
||||
? 'mt-1 text-lg font-semibold text-emerald-600'
|
||||
: 'mt-1 text-lg font-semibold'
|
||||
}
|
||||
>
|
||||
{value}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
const CheckPreview = () => (
|
||||
<div className='space-y-4'>
|
||||
<div className='grid gap-3 sm:grid-cols-3'>
|
||||
<Metric label='Raw upstream ahead' value='3 commits' tone='warning' />
|
||||
<Metric label='Fork-only work' value='5 commits' />
|
||||
<Metric label='Status' value='Needs thread' tone='warning' />
|
||||
</div>
|
||||
<div className='border-border bg-background rounded-md border p-4'>
|
||||
<div className='flex items-center justify-between gap-4'>
|
||||
<div className='min-w-0'>
|
||||
<p className='font-medium'>usesend-authentik</p>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
usesend/usesend {'->'} gibbyb/usesend
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant='outline'>daily check</Badge>
|
||||
</div>
|
||||
<div className='mt-4 grid gap-2 text-sm'>
|
||||
<div className='bg-muted/50 flex items-center justify-between rounded-md px-3 py-2'>
|
||||
<span className='flex items-center gap-2'>
|
||||
<RefreshCw className='text-primary size-4' />
|
||||
Compare upstream main
|
||||
</span>
|
||||
<span className='text-muted-foreground'>complete</span>
|
||||
</div>
|
||||
<div className='bg-muted/50 flex items-center justify-between rounded-md px-3 py-2'>
|
||||
<span className='flex items-center gap-2'>
|
||||
<GitBranch className='text-primary size-4' />
|
||||
Detect fork-only commits
|
||||
</span>
|
||||
<span className='text-muted-foreground'>custom auth work</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const ThreadPreview = () => (
|
||||
<div className='space-y-4'>
|
||||
<div className='border-border bg-background rounded-md border p-4'>
|
||||
<div className='flex flex-wrap items-center gap-2'>
|
||||
<MessagesSquare className='text-primary size-4' />
|
||||
<p className='font-medium'>
|
||||
Upstream changed: auth and webhook updates
|
||||
</p>
|
||||
<Badge className='bg-amber-500/10 text-amber-700 hover:bg-amber-500/10'>
|
||||
Review required
|
||||
</Badge>
|
||||
</div>
|
||||
<div className='mt-4 space-y-3 text-sm'>
|
||||
<div className='bg-muted/60 rounded-md p-3'>
|
||||
<p className='text-muted-foreground text-xs'>system</p>
|
||||
<p className='mt-1'>
|
||||
Spoon found upstream commits that touch auth-adjacent files. Fork
|
||||
has custom Authentik work.
|
||||
</p>
|
||||
</div>
|
||||
<div className='rounded-md border p-3'>
|
||||
<p className='text-muted-foreground text-xs'>assistant</p>
|
||||
<p className='mt-1'>
|
||||
These updates are probably valuable, but they overlap with fork-only
|
||||
provider changes.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const WorkspacePreview = () => (
|
||||
<div className='grid min-h-[19rem] gap-3 lg:grid-cols-[0.8fr_1.4fr_0.9fr]'>
|
||||
<div className='border-border bg-background rounded-md border p-3 text-xs'>
|
||||
<p className='mb-3 font-medium'>Files</p>
|
||||
{['packages/auth/providers.ts', '.env.example', 'apps/web/auth.ts'].map(
|
||||
(file) => (
|
||||
<div
|
||||
key={file}
|
||||
className='text-muted-foreground hover:text-foreground rounded px-2 py-1.5'
|
||||
>
|
||||
{file}
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
<div className='border-border rounded-md border bg-zinc-950 p-3 font-mono text-xs text-zinc-100'>
|
||||
<div className='mb-3 flex items-center justify-between text-zinc-400'>
|
||||
<span>providers.ts</span>
|
||||
<span>vim mode</span>
|
||||
</div>
|
||||
<pre className='overflow-hidden leading-6'>
|
||||
<code>{`export const providers = [
|
||||
Authentik({
|
||||
issuer: env.AUTHENTIK_ISSUER,
|
||||
clientId: env.AUTHENTIK_CLIENT_ID,
|
||||
}),
|
||||
];
|
||||
|
||||
// GitHub provider removed in fork`}</code>
|
||||
</pre>
|
||||
</div>
|
||||
<div className='border-border bg-background rounded-md border p-3 text-xs'>
|
||||
<p className='mb-3 font-medium'>Thread</p>
|
||||
<div className='space-y-2'>
|
||||
<p className='bg-muted/60 rounded-md p-2'>
|
||||
Preserve Authentik-only auth.
|
||||
</p>
|
||||
<p className='rounded-md border p-2'>
|
||||
Running typecheck after provider update.
|
||||
</p>
|
||||
<p className='text-muted-foreground bg-muted/40 rounded-md p-2'>
|
||||
Secrets available as process env.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const DecisionPreview = () => (
|
||||
<div className='space-y-3'>
|
||||
{[
|
||||
['Risk', 'Medium', 'Auth provider wiring overlaps custom fork changes.'],
|
||||
[
|
||||
'Recommended action',
|
||||
'Open review PR',
|
||||
'Keep the fork branch reviewable.',
|
||||
],
|
||||
[
|
||||
'Conflict signals',
|
||||
'2 files',
|
||||
'OAuth callback copy and package scripts.',
|
||||
],
|
||||
].map(([label, value, detail]) => (
|
||||
<div
|
||||
key={label}
|
||||
className='border-border bg-background rounded-md border p-4'
|
||||
>
|
||||
<div className='flex items-center justify-between gap-3'>
|
||||
<p className='text-muted-foreground text-sm'>{label}</p>
|
||||
<Badge variant='outline'>{value}</Badge>
|
||||
</div>
|
||||
<p className='mt-2 text-sm'>{detail}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
const DraftPrPreview = () => (
|
||||
<div className='border-border bg-background rounded-md border p-5'>
|
||||
<div className='flex flex-wrap items-center justify-between gap-3'>
|
||||
<div>
|
||||
<p className='font-medium'>Draft PR opened</p>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
spoon/thread/authentik-upstream
|
||||
</p>
|
||||
</div>
|
||||
<Badge className='bg-emerald-500/10 text-emerald-700 hover:bg-emerald-500/10'>
|
||||
ready for review
|
||||
</Badge>
|
||||
</div>
|
||||
<div className='mt-5 grid gap-2 text-sm'>
|
||||
<div className='bg-muted/50 flex items-center justify-between rounded-md px-3 py-2'>
|
||||
<span className='flex items-center gap-2'>
|
||||
<CheckCircle2 className='size-4 text-emerald-600' />
|
||||
lint
|
||||
</span>
|
||||
<span>passed</span>
|
||||
</div>
|
||||
<div className='bg-muted/50 flex items-center justify-between rounded-md px-3 py-2'>
|
||||
<span className='flex items-center gap-2'>
|
||||
<AlertTriangle className='size-4 text-amber-600' />
|
||||
typecheck
|
||||
</span>
|
||||
<span>queued</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button className='mt-5 w-full' variant='outline'>
|
||||
Review PR
|
||||
<GitPullRequest className='size-4' />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderPreview = (step: DemoStep) => {
|
||||
if (step === 'check') return <CheckPreview />;
|
||||
if (step === 'thread') return <ThreadPreview />;
|
||||
if (step === 'workspace') return <WorkspacePreview />;
|
||||
if (step === 'decision') return <DecisionPreview />;
|
||||
return <DraftPrPreview />;
|
||||
};
|
||||
|
||||
export const ProductStoryDemo = () => {
|
||||
const [activeStep, setActiveStep] = useState<DemoStep>('check');
|
||||
const [paused, setPaused] = useState(false);
|
||||
const reducedMotion = usePrefersReducedMotion();
|
||||
const active = useMemo(
|
||||
() => steps.find((step) => step.id === activeStep) ?? defaultStep,
|
||||
[activeStep],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (paused || reducedMotion) return;
|
||||
const interval = window.setInterval(() => {
|
||||
setActiveStep((current) => {
|
||||
const index = steps.findIndex((step) => step.id === current);
|
||||
return steps[(index + 1) % steps.length]?.id ?? 'check';
|
||||
});
|
||||
}, 3500);
|
||||
return () => window.clearInterval(interval);
|
||||
}, [paused, reducedMotion]);
|
||||
|
||||
return (
|
||||
<div
|
||||
id='demo'
|
||||
aria-label='Animated Spoon maintenance flow demo'
|
||||
className='border-border bg-card overflow-hidden rounded-lg border shadow-sm'
|
||||
onMouseEnter={() => setPaused(true)}
|
||||
onMouseLeave={() => setPaused(false)}
|
||||
onFocus={() => setPaused(true)}
|
||||
onBlur={() => setPaused(false)}
|
||||
>
|
||||
<div className='border-border flex items-start justify-between gap-4 border-b px-5 py-4'>
|
||||
<div>
|
||||
<p className='text-sm font-medium'>{active.title}</p>
|
||||
<p className='text-muted-foreground mt-1 text-xs'>
|
||||
{active.description}
|
||||
</p>
|
||||
</div>
|
||||
<Badge className='bg-primary/10 text-primary hover:bg-primary/10'>
|
||||
Live flow
|
||||
</Badge>
|
||||
</div>
|
||||
<div className='border-border flex gap-2 overflow-x-auto border-b p-2'>
|
||||
{steps.map((step) => (
|
||||
<button
|
||||
key={step.id}
|
||||
type='button'
|
||||
aria-current={step.id === activeStep ? 'step' : undefined}
|
||||
onClick={() => setActiveStep(step.id)}
|
||||
className={
|
||||
step.id === activeStep
|
||||
? 'bg-primary text-primary-foreground flex-none rounded-md px-3 py-2 text-xs font-medium'
|
||||
: 'text-muted-foreground hover:bg-muted hover:text-foreground flex-none rounded-md px-3 py-2 text-xs font-medium'
|
||||
}
|
||||
>
|
||||
{step.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className='min-h-[24rem] p-4 sm:p-5'>
|
||||
{renderPreview(activeStep)}
|
||||
</div>
|
||||
<div className='border-border bg-muted/30 grid gap-3 border-t p-4 text-sm sm:grid-cols-3'>
|
||||
<span className='flex items-center gap-2'>
|
||||
<GitBranch className='text-primary size-4' />
|
||||
fork stays source of truth
|
||||
</span>
|
||||
<span className='flex items-center gap-2'>
|
||||
<MessagesSquare className='text-primary size-4' />
|
||||
decisions stay threaded
|
||||
</span>
|
||||
<span className='flex items-center gap-2'>
|
||||
<Code2 className='text-primary size-4' />
|
||||
workspace opens when needed
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,150 @@
|
||||
import {
|
||||
CheckCircle2,
|
||||
Code2,
|
||||
FileCode2,
|
||||
GitBranch,
|
||||
MessagesSquare,
|
||||
Terminal,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { Badge } from '@spoon/ui';
|
||||
|
||||
const files = [
|
||||
'apps/web/auth.ts',
|
||||
'packages/auth/providers.ts',
|
||||
'packages/auth/env.ts',
|
||||
'.env.example',
|
||||
];
|
||||
|
||||
const codeLines = [
|
||||
'export const authProviders = [',
|
||||
' Authentik({',
|
||||
' issuer: env.AUTHENTIK_ISSUER,',
|
||||
' clientId: env.AUTHENTIK_CLIENT_ID,',
|
||||
' }),',
|
||||
'];',
|
||||
];
|
||||
|
||||
export const WorkspaceShowcase = () => (
|
||||
<section id='workspace' className='border-border/60 bg-muted/30 border-y'>
|
||||
<div className='container mx-auto px-4 py-24'>
|
||||
<div className='mb-10 max-w-3xl'>
|
||||
<Badge variant='outline' className='mb-4'>
|
||||
Workspace
|
||||
</Badge>
|
||||
<h2 className='text-3xl font-semibold tracking-normal md:text-4xl'>
|
||||
When a thread needs code, open a real workspace.
|
||||
</h2>
|
||||
<p className='text-muted-foreground mt-4 text-lg leading-8'>
|
||||
Spoon can expose project secrets as process env, optionally
|
||||
materialize an env file, run configured checks, and refuse to commit
|
||||
`.env*` files. The result is reviewable code, not a mystery patch.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className='border-border bg-card overflow-hidden rounded-lg border shadow-sm'>
|
||||
<div className='border-border flex flex-wrap items-center justify-between gap-3 border-b px-5 py-4'>
|
||||
<div className='flex flex-wrap items-center gap-3 text-sm'>
|
||||
<span className='flex items-center gap-2'>
|
||||
<GitBranch className='text-primary size-4' />
|
||||
spoon/thread/authentik-upstream
|
||||
</span>
|
||||
<span className='text-muted-foreground hidden sm:inline'>/</span>
|
||||
<span className='flex items-center gap-2'>
|
||||
<Code2 className='text-primary size-4' />
|
||||
OpenCode
|
||||
</span>
|
||||
</div>
|
||||
<Badge className='bg-emerald-500/10 text-emerald-700 hover:bg-emerald-500/10'>
|
||||
Workspace active
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className='bg-border grid min-h-[30rem] gap-px lg:grid-cols-[0.8fr_1.5fr_0.9fr]'>
|
||||
<div className='bg-background p-4'>
|
||||
<p className='mb-3 flex items-center gap-2 text-sm font-medium'>
|
||||
<FileCode2 className='text-primary size-4' />
|
||||
Files
|
||||
</p>
|
||||
<div className='space-y-1 text-sm'>
|
||||
{files.map((file, index) => (
|
||||
<div
|
||||
key={file}
|
||||
className={
|
||||
index === 1
|
||||
? 'bg-primary/10 text-primary rounded-md px-3 py-2 font-medium'
|
||||
: 'text-muted-foreground rounded-md px-3 py-2'
|
||||
}
|
||||
>
|
||||
{file}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='bg-zinc-950 p-4 text-zinc-100'>
|
||||
<div className='mb-4 flex items-center justify-between text-xs text-zinc-400'>
|
||||
<span>packages/auth/providers.ts</span>
|
||||
<span>vim mode on</span>
|
||||
</div>
|
||||
<pre className='overflow-x-auto rounded-md bg-zinc-900 p-4 text-xs leading-7'>
|
||||
<code>
|
||||
{codeLines.map((line, index) => (
|
||||
<span key={line} className='block'>
|
||||
<span className='mr-4 text-zinc-600'>
|
||||
{String(index + 1).padStart(2, '0')}
|
||||
</span>
|
||||
{line}
|
||||
</span>
|
||||
))}
|
||||
</code>
|
||||
</pre>
|
||||
<div className='mt-4 rounded-md border border-zinc-800 bg-zinc-900 p-4 text-xs'>
|
||||
<p className='text-emerald-400'>+ Authentik provider</p>
|
||||
<p className='text-red-300'>- GitHub provider fallback</p>
|
||||
<p className='mt-2 text-zinc-400'>
|
||||
Diff stays attached to the thread before the draft PR opens.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='bg-background bg-border grid gap-px'>
|
||||
<div className='bg-background p-4'>
|
||||
<p className='mb-3 flex items-center gap-2 text-sm font-medium'>
|
||||
<MessagesSquare className='text-primary size-4' />
|
||||
Thread
|
||||
</p>
|
||||
<div className='space-y-3 text-sm'>
|
||||
<p className='bg-muted/60 rounded-md p-3'>
|
||||
Preserve Authentik as the only provider.
|
||||
</p>
|
||||
<p className='rounded-md border p-3'>
|
||||
I found the provider wiring and updated the env example.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className='bg-background p-4'>
|
||||
<p className='mb-3 flex items-center gap-2 text-sm font-medium'>
|
||||
<Terminal className='text-primary size-4' />
|
||||
Checks
|
||||
</p>
|
||||
<div className='space-y-2 text-sm'>
|
||||
<div className='bg-muted/60 flex items-center justify-between rounded-md px-3 py-2'>
|
||||
<span>lint</span>
|
||||
<span className='flex items-center gap-1 text-emerald-600'>
|
||||
<CheckCircle2 className='size-4' />
|
||||
passed
|
||||
</span>
|
||||
</div>
|
||||
<div className='bg-muted/60 flex items-center justify-between rounded-md px-3 py-2'>
|
||||
<span>typecheck</span>
|
||||
<span className='text-muted-foreground'>queued</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
@@ -9,9 +9,8 @@ export default function Footer() {
|
||||
<div className='md:col-span-2'>
|
||||
<SpoonLogo className='mb-4' />
|
||||
<p className='text-muted-foreground max-w-md text-sm'>
|
||||
Spoon is a self-hostable fork maintenance dashboard for teams who
|
||||
want to customize upstream projects without drifting away from
|
||||
security fixes, product updates, and merge history.
|
||||
Spoon is a self-hostable fork maintenance cockpit for keeping
|
||||
important forks close to upstream without supporting them alone.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -36,10 +35,10 @@ export default function Footer() {
|
||||
</li>
|
||||
<li>
|
||||
<Link
|
||||
href='/updates'
|
||||
href='/threads'
|
||||
className='text-muted-foreground hover:text-foreground transition-colors'
|
||||
>
|
||||
Updates
|
||||
Threads
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
@@ -50,18 +49,18 @@ export default function Footer() {
|
||||
<ul className='space-y-2 text-sm'>
|
||||
<li>
|
||||
<Link
|
||||
href='/agents'
|
||||
href='/settings/ai-providers'
|
||||
className='text-muted-foreground hover:text-foreground transition-colors'
|
||||
>
|
||||
Agents
|
||||
AI providers
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link
|
||||
href='/profile'
|
||||
href='/settings/integrations'
|
||||
className='text-muted-foreground hover:text-foreground transition-colors'
|
||||
>
|
||||
Profile
|
||||
Integrations
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
@@ -80,7 +79,7 @@ export default function Footer() {
|
||||
|
||||
<div className='border-border/40 text-muted-foreground mt-12 border-t pt-8 text-center text-sm'>
|
||||
<p>
|
||||
Self-hostable fork maintenance for teams that stay close to
|
||||
Self-hostable fork maintenance for projects that stay close to
|
||||
upstream.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -7,10 +7,9 @@ import { useConvexAuth } from 'convex/react';
|
||||
import {
|
||||
GitBranch,
|
||||
LayoutDashboard,
|
||||
RefreshCw,
|
||||
MessagesSquare,
|
||||
Settings,
|
||||
ShieldCheck,
|
||||
Sparkles,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { Button } from '@spoon/ui';
|
||||
@@ -31,12 +30,12 @@ const Header = (headerProps: ComponentProps<'header'>) => {
|
||||
{
|
||||
href: '/spoons',
|
||||
icon: GitBranch,
|
||||
label: 'My Spoons',
|
||||
label: 'Spoons',
|
||||
},
|
||||
{
|
||||
href: '/updates',
|
||||
icon: RefreshCw,
|
||||
label: 'Updates',
|
||||
href: '/threads',
|
||||
icon: MessagesSquare,
|
||||
label: 'Threads',
|
||||
},
|
||||
{
|
||||
href: '/settings/profile',
|
||||
@@ -51,9 +50,9 @@ const Header = (headerProps: ComponentProps<'header'>) => {
|
||||
label: 'Workflow',
|
||||
},
|
||||
{
|
||||
href: '/#features',
|
||||
icon: Sparkles,
|
||||
label: 'Features',
|
||||
href: '/#threads',
|
||||
icon: MessagesSquare,
|
||||
label: 'Threads',
|
||||
},
|
||||
{
|
||||
href: '/#security',
|
||||
|
||||
@@ -9,12 +9,12 @@ const formatDate = (value: number) =>
|
||||
|
||||
export const SpoonActivityTimeline = ({
|
||||
syncRuns,
|
||||
reviews,
|
||||
requests,
|
||||
threads,
|
||||
jobs,
|
||||
}: {
|
||||
syncRuns: Doc<'syncRuns'>[];
|
||||
reviews: Doc<'aiReviews'>[];
|
||||
requests: Doc<'agentRequests'>[];
|
||||
threads: Doc<'threads'>[];
|
||||
jobs: Doc<'agentJobs'>[];
|
||||
}) => {
|
||||
const items = [
|
||||
...syncRuns.map((item) => ({
|
||||
@@ -24,18 +24,18 @@ export const SpoonActivityTimeline = ({
|
||||
summary: item.summary ?? item.error ?? 'Sync run recorded.',
|
||||
time: item.createdAt,
|
||||
})),
|
||||
...reviews.map((item) => ({
|
||||
...threads.map((item) => ({
|
||||
id: item._id,
|
||||
kind: 'AI review',
|
||||
kind: item.source.replaceAll('_', ' '),
|
||||
status: item.status,
|
||||
summary: item.outputSummary ?? item.inputSummary,
|
||||
summary: item.summary ?? item.title,
|
||||
time: item.createdAt,
|
||||
})),
|
||||
...requests.map((item) => ({
|
||||
...jobs.map((item) => ({
|
||||
id: item._id,
|
||||
kind: 'Agent request',
|
||||
kind: item.jobType?.replaceAll('_', ' ') ?? 'workspace job',
|
||||
status: item.status,
|
||||
summary: item.prompt,
|
||||
summary: item.summary ?? item.prompt,
|
||||
time: item.createdAt,
|
||||
})),
|
||||
].sort((a, b) => b.time - a.time);
|
||||
@@ -62,7 +62,7 @@ export const SpoonActivityTimeline = ({
|
||||
) : (
|
||||
<Card className='shadow-none'>
|
||||
<CardContent className='text-muted-foreground p-6 text-sm'>
|
||||
Refreshes, AI reviews, and queued requests will build this timeline.
|
||||
Refreshes, threads, and workspace jobs will build this timeline.
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useMutation } from 'convex/react';
|
||||
import type { ProviderModelOption } from '@/lib/models-dev';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { loadModelsDevOptions } from '@/lib/models-dev';
|
||||
import { useMutation, useQuery } from 'convex/react';
|
||||
import { Bot } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import type { Doc } from '@spoon/backend/convex/_generated/dataModel.js';
|
||||
import type { Doc, Id } from '@spoon/backend/convex/_generated/dataModel.js';
|
||||
import { api } from '@spoon/backend/convex/_generated/api.js';
|
||||
import {
|
||||
Button,
|
||||
@@ -24,17 +26,9 @@ import {
|
||||
} from '@spoon/ui';
|
||||
|
||||
const efforts = ['minimal', 'low', 'medium', 'high', 'xhigh'] as const;
|
||||
const modelOptions = [
|
||||
{ value: 'gpt-5.1-codex', label: 'GPT-5.1 Codex' },
|
||||
{ value: 'gpt-5.5', label: 'GPT-5.5' },
|
||||
{ value: 'gpt-5.5-pro', label: 'GPT-5.5 Pro' },
|
||||
{ value: 'gpt-5.4', label: 'GPT-5.4' },
|
||||
{ value: 'gpt-5.4-mini', label: 'GPT-5.4 Mini' },
|
||||
] as const;
|
||||
type AgentModel = (typeof modelOptions)[number]['value'];
|
||||
|
||||
type AgentSettings = {
|
||||
enabled: boolean;
|
||||
runtime?: 'opencode' | 'openai_direct';
|
||||
defaultBaseBranch?: string;
|
||||
branchPrefix: string;
|
||||
installCommand?: string;
|
||||
@@ -42,13 +36,14 @@ type AgentSettings = {
|
||||
testCommand?: string;
|
||||
agentModel: string;
|
||||
reasoningEffort: 'none' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh';
|
||||
envFilePath?: string;
|
||||
customEnvFilePath?: string;
|
||||
materializeEnvFileByDefault?: boolean;
|
||||
autoDetectCommands?: boolean;
|
||||
allowUserFileEditing?: boolean;
|
||||
aiProviderProfileId?: Id<'aiProviderProfiles'>;
|
||||
};
|
||||
|
||||
const toAgentModel = (value?: string): AgentModel =>
|
||||
modelOptions.some((option) => option.value === value)
|
||||
? (value as AgentModel)
|
||||
: 'gpt-5.1-codex';
|
||||
|
||||
export const SpoonAgentSettingsForm = ({
|
||||
spoon,
|
||||
settings,
|
||||
@@ -57,6 +52,13 @@ export const SpoonAgentSettingsForm = ({
|
||||
settings?: AgentSettings | null;
|
||||
}) => {
|
||||
const update = useMutation(api.spoonAgentSettings.update);
|
||||
const profiles = useQuery(api.aiProviderProfiles.listMine, {}) ?? [];
|
||||
const configuredProfiles = profiles.filter(
|
||||
(profile) => profile.enabled && profile.configured,
|
||||
);
|
||||
const defaultProfile = configuredProfiles.find(
|
||||
(profile) => profile.isDefault,
|
||||
);
|
||||
const [enabled, setEnabled] = useState(settings?.enabled ?? true);
|
||||
const [defaultBaseBranch, setDefaultBaseBranch] = useState(
|
||||
settings?.defaultBaseBranch ??
|
||||
@@ -73,29 +75,113 @@ export const SpoonAgentSettingsForm = ({
|
||||
settings?.checkCommand ?? '',
|
||||
);
|
||||
const [testCommand, setTestCommand] = useState(settings?.testCommand ?? '');
|
||||
const [agentModel, setAgentModel] = useState<AgentModel>(
|
||||
toAgentModel(settings?.agentModel),
|
||||
const [envFilePath, setEnvFilePath] = useState(
|
||||
settings?.envFilePath ?? '.env.local',
|
||||
);
|
||||
const [customEnvFilePath, setCustomEnvFilePath] = useState(
|
||||
settings?.customEnvFilePath ?? '',
|
||||
);
|
||||
const [materializeEnvFileByDefault, setMaterializeEnvFileByDefault] =
|
||||
useState(settings?.materializeEnvFileByDefault ?? false);
|
||||
const [autoDetectCommands, setAutoDetectCommands] = useState(
|
||||
settings?.autoDetectCommands ?? true,
|
||||
);
|
||||
const [allowUserFileEditing, setAllowUserFileEditing] = useState(
|
||||
settings?.allowUserFileEditing ?? true,
|
||||
);
|
||||
const [aiProviderProfileId, setAiProviderProfileId] = useState(
|
||||
settings?.aiProviderProfileId ?? '__default',
|
||||
);
|
||||
const selectedProfile = profiles.find(
|
||||
(profile) =>
|
||||
profile._id ===
|
||||
(aiProviderProfileId === '__default'
|
||||
? defaultProfile?._id
|
||||
: aiProviderProfileId),
|
||||
);
|
||||
const [availableModels, setAvailableModels] = useState<ProviderModelOption[]>(
|
||||
[],
|
||||
);
|
||||
const [agentModel, setAgentModel] = useState(
|
||||
settings?.aiProviderProfileId ? settings.agentModel : '',
|
||||
);
|
||||
const [reasoningEffort, setReasoningEffort] = useState<
|
||||
'minimal' | 'low' | 'medium' | 'high' | 'xhigh'
|
||||
>(
|
||||
settings?.reasoningEffort === 'none'
|
||||
? 'minimal'
|
||||
: (settings?.reasoningEffort ?? 'high'),
|
||||
!settings?.aiProviderProfileId
|
||||
? 'medium'
|
||||
: settings.reasoningEffort === 'none'
|
||||
? 'minimal'
|
||||
: 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 save = async () => {
|
||||
try {
|
||||
await update({
|
||||
spoonId: spoon._id,
|
||||
enabled,
|
||||
runtime: 'opencode',
|
||||
defaultBaseBranch,
|
||||
branchPrefix,
|
||||
installCommand: installCommand || undefined,
|
||||
checkCommand: checkCommand || undefined,
|
||||
testCommand: testCommand || undefined,
|
||||
agentModel,
|
||||
agentModel: agentModel.trim()
|
||||
? agentModel
|
||||
: (selectableModels[0]?.id ?? undefined),
|
||||
reasoningEffort,
|
||||
envFilePath: envFilePath as
|
||||
| '.env'
|
||||
| '.env.local'
|
||||
| '.env.production'
|
||||
| '.env.production.local'
|
||||
| 'custom',
|
||||
customEnvFilePath: customEnvFilePath || undefined,
|
||||
materializeEnvFileByDefault,
|
||||
autoDetectCommands,
|
||||
allowUserFileEditing,
|
||||
aiProviderProfileId:
|
||||
aiProviderProfileId === '__default'
|
||||
? undefined
|
||||
: (aiProviderProfileId as Id<'aiProviderProfiles'>),
|
||||
clearAiProviderProfile: aiProviderProfileId === '__default',
|
||||
});
|
||||
toast.success('Agent settings saved.');
|
||||
} catch (error) {
|
||||
@@ -122,6 +208,50 @@ export const SpoonAgentSettingsForm = ({
|
||||
/>
|
||||
</div>
|
||||
<div className='grid gap-3 md:grid-cols-2'>
|
||||
<div className='grid gap-2'>
|
||||
<Label>Runtime</Label>
|
||||
<Input value='OpenCode workspace' disabled />
|
||||
</div>
|
||||
<div className='grid gap-2'>
|
||||
<Label>AI provider profile</Label>
|
||||
<Select
|
||||
value={aiProviderProfileId}
|
||||
onValueChange={(value) => {
|
||||
setAiProviderProfileId(value);
|
||||
const nextProfile = profiles.find(
|
||||
(profile) =>
|
||||
profile._id ===
|
||||
(value === '__default' ? defaultProfile?._id : value),
|
||||
);
|
||||
if (!nextProfile?.configured) setAgentModel('');
|
||||
if (nextProfile?.configured) {
|
||||
setReasoningEffort(
|
||||
nextProfile.reasoningEffort === 'none'
|
||||
? 'minimal'
|
||||
: nextProfile.reasoningEffort,
|
||||
);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value='__default'>
|
||||
Use account default
|
||||
{defaultProfile ? ` (${defaultProfile.name})` : ''}
|
||||
</SelectItem>
|
||||
{configuredProfiles.map((profile) => (
|
||||
<SelectItem key={profile._id} value={profile._id}>
|
||||
{profile.name} · {profile.provider.replaceAll('_', ' ')}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
OpenCode jobs and maintenance review threads use this profile.
|
||||
</p>
|
||||
</div>
|
||||
<div className='grid gap-2'>
|
||||
<Label htmlFor='defaultBaseBranch'>Default base branch</Label>
|
||||
<Input
|
||||
@@ -142,19 +272,26 @@ export const SpoonAgentSettingsForm = ({
|
||||
<Label htmlFor='agentModel'>Model</Label>
|
||||
<Select
|
||||
value={agentModel}
|
||||
onValueChange={(value) => setAgentModel(value as AgentModel)}
|
||||
onValueChange={setAgentModel}
|
||||
disabled={!selectableModels.length}
|
||||
>
|
||||
<SelectTrigger id='agentModel'>
|
||||
<SelectValue />
|
||||
<SelectValue placeholder='Choose a configured model' />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{modelOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{selectableModels.map((option) => (
|
||||
<SelectItem key={option.id} value={option.id}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{!selectableModels.length ? (
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
Configure an enabled AI provider profile in Settings before
|
||||
choosing a model.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className='grid gap-2'>
|
||||
<Label>Reasoning effort</Label>
|
||||
@@ -215,8 +352,80 @@ export const SpoonAgentSettingsForm = ({
|
||||
Leave blank to run the detected test script when one exists.
|
||||
</p>
|
||||
</div>
|
||||
<div className='grid gap-2'>
|
||||
<Label>Env file path</Label>
|
||||
<Select value={envFilePath} onValueChange={setEnvFilePath}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value='.env'>.env</SelectItem>
|
||||
<SelectItem value='.env.local'>.env.local</SelectItem>
|
||||
<SelectItem value='.env.production'>.env.production</SelectItem>
|
||||
<SelectItem value='.env.production.local'>
|
||||
.env.production.local
|
||||
</SelectItem>
|
||||
<SelectItem value='custom'>Custom path</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{envFilePath === 'custom' ? (
|
||||
<div className='grid gap-2'>
|
||||
<Label htmlFor='customEnvFilePath'>Custom env path</Label>
|
||||
<Input
|
||||
id='customEnvFilePath'
|
||||
value={customEnvFilePath}
|
||||
placeholder='.env.spoon'
|
||||
onChange={(event) => setCustomEnvFilePath(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
<div className='flex items-center justify-between gap-4 rounded-md border p-3'>
|
||||
<div>
|
||||
<Label>Materialize env file by default</Label>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
Write all Spoon secrets into the chosen .env file for new
|
||||
workspaces.
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={materializeEnvFileByDefault}
|
||||
onCheckedChange={setMaterializeEnvFileByDefault}
|
||||
/>
|
||||
</div>
|
||||
<div className='flex items-center justify-between gap-4 rounded-md border p-3'>
|
||||
<div>
|
||||
<Label>Auto-detect commands</Label>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
Inspect package files after cloning.
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={autoDetectCommands}
|
||||
onCheckedChange={setAutoDetectCommands}
|
||||
/>
|
||||
</div>
|
||||
<div className='flex items-center justify-between gap-4 rounded-md border p-3'>
|
||||
<div>
|
||||
<Label>Allow browser file editing</Label>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
Let users edit workspace files manually.
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={allowUserFileEditing}
|
||||
onCheckedChange={setAllowUserFileEditing}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Button type='button' onClick={save}>
|
||||
<Button
|
||||
type='button'
|
||||
onClick={save}
|
||||
disabled={
|
||||
!selectedProfile?.configured ||
|
||||
!selectableModels.some((model) => model.id === agentModel)
|
||||
}
|
||||
>
|
||||
Save agent settings
|
||||
</Button>
|
||||
</CardContent>
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
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>
|
||||
);
|
||||
@@ -4,7 +4,7 @@ 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 { RefreshCw, RotateCw } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import type { Doc, Id } from '@spoon/backend/convex/_generated/dataModel.js';
|
||||
@@ -27,12 +27,6 @@ const syncRef = makeFunctionReference<
|
||||
unknown
|
||||
>('githubSync:syncForkWithUpstream');
|
||||
|
||||
const reviewRef = makeFunctionReference<
|
||||
'action',
|
||||
{ spoonId: Id<'spoons'> },
|
||||
{ reviewId: Id<'aiReviews'>; risk: string; recommendedAction: string }
|
||||
>('aiReviewActions:reviewLatestUpstreamChanges');
|
||||
|
||||
export const SpoonDetailHeader = ({
|
||||
spoon,
|
||||
state,
|
||||
@@ -42,7 +36,6 @@ export const SpoonDetailHeader = ({
|
||||
}) => {
|
||||
const refresh = useAction(refreshRef);
|
||||
const sync = useAction(syncRef);
|
||||
const review = useAction(reviewRef);
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
const canSync =
|
||||
spoon.provider === 'github' &&
|
||||
@@ -110,14 +103,6 @@ export const SpoonDetailHeader = ({
|
||||
<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}
|
||||
|
||||
@@ -2,7 +2,7 @@ import {
|
||||
Clock,
|
||||
GitCommit,
|
||||
GitPullRequest,
|
||||
ShieldCheck,
|
||||
MessagesSquare,
|
||||
TrendingUp,
|
||||
} from 'lucide-react';
|
||||
|
||||
@@ -17,11 +17,11 @@ const formatDate = (value?: number) =>
|
||||
export const SpoonMetrics = ({
|
||||
spoon,
|
||||
state,
|
||||
latestReview,
|
||||
latestThread,
|
||||
}: {
|
||||
spoon: Doc<'spoons'>;
|
||||
state?: Doc<'spoonRepositoryStates'> | null;
|
||||
latestReview?: Doc<'aiReviews'> | null;
|
||||
latestThread?: Doc<'threads'> | null;
|
||||
}) => {
|
||||
const metrics = [
|
||||
{
|
||||
@@ -42,9 +42,9 @@ export const SpoonMetrics = ({
|
||||
icon: GitPullRequest,
|
||||
},
|
||||
{
|
||||
label: 'Latest AI risk',
|
||||
value: latestReview?.risk ?? 'unknown',
|
||||
icon: ShieldCheck,
|
||||
label: 'Latest thread',
|
||||
value: latestThread?.status.replaceAll('_', ' ') ?? 'none',
|
||||
icon: MessagesSquare,
|
||||
},
|
||||
{
|
||||
label: 'Last check',
|
||||
|
||||
@@ -93,7 +93,7 @@ export const SpoonSettingsForm = ({
|
||||
onChange: setAutoRefreshEnabled,
|
||||
},
|
||||
{
|
||||
label: 'Auto AI review',
|
||||
label: 'Auto maintenance threads',
|
||||
value: autoReviewEnabled,
|
||||
onChange: setAutoReviewEnabled,
|
||||
},
|
||||
@@ -103,7 +103,7 @@ export const SpoonSettingsForm = ({
|
||||
onChange: setAutoSyncEnabled,
|
||||
},
|
||||
{
|
||||
label: 'Require low AI risk for sync',
|
||||
label: 'Require low-risk thread decision for sync',
|
||||
value: requireAiLowRiskForSync,
|
||||
onChange: setRequireAiLowRiskForSync,
|
||||
},
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
import type { Doc } from '@spoon/backend/convex/_generated/dataModel.js';
|
||||
import { Badge, Button, Card, CardContent } from '@spoon/ui';
|
||||
|
||||
export const MaintenanceQueue = ({
|
||||
threads,
|
||||
}: {
|
||||
threads: Doc<'threads'>[];
|
||||
}) => {
|
||||
const queued = threads.filter(
|
||||
(thread) =>
|
||||
['upstream_update', 'merge_conflict'].includes(thread.source) &&
|
||||
!['resolved', 'ignored', 'failed', 'cancelled'].includes(thread.status),
|
||||
);
|
||||
|
||||
return (
|
||||
<div className='space-y-3'>
|
||||
{queued.length ? (
|
||||
queued.map((thread) => (
|
||||
<Card key={thread._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'>{thread.title}</p>
|
||||
<Badge>{thread.status.replaceAll('_', ' ')}</Badge>
|
||||
{thread.maintenanceOutcome ? (
|
||||
<Badge variant='secondary'>
|
||||
{thread.maintenanceOutcome.replaceAll('_', ' ')}
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
<p className='text-muted-foreground mt-1 text-sm'>
|
||||
{thread.summary ?? 'Maintenance thread waiting for review.'}
|
||||
</p>
|
||||
<p className='text-muted-foreground mt-1 text-xs'>
|
||||
{new Date(thread.updatedAt).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
<Button variant='outline' size='sm' asChild>
|
||||
<Link href={`/threads/${thread._id}`}>Open thread</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>
|
||||
);
|
||||
};
|
||||
@@ -1,52 +0,0 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -19,6 +19,9 @@ export const env = createEnv({
|
||||
GITHUB_APP_SLUG: z.string().optional(),
|
||||
GITHUB_APP_INSTALLATION_ID: z.string().optional(),
|
||||
GITHUB_APP_OWNER: z.string().optional(),
|
||||
SPOON_AGENT_WORKER_URL: z.url().default('http://localhost:3921'),
|
||||
SPOON_AGENT_WORKER_INTERNAL_TOKEN: z.string().optional(),
|
||||
SPOON_WORKER_TOKEN: z.string().optional(),
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -52,6 +55,10 @@ export const env = createEnv({
|
||||
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,
|
||||
SPOON_AGENT_WORKER_URL: process.env.SPOON_AGENT_WORKER_URL,
|
||||
SPOON_AGENT_WORKER_INTERNAL_TOKEN:
|
||||
process.env.SPOON_AGENT_WORKER_INTERNAL_TOKEN,
|
||||
SPOON_WORKER_TOKEN: process.env.SPOON_WORKER_TOKEN,
|
||||
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,86 @@
|
||||
import 'server-only';
|
||||
|
||||
import { NextResponse } from 'next/server';
|
||||
import { env } from '@/env';
|
||||
import { convexAuthNextjsToken } from '@convex-dev/auth/nextjs/server';
|
||||
import { fetchQuery } from 'convex/nextjs';
|
||||
|
||||
import type { Id } from '@spoon/backend/convex/_generated/dataModel.js';
|
||||
import { api } from '@spoon/backend/convex/_generated/api.js';
|
||||
|
||||
type RouteContext = {
|
||||
params: Promise<{ jobId: string }> | { jobId: string };
|
||||
};
|
||||
|
||||
export const routeJobId = async (context: RouteContext) => {
|
||||
const params = await context.params;
|
||||
return params.jobId as Id<'agentJobs'>;
|
||||
};
|
||||
|
||||
const workerToken = () =>
|
||||
env.SPOON_AGENT_WORKER_INTERNAL_TOKEN ?? env.SPOON_WORKER_TOKEN;
|
||||
|
||||
export const requireOwnedJob = async (jobId: Id<'agentJobs'>) => {
|
||||
const token = await convexAuthNextjsToken();
|
||||
if (!token) {
|
||||
return {
|
||||
ok: false as const,
|
||||
response: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
|
||||
};
|
||||
}
|
||||
await fetchQuery(api.agentJobs.assertOwned, { jobId }, { token });
|
||||
return { ok: true as const };
|
||||
};
|
||||
|
||||
export const proxyWorker = async (
|
||||
jobId: Id<'agentJobs'>,
|
||||
action: string,
|
||||
init?: RequestInit,
|
||||
search?: URLSearchParams,
|
||||
) => {
|
||||
const token = workerToken();
|
||||
if (!token) {
|
||||
return NextResponse.json(
|
||||
{ error: 'SPOON_AGENT_WORKER_INTERNAL_TOKEN is not configured.' },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
const url = new URL(
|
||||
`/jobs/${encodeURIComponent(jobId)}/${action}`,
|
||||
env.SPOON_AGENT_WORKER_URL,
|
||||
);
|
||||
if (search) {
|
||||
for (const [key, value] of search) url.searchParams.set(key, value);
|
||||
}
|
||||
const response = await fetch(url, {
|
||||
...init,
|
||||
headers: {
|
||||
authorization: `Bearer ${token}`,
|
||||
'content-type': 'application/json',
|
||||
...init?.headers,
|
||||
},
|
||||
});
|
||||
const text = await response.text();
|
||||
return new NextResponse(text, {
|
||||
status: response.status,
|
||||
headers: {
|
||||
'content-type':
|
||||
response.headers.get('content-type') ?? 'application/json',
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const withOwnedJob = async (
|
||||
context: RouteContext,
|
||||
handler: (jobId: Id<'agentJobs'>) => Promise<Response>,
|
||||
) => {
|
||||
try {
|
||||
const jobId = await routeJobId(context);
|
||||
const owned = await requireOwnedJob(jobId);
|
||||
if (!owned.ok) return owned.response;
|
||||
return await handler(jobId);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,56 @@
|
||||
type ModelsDevModel = {
|
||||
id?: string;
|
||||
name?: string;
|
||||
tool_call?: boolean;
|
||||
reasoning?: boolean;
|
||||
limit?: { context?: number };
|
||||
};
|
||||
|
||||
type ModelsDevProvider = {
|
||||
id?: string;
|
||||
name?: string;
|
||||
models?: Record<string, ModelsDevModel>;
|
||||
};
|
||||
|
||||
const providerMap = {
|
||||
openai: 'openai',
|
||||
anthropic: 'anthropic',
|
||||
google: 'google',
|
||||
openrouter: 'openrouter',
|
||||
requesty: 'requesty',
|
||||
litellm: 'litellm',
|
||||
cloudflare_ai_gateway: 'cloudflare',
|
||||
custom_openai_compatible: '',
|
||||
opencode_openai_login: 'openai',
|
||||
} as const;
|
||||
|
||||
export type ProviderModelOption = {
|
||||
id: string;
|
||||
label: string;
|
||||
reasoning: boolean;
|
||||
toolCall: boolean;
|
||||
context?: number;
|
||||
};
|
||||
|
||||
export const loadModelsDevOptions = async (provider: string) => {
|
||||
const mapped = providerMap[provider as keyof typeof providerMap];
|
||||
if (!mapped) return [];
|
||||
const response = await fetch('https://models.dev/api.json', {
|
||||
cache: 'force-cache',
|
||||
});
|
||||
if (!response.ok) return [];
|
||||
const catalog = (await response.json()) as Record<string, ModelsDevProvider>;
|
||||
const providerCatalog = catalog[mapped];
|
||||
return Object.entries(providerCatalog?.models ?? {})
|
||||
.map(
|
||||
([id, model]): ProviderModelOption => ({
|
||||
id: model.id ?? id,
|
||||
label: model.name ?? model.id ?? id,
|
||||
reasoning: Boolean(model.reasoning),
|
||||
toolCall: Boolean(model.tool_call),
|
||||
context: model.limit?.context,
|
||||
}),
|
||||
)
|
||||
.filter((model) => model.toolCall)
|
||||
.sort((a, b) => a.label.localeCompare(b.label));
|
||||
};
|
||||
@@ -10,6 +10,7 @@ const isProtectedRoute = createRouteMatcher([
|
||||
'/spoons(.*)',
|
||||
'/updates(.*)',
|
||||
'/agents(.*)',
|
||||
'/threads(.*)',
|
||||
'/github(.*)',
|
||||
'/settings(.*)',
|
||||
'/profile(.*)',
|
||||
|
||||
Reference in New Issue
Block a user