Clean up old stuff & fix ui errors
Build and Push Spoon Images / quality (push) Successful in 2m22s
Build and Push Spoon Images / build-images (push) Successful in 23m10s

This commit is contained in:
Gabriel Brown
2026-06-23 14:57:05 -04:00
parent d207b8b0b8
commit a6f7ea7f78
34 changed files with 1565 additions and 551 deletions
+4
View File
@@ -12,6 +12,10 @@
- `packages/backend/convex`: self-hosted Convex functions, schema, and auth. - `packages/backend/convex`: self-hosted Convex functions, schema, and auth.
- `packages/ui`: shared shadcn-based UI components. - `packages/ui`: shared shadcn-based UI components.
- `tools`: shared ESLint, Prettier, Tailwind, TypeScript, and Vitest config. - `tools`: shared ESLint, Prettier, Tailwind, TypeScript, and Vitest config.
- Threads are the canonical user-facing workspace route. Normal navigation
should open `/threads/[threadId]`; legacy job URLs under
`/spoons/[spoonId]/agent/[jobId]` are compatibility routes for jobs that do
not have a thread yet.
- Local development uses host-run apps, local Convex on ports 3210/3211, local - Local development uses host-run apps, local Convex on ports 3210/3211, local
Postgres on port 5432 for Convex storage, and the Convex dashboard on port 6791. Postgres on port 5432 for Convex storage, and the Convex dashboard on port 6791.
Agent jobs are opt-in; build `docker/agent-job.Dockerfile` as Agent jobs are opt-in; build `docker/agent-job.Dockerfile` as
+7
View File
@@ -111,6 +111,12 @@ Common thread sources:
Threads hold messages, status, outcomes, related sync runs, related jobs, Threads hold messages, status, outcomes, related sync runs, related jobs,
workspace links, draft PR links, and ignored upstream decisions. workspace links, draft PR links, and ignored upstream decisions.
Opening a thread opens its workspace when a run exists. The workspace is the
primary surface for that thread: agent messages, tool activity, file edits,
manual edits, diffs, commands, and draft PR actions all happen there. Legacy
job URLs under `/spoons/[spoonId]/agent/[jobId]` are kept for compatibility,
but normal navigation targets `/threads/[threadId]`.
</details> </details>
<details open> <details open>
@@ -144,6 +150,7 @@ Workspace capabilities:
- browse repository files - browse repository files
- edit files in a browser editor - edit files in a browser editor
- use optional Vim keybindings - use optional Vim keybindings
- resize the agent thread panel on desktop
- inspect diffs - inspect diffs
- send thread messages to the agent - send thread messages to the agent
- run configured commands - run configured commands
+132 -4
View File
@@ -70,6 +70,62 @@ const textFromPart = (part: Record<string, unknown>) => {
return typeof text === 'string' ? text : ''; return typeof text === 'string' ? text : '';
}; };
const commandString = (value: unknown) => {
if (Array.isArray(value)) return value.map((part) => stringify(part)).join(' ');
return stringify(value);
};
const toolNameFromRecord = (record: Record<string, unknown> | null) =>
stringify(
record?.tool ??
record?.tool_name ??
record?.toolName ??
record?.name ??
record?.function ??
record?.type ??
record?.command ??
'tool',
);
const toolInputFromRecord = (record: Record<string, unknown> | null) =>
commandString(
record?.input ??
record?.arguments ??
record?.args ??
record?.params ??
record?.command ??
record?.cmd,
);
const toolOutputFromRecord = (
record: Record<string, unknown> | null,
fallback?: unknown,
) =>
stringify(
record?.output ??
record?.result ??
record?.content ??
record?.text ??
fallback,
);
const recordLooksLikeTool = (
type: string,
record: Record<string, unknown> | null,
) => {
const recordType = stringify(record?.type).toLowerCase();
const lowerType = type.toLowerCase();
return (
lowerType.includes('tool') ||
lowerType.includes('function_call') ||
recordType.includes('tool') ||
recordType.includes('function_call') ||
recordType.includes('local_shell_call') ||
recordType.includes('mcp') ||
Boolean(record?.tool ?? record?.tool_name ?? record?.name)
);
};
export const normalizeCodexJsonLine = ( export const normalizeCodexJsonLine = (
line: string, line: string,
): NormalizedAgentEvent[] => { ): NormalizedAgentEvent[] => {
@@ -95,6 +151,37 @@ export const normalizeCodexJsonLine = (
const item = asRecord(event.item); const item = asRecord(event.item);
const data = asRecord(event.data); const data = asRecord(event.data);
const part = asRecord(event.part); const part = asRecord(event.part);
const itemType = item ? stringify(item.type) : '';
const lowerType = type.toLowerCase();
const lowerItemType = itemType.toLowerCase();
if (
item &&
recordLooksLikeTool(type, item) &&
(lowerType.includes('started') ||
lowerType.includes('in_progress') ||
lowerType.includes('created'))
) {
events.push({
kind: 'tool_started',
name: toolNameFromRecord(item),
input: toolInputFromRecord(item),
externalMessageId: stringify(item.id ?? event.id),
});
}
if (
item &&
recordLooksLikeTool(type, item) &&
(lowerType.includes('completed') ||
lowerType.includes('done') ||
lowerType.includes('finished'))
) {
events.push({
kind: 'tool_completed',
name: toolNameFromRecord(item),
output: toolOutputFromRecord(item, event.output ?? data?.output),
externalMessageId: stringify(item.id ?? event.id),
});
}
const delta = event.delta ?? data?.delta; const delta = event.delta ?? data?.delta;
if (typeof delta === 'string') { if (typeof delta === 'string') {
events.push({ kind: 'assistant_delta', content: delta }); events.push({ kind: 'assistant_delta', content: delta });
@@ -107,19 +194,43 @@ export const normalizeCodexJsonLine = (
text && text &&
(type.includes('message') || (type.includes('message') ||
type.includes('response.output_text') || type.includes('response.output_text') ||
type.includes('agent_message')) type.includes('agent_message') ||
itemType.includes('message') ||
itemType.includes('agent_message'))
) { ) {
events.push({ kind: 'assistant_delta', content: text }); events.push({ kind: 'assistant_delta', content: text });
} }
const command = event.command ?? data?.command; const error = event.error ?? item?.error;
if (error || itemType === 'error') {
events.push({
kind: 'error',
message: stringify(error ?? item?.message ?? event.message),
});
}
const command =
event.command ??
data?.command ??
(lowerItemType.includes('shell') ? item?.command : undefined);
if (typeof command === 'string') { if (typeof command === 'string') {
events.push({ events.push({
kind: 'command_executed', kind: 'command_executed',
command, command,
output: stringify(event.output ?? data?.output), output: stringify(event.output ?? data?.output),
}); });
} else if (Array.isArray(command)) {
events.push({
kind: 'command_executed',
command: command.map((part) => stringify(part)).join(' '),
output: stringify(event.output ?? data?.output ?? item?.output),
});
} }
const file = event.file ?? event.path ?? data?.file ?? data?.path; const file =
event.file ??
event.path ??
data?.file ??
data?.path ??
item?.file ??
item?.path;
if (typeof file === 'string' && type.includes('file')) { if (typeof file === 'string' && type.includes('file')) {
events.push({ kind: 'file_edited', path: file }); events.push({ kind: 'file_edited', path: file });
} }
@@ -129,7 +240,16 @@ export const normalizeCodexJsonLine = (
message: stringify(event.message ?? event.error ?? data), message: stringify(event.message ?? event.error ?? data),
}); });
} }
if (type.includes('completed') || type.includes('turn.done')) { if (
type.includes('completed') &&
itemType !== 'error' &&
!itemType.includes('message') &&
!itemType.includes('agent_message') &&
!recordLooksLikeTool(type, item)
) {
events.push({ kind: 'assistant_completed' });
}
if (type.includes('turn.done')) {
events.push({ kind: 'assistant_completed' }); events.push({ kind: 'assistant_completed' });
} }
if (events.length === 0) { if (events.length === 0) {
@@ -188,6 +308,14 @@ export const normalizeOpenCodeEvent = (
externalMessageId: stringify(properties.messageID), externalMessageId: stringify(properties.messageID),
}); });
} }
if (type.includes('tool.updated') || type.includes('tool.output')) {
events.push({
kind: 'tool_completed',
name: stringify(properties.tool ?? properties.name ?? 'tool'),
output: stringify(properties.output ?? properties.result ?? properties),
externalMessageId: stringify(properties.messageID),
});
}
if (type === 'file.edited') { if (type === 'file.edited') {
const file = properties.file; const file = properties.file;
if (typeof file === 'string') events.push({ kind: 'file_edited', path: file }); if (typeof file === 'string') events.push({ kind: 'file_edited', path: file });
+39
View File
@@ -0,0 +1,39 @@
import { chmod, mkdir, stat } from 'node:fs/promises';
import path from 'node:path';
export const codexContainerWorkspace = '/workspace';
export const codexContainerRepo = '/workspace/repo';
export const prepareCodexWorkspaceFiles = async (args: {
workdir: string;
repoDir: string;
}) => {
await mkdir(path.join(args.workdir, '.codex'), { recursive: true });
await mkdir(path.join(args.workdir, '.config'), { recursive: true });
await mkdir(path.join(args.workdir, '.local', 'share'), { recursive: true });
await Promise.all([
chmod(args.workdir, 0o755),
chmod(args.repoDir, 0o755),
chmod(path.join(args.workdir, '.codex'), 0o755),
chmod(path.join(args.workdir, '.config'), 0o755),
chmod(path.join(args.workdir, '.local'), 0o755),
chmod(path.join(args.workdir, '.local', 'share'), 0o755),
]);
const projectCodexDir = path.join(args.repoDir, '.codex');
const projectConfig = path.join(projectCodexDir, 'config.toml');
try {
if ((await stat(projectCodexDir)).isDirectory()) {
await chmod(projectCodexDir, 0o755);
}
if ((await stat(projectConfig)).isFile()) {
await chmod(projectConfig, 0o644);
}
} catch (error) {
const code = error && typeof error === 'object' ? 'code' in error : false;
if (!code || (error as { code?: string }).code !== 'ENOENT') {
throw error;
}
}
};
+1
View File
@@ -23,6 +23,7 @@ export const env = {
process.env.SPOON_AGENT_CONTAINER_RUNTIME?.trim() ?? process.env.SPOON_AGENT_CONTAINER_RUNTIME?.trim() ??
process.env.SPOON_CONTAINER_RUNTIME?.trim() ?? process.env.SPOON_CONTAINER_RUNTIME?.trim() ??
'docker', 'docker',
containerVolumeOptions: process.env.SPOON_AGENT_CONTAINER_VOLUME_OPTIONS?.trim(),
containerAccess: containerAccess:
process.env.SPOON_AGENT_CONTAINER_ACCESS?.trim() === 'host_port' process.env.SPOON_AGENT_CONTAINER_ACCESS?.trim() === 'host_port'
? 'host_port' ? 'host_port'
+12 -3
View File
@@ -17,6 +17,15 @@ const networkArgs = () => (env.network ? ['--network', env.network] : []);
const containerRuntime = () => env.containerRuntime; const containerRuntime = () => env.containerRuntime;
export const jobWorkspaceVolumeSpec = (workdir: string) => {
const volumeOptions =
env.containerVolumeOptions ??
(containerRuntime().endsWith('podman') ? 'Z' : undefined);
return volumeOptions
? `${workdir}:/workspace:${volumeOptions}`
: `${workdir}:/workspace`;
};
export const runInJobContainer = async (args: { export const runInJobContainer = async (args: {
workdir: string; workdir: string;
command: string[]; command: string[];
@@ -36,7 +45,7 @@ export const runInJobContainer = async (args: {
...networkArgs(), ...networkArgs(),
...environmentArgs(args.environment), ...environmentArgs(args.environment),
'-v', '-v',
`${args.workdir}:/workspace`, jobWorkspaceVolumeSpec(args.workdir),
'-w', '-w',
'/workspace/repo', '/workspace/repo',
env.jobImage, env.jobImage,
@@ -87,7 +96,7 @@ export const startWorkspaceContainer = async (args: {
: []), : []),
...environmentArgs(args.environment), ...environmentArgs(args.environment),
'-v', '-v',
`${args.workdir}:/workspace`, jobWorkspaceVolumeSpec(args.workdir),
'-w', '-w',
'/workspace/repo', '/workspace/repo',
env.jobImage, env.jobImage,
@@ -168,7 +177,7 @@ export const streamInJobContainer = async (args: {
...networkArgs(), ...networkArgs(),
...environmentArgs(args.environment), ...environmentArgs(args.environment),
'-v', '-v',
`${args.workdir}:/workspace`, jobWorkspaceVolumeSpec(args.workdir),
'-w', '-w',
'/workspace/repo', '/workspace/repo',
env.jobImage, env.jobImage,
+107 -23
View File
@@ -16,6 +16,11 @@ import { api } from '@spoon/backend/convex/_generated/api.js';
import type { NormalizedAgentEvent } from './agent-events'; import type { NormalizedAgentEvent } from './agent-events';
import { normalizeCodexJsonLine } from './agent-events'; import { normalizeCodexJsonLine } from './agent-events';
import {
codexContainerRepo,
codexContainerWorkspace,
prepareCodexWorkspaceFiles,
} from './codex-runtime';
import { env } from './env'; import { env } from './env';
import { import {
cloneRepository, cloneRepository,
@@ -118,7 +123,6 @@ const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
const client = new ConvexHttpClient(env.convexUrl); const client = new ConvexHttpClient(env.convexUrl);
const activeWorkspaces = new Map<string, ActiveWorkspace>(); const activeWorkspaces = new Map<string, ActiveWorkspace>();
const jobContainerWorkspace = '/workspace';
const appendEvent = async ( const appendEvent = async (
jobId: Id<'agentJobs'>, jobId: Id<'agentJobs'>,
@@ -442,6 +446,10 @@ const prepareCodexAuth = async (workspace: ActiveWorkspace) => {
if (!secret) { if (!secret) {
throw new Error('Codex auth profile is missing auth.json contents.'); throw new Error('Codex auth profile is missing auth.json contents.');
} }
await prepareCodexWorkspaceFiles({
workdir: workspace.workdir,
repoDir: workspace.repoDir,
});
const codexAuthPath = path.join(workspace.workdir, '.codex', 'auth.json'); const codexAuthPath = path.join(workspace.workdir, '.codex', 'auth.json');
await writeJsonFile(codexAuthPath, secret); await writeJsonFile(codexAuthPath, secret);
@@ -688,14 +696,18 @@ const runCodexTurn = async (args: {
? commandToShell( ? commandToShell(
`codex exec resume --json --model ${quoteShell( `codex exec resume --json --model ${quoteShell(
codexModel(workspace.claim), codexModel(workspace.claim),
)} ${quoteShell(workspace.codexSessionId)} ${quoteShell(prompt)}`, )} --dangerously-bypass-approvals-and-sandbox ${quoteShell(
workspace.codexSessionId,
)} ${quoteShell(prompt)}`,
) )
: commandToShell( : commandToShell(
`codex exec --json --model ${quoteShell( `codex exec --json --model ${quoteShell(
codexModel(workspace.claim), codexModel(workspace.claim),
)} --sandbox workspace-write ${quoteShell(prompt)}`, )} --dangerously-bypass-approvals-and-sandbox --cd ${quoteShell(
codexContainerRepo,
)} ${quoteShell(prompt)}`,
); );
const aiEnv = providerEnvironment(workspace.claim, jobContainerWorkspace); const aiEnv = providerEnvironment(workspace.claim, codexContainerWorkspace);
const secretEnv = Object.fromEntries( const secretEnv = Object.fromEntries(
workspace.claim.secrets.map((secret) => [secret.name, secret.value]), workspace.claim.secrets.map((secret) => [secret.name, secret.value]),
); );
@@ -958,6 +970,89 @@ const fileChangedType = async (repoDir: string, filePath: string) => {
return 'modified' as const; return 'modified' as const;
}; };
const sensitiveWorkspacePath = (filePath: string) => {
const parts = filePath.split('/');
if (parts.includes('.git') || parts.includes('.codex')) return true;
const name = parts.at(-1) ?? filePath;
if (name === '.env') return true;
if (name.startsWith('.env.') && name !== '.env.example') return true;
return false;
};
const changedFilesFromStatus = async (
repoDir: string,
redact: (value: string) => string,
) => {
const status = await run('git', ['status', '--short'], {
cwd: repoDir,
redact,
timeoutMs: 60_000,
});
if (status.exitCode !== 0) return [];
return status.output
.split('\n')
.map((line) => {
if (line.length < 4) return null;
const code = line.slice(0, 2);
const rawPath = line.slice(3).trim();
if (!rawPath) return null;
const filePath = rawPath.includes(' -> ')
? rawPath.split(' -> ').at(-1)?.trim()
: rawPath;
if (!filePath || sensitiveWorkspacePath(filePath)) return null;
const changeType = code.includes('D')
? 'deleted'
: code.includes('R')
? 'renamed'
: code.includes('A') || code.includes('?')
? 'added'
: 'modified';
return {
path: filePath,
changeType,
};
})
.filter(
(
value,
): value is {
path: string;
changeType: 'added' | 'modified' | 'deleted' | 'renamed';
} => Boolean(value),
);
};
const recordChangedFiles = async (
workspace: ActiveWorkspace,
source: 'agent' | 'command',
diff: string,
) => {
const changes = await changedFilesFromStatus(
workspace.repoDir,
workspace.redact,
);
for (const change of changes) {
await recordWorkspaceChange({
jobId: workspace.claim.job._id,
path: change.path,
source,
changeType: change.changeType,
diff: truncate(diff, 50_000),
});
}
if (changes.length > 0) {
await appendEvent(
workspace.claim.job._id,
'info',
'edit',
`Workspace has ${changes.length} changed file${
changes.length === 1 ? '' : 's'
}.`,
JSON.stringify(changes),
);
}
};
const materializeEnvFile = async (workspace: ActiveWorkspace) => { const materializeEnvFile = async (workspace: ActiveWorkspace) => {
const { claim, repoDir } = workspace; const { claim, repoDir } = workspace;
if (!claim.job.materializeEnvFile || !claim.job.envFilePath) return; if (!claim.job.materializeEnvFile || !claim.job.envFilePath) return;
@@ -1085,6 +1180,9 @@ const runClaim = async (claim: Claim) => {
].filter(Boolean); ].filter(Boolean);
const redact = createRedactor(secretValues); const redact = createRedactor(secretValues);
try { try {
if ((claim.job.runtime ?? 'opencode') !== 'opencode') {
throw new Error('Legacy OpenAI direct jobs are no longer supported.');
}
await updateStatus(jobId, 'preparing'); await updateStatus(jobId, 'preparing');
await appendEvent(jobId, 'info', 'clone', 'Creating installation token.'); await appendEvent(jobId, 'info', 'clone', 'Creating installation token.');
if (!claim.github.installationId) { if (!claim.github.installationId) {
@@ -1251,16 +1349,11 @@ export const runWorkspaceCommand = async (jobId: string, command: string) => {
redact: workspace.redact, redact: workspace.redact,
}); });
await updateStatus(workspace.claim.job._id, 'running'); await updateStatus(workspace.claim.job._id, 'running');
await recordWorkspaceChange({ await recordChangedFiles(
jobId: workspace.claim.job._id, workspace,
path: '.', 'command',
source: 'command',
changeType: 'modified',
diff: truncate(
(await getWorktreeDiff(workspace.repoDir, workspace.redact)).output, (await getWorktreeDiff(workspace.repoDir, workspace.redact)).output,
50_000, );
),
});
return { success: true }; return { success: true };
}; };
@@ -1347,9 +1440,6 @@ export const sendWorkspaceMessage = async (jobId: string, prompt: string) => {
await appendEvent(claim.job._id, 'info', 'plan', 'Sending message to agent.'); await appendEvent(claim.job._id, 'info', 'plan', 'Sending message to agent.');
try { try {
if ((claim.job.runtime ?? 'opencode') !== 'opencode') {
throw new Error('Legacy OpenAI direct jobs are no longer supported.');
}
workspace.agentTurnActive = true; workspace.agentTurnActive = true;
const assistantMessageId = await appendMessage({ const assistantMessageId = await appendMessage({
jobId: claim.job._id, jobId: claim.job._id,
@@ -1430,13 +1520,7 @@ export const sendWorkspaceMessage = async (jobId: string, prompt: string) => {
content: truncate(diff.output, 200_000), content: truncate(diff.output, 200_000),
contentType: 'text/x-diff', contentType: 'text/x-diff',
}); });
await recordWorkspaceChange({ await recordChangedFiles(workspace, 'agent', diff.output);
jobId: claim.job._id,
path: '.',
source: 'agent',
changeType: 'modified',
diff: truncate(diff.output, 50_000),
});
} catch (error) { } catch (error) {
workspace.agentTurnActive = false; workspace.agentTurnActive = false;
workspace.resolveTurn?.(); workspace.resolveTurn?.();
@@ -51,6 +51,91 @@ describe('agent event normalization', () => {
).toContainEqual({ kind: 'file_edited', path: 'src/app.ts' }); ).toContainEqual({ kind: 'file_edited', path: 'src/app.ts' });
}); });
test('normalizes current Codex item events', () => {
expect(
normalizeCodexJsonLine(
JSON.stringify({
type: 'item.completed',
item: {
id: 'item-1',
type: 'agent_message',
text: 'I updated the auth provider.',
},
}),
),
).toContainEqual({
kind: 'assistant_delta',
content: 'I updated the auth provider.',
});
expect(
normalizeCodexJsonLine(
JSON.stringify({
type: 'item.completed',
item: {
id: 'item-2',
type: 'error',
message: 'sandbox failed',
},
}),
),
).toContainEqual({
kind: 'error',
message: 'sandbox failed',
});
expect(
normalizeCodexJsonLine(
JSON.stringify({
type: 'turn.failed',
error: { message: 'request failed' },
}),
),
).toContainEqual({
kind: 'error',
message: '{\n "message": "request failed"\n}',
});
});
test('normalizes Codex tool item lifecycle events', () => {
expect(
normalizeCodexJsonLine(
JSON.stringify({
type: 'item.started',
item: {
id: 'tool-1',
type: 'local_shell_call',
command: ['bash', '-lc', 'rg Authentik'],
},
}),
),
).toContainEqual({
kind: 'tool_started',
name: 'local_shell_call',
input: 'bash -lc rg Authentik',
externalMessageId: 'tool-1',
});
expect(
normalizeCodexJsonLine(
JSON.stringify({
type: 'item.completed',
item: {
id: 'tool-1',
type: 'local_shell_call',
command: ['bash', '-lc', 'rg Authentik'],
output: 'apps/web/auth.ts',
},
}),
),
).toContainEqual({
kind: 'tool_completed',
name: 'local_shell_call',
output: 'apps/web/auth.ts',
externalMessageId: 'tool-1',
});
});
test('normalizes OpenCode assistant, tool, and permission events', () => { test('normalizes OpenCode assistant, tool, and permission events', () => {
expect( expect(
normalizeOpenCodeEvent({ normalizeOpenCodeEvent({
@@ -93,5 +178,21 @@ describe('agent event normalization', () => {
body: 'Run bun test?', body: 'Run bun test?',
metadata: '{\n "permissionID": "perm-1",\n "message": "Run bun test?"\n}', metadata: '{\n "permissionID": "perm-1",\n "message": "Run bun test?"\n}',
}); });
expect(
normalizeOpenCodeEvent({
type: 'tool.output',
properties: {
tool: 'read',
output: 'apps/web/auth.ts',
messageID: 'message-2',
},
}),
).toContainEqual({
kind: 'tool_completed',
name: 'read',
output: 'apps/web/auth.ts',
externalMessageId: 'message-2',
});
}); });
}); });
@@ -0,0 +1,43 @@
import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { afterEach, describe, expect, test } from 'vitest';
import { prepareCodexWorkspaceFiles } from '../../src/codex-runtime';
const tempDirs: string[] = [];
const mode = async (filePath: string) => (await stat(filePath)).mode & 0o777;
describe('Codex runtime preparation', () => {
afterEach(async () => {
await Promise.all(
tempDirs.map((dir) => rm(dir, { force: true, recursive: true })),
);
tempDirs.length = 0;
});
test('prepares writable Codex directories and preserves project config contents', async () => {
const workdir = await mkdtemp(path.join(os.tmpdir(), 'spoon-codex-'));
tempDirs.push(workdir);
const repoDir = path.join(workdir, 'repo');
await mkdir(path.join(repoDir, '.codex'), { recursive: true });
const projectConfig = path.join(repoDir, '.codex', 'config.toml');
await writeFile(projectConfig, '[features]\ncodex_hooks = true\n');
await prepareCodexWorkspaceFiles({ workdir, repoDir });
await expect(readFile(projectConfig, 'utf8')).resolves.toBe(
'[features]\ncodex_hooks = true\n',
);
await expect(mode(workdir)).resolves.toBe(0o755);
await expect(mode(repoDir)).resolves.toBe(0o755);
await expect(mode(path.join(workdir, '.codex'))).resolves.toBe(0o755);
await expect(mode(path.join(workdir, '.config'))).resolves.toBe(0o755);
await expect(mode(path.join(workdir, '.local', 'share'))).resolves.toBe(
0o755,
);
await expect(mode(path.join(repoDir, '.codex'))).resolves.toBe(0o755);
await expect(mode(projectConfig)).resolves.toBe(0o644);
});
});
@@ -0,0 +1,46 @@
import { afterEach, describe, expect, test, vi } from 'vitest';
const loadVolumeSpec = async () => {
vi.resetModules();
process.env.SPOON_WORKER_TOKEN = 'test-worker-token';
process.env.GITHUB_APP_ID = '123';
process.env.GITHUB_APP_PRIVATE_KEY =
'-----BEGIN PRIVATE KEY-----\\ntest\\n-----END PRIVATE KEY-----';
return await import('../../src/runtime/docker');
};
describe('Docker runtime', () => {
afterEach(() => {
delete process.env.SPOON_AGENT_CONTAINER_RUNTIME;
delete process.env.SPOON_AGENT_CONTAINER_VOLUME_OPTIONS;
vi.resetModules();
});
test('adds SELinux relabel option for Podman workspace mounts by default', async () => {
process.env.SPOON_AGENT_CONTAINER_RUNTIME = 'podman';
const { jobWorkspaceVolumeSpec } = await loadVolumeSpec();
expect(jobWorkspaceVolumeSpec('/tmp/spoon-job')).toBe(
'/tmp/spoon-job:/workspace:Z',
);
});
test('does not add Podman volume options for Docker by default', async () => {
process.env.SPOON_AGENT_CONTAINER_RUNTIME = 'docker';
const { jobWorkspaceVolumeSpec } = await loadVolumeSpec();
expect(jobWorkspaceVolumeSpec('/tmp/spoon-job')).toBe(
'/tmp/spoon-job:/workspace',
);
});
test('allows explicit workspace mount options', async () => {
process.env.SPOON_AGENT_CONTAINER_RUNTIME = 'podman';
process.env.SPOON_AGENT_CONTAINER_VOLUME_OPTIONS = 'z';
const { jobWorkspaceVolumeSpec } = await loadVolumeSpec();
expect(jobWorkspaceVolumeSpec('/tmp/spoon-job')).toBe(
'/tmp/spoon-job:/workspace:z',
);
});
});
@@ -1,15 +1,33 @@
'use client'; 'use client';
import { useEffect } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { useParams } from 'next/navigation'; import { useParams, useRouter } from 'next/navigation';
import { AgentWorkspaceShell } from '@/components/agent-workspace/agent-workspace-shell'; import { AgentWorkspaceShell } from '@/components/agent-workspace/agent-workspace-shell';
import { useQuery } from 'convex/react';
import { ArrowLeft } from 'lucide-react'; import { ArrowLeft } from 'lucide-react';
import type { Id } from '@spoon/backend/convex/_generated/dataModel.js'; import type { Id } from '@spoon/backend/convex/_generated/dataModel.js';
import { api } from '@spoon/backend/convex/_generated/api.js';
import { Button } from '@spoon/ui'; import { Button } from '@spoon/ui';
const AgentWorkspacePage = () => { const AgentWorkspacePage = () => {
const router = useRouter();
const params = useParams<{ spoonId: string; jobId: string }>(); const params = useParams<{ spoonId: string; jobId: string }>();
const jobId = params.jobId as Id<'agentJobs'>;
const job = useQuery(api.agentJobs.get, { jobId });
useEffect(() => {
if (job?.threadId) router.replace(`/threads/${job.threadId}`);
}, [job?.threadId, router]);
if (job?.threadId) {
return (
<main className='text-muted-foreground p-6'>
Opening thread workspace...
</main>
);
}
return ( return (
<main className='space-y-4'> <main className='space-y-4'>
@@ -19,7 +37,7 @@ const AgentWorkspacePage = () => {
Back to Spoon Back to Spoon
</Link> </Link>
</Button> </Button>
<AgentWorkspaceShell jobId={params.jobId as Id<'agentJobs'>} /> <AgentWorkspaceShell jobId={jobId} />
</main> </main>
); );
}; };
@@ -2,8 +2,6 @@
import Link from 'next/link'; import Link from 'next/link';
import { useParams } from 'next/navigation'; 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 { SpoonActivityTimeline } from '@/components/spoons/spoon-activity-timeline';
import { SpoonAgentSettingsForm } from '@/components/spoons/spoon-agent-settings-form'; import { SpoonAgentSettingsForm } from '@/components/spoons/spoon-agent-settings-form';
import { SpoonClonePanel } from '@/components/spoons/spoon-clone-panel'; import { SpoonClonePanel } from '@/components/spoons/spoon-clone-panel';
@@ -13,6 +11,7 @@ import { SpoonMetrics } from '@/components/spoons/spoon-metrics';
import { SpoonPrList } from '@/components/spoons/spoon-pr-list'; import { SpoonPrList } from '@/components/spoons/spoon-pr-list';
import { SpoonSecretsForm } from '@/components/spoons/spoon-secrets-form'; import { SpoonSecretsForm } from '@/components/spoons/spoon-secrets-form';
import { SpoonSettingsForm } from '@/components/spoons/spoon-settings-form'; import { SpoonSettingsForm } from '@/components/spoons/spoon-settings-form';
import { ThreadWorkspaceForm } from '@/components/threads/thread-workspace-form';
import { useQuery } from 'convex/react'; import { useQuery } from 'convex/react';
import type { Id } from '@spoon/backend/convex/_generated/dataModel.js'; import type { Id } from '@spoon/backend/convex/_generated/dataModel.js';
@@ -243,7 +242,7 @@ const SpoonDetailPage = () => {
</TabsContent> </TabsContent>
<TabsContent value='threads' className='space-y-4'> <TabsContent value='threads' className='space-y-4'>
<AgentRequestForm <ThreadWorkspaceForm
spoon={details.spoon} spoon={details.spoon}
agentSettings={agentSettings} agentSettings={agentSettings}
/> />
@@ -273,7 +272,6 @@ const SpoonDetailPage = () => {
)} )}
</CardContent> </CardContent>
</Card> </Card>
<AgentJobList jobs={agentJobs} />
</TabsContent> </TabsContent>
<TabsContent value='activity'> <TabsContent value='activity'>
@@ -2,9 +2,16 @@
import { useState } from 'react'; import { useState } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { useParams } from 'next/navigation'; import { useParams, useRouter } from 'next/navigation';
import { AgentWorkspaceShell } from '@/components/agent-workspace/agent-workspace-shell';
import { useMutation, useQuery } from 'convex/react'; import { useMutation, useQuery } from 'convex/react';
import { ArrowUpRight, CheckCircle2, Play, XCircle } from 'lucide-react'; import {
ArrowUpRight,
CheckCircle2,
Play,
Trash2,
XCircle,
} from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import type { Id } from '@spoon/backend/convex/_generated/dataModel.js'; import type { Id } from '@spoon/backend/convex/_generated/dataModel.js';
@@ -16,41 +23,45 @@ import {
CardContent, CardContent,
CardHeader, CardHeader,
CardTitle, CardTitle,
Textarea,
} from '@spoon/ui'; } from '@spoon/ui';
const ThreadDetailPage = () => { const ThreadDetailPage = () => {
const router = useRouter();
const params = useParams<{ threadId: string }>(); const params = useParams<{ threadId: string }>();
const threadId = params.threadId as Id<'threads'>; const threadId = params.threadId as Id<'threads'>;
const details = useQuery(api.threads.get, { threadId }); const details = useQuery(api.threads.get, { threadId });
const messages = useQuery(api.threads.listMessages, { threadId }) ?? [];
const createJob = useMutation(api.agentJobs.createForThread); const createJob = useMutation(api.agentJobs.createForThread);
const markResolved = useMutation(api.threads.markResolved); const markResolved = useMutation(api.threads.markResolved);
const cancel = useMutation(api.threads.cancel); const cancel = useMutation(api.threads.cancel);
const [sending, setSending] = useState(false); const deleteThread = useMutation(api.threads.deleteThread);
const [queueing, setQueueing] = useState(false); const [queueing, setQueueing] = useState(false);
const [deleting, setDeleting] = useState(false);
if (details === undefined) { if (details === undefined) {
return <main className='text-muted-foreground p-6'>Loading thread...</main>; return <main className='text-muted-foreground p-6'>Loading thread...</main>;
} }
const { thread, spoon, latestJob } = details; const { thread, spoon, latestJob } = details;
if (latestJob && spoon) {
return (
<main className='space-y-4'>
<Button asChild variant='ghost' size='sm'>
<Link href={`/spoons/${spoon._id}`}>
<ArrowUpRight className='size-4 rotate-180' />
Back to Spoon
</Link>
</Button>
<AgentWorkspaceShell jobId={latestJob._id} />
</main>
);
}
const terminalThread = [ const terminalThread = [
'resolved', 'resolved',
'ignored', 'ignored',
'failed', 'failed',
'cancelled', 'cancelled',
].includes(thread.status); ].includes(thread.status);
const activeJob =
latestJob &&
[
'claimed',
'preparing',
'running',
'checks_running',
'changes_ready',
].includes(latestJob.status) &&
['active', 'idle'].includes(latestJob.workspaceStatus ?? '');
const canQueueRun = const canQueueRun =
spoon && spoon &&
(!latestJob || (!latestJob ||
@@ -67,40 +78,12 @@ const ThreadDetailPage = () => {
? ('maintenance_review' as const) ? ('maintenance_review' as const)
: ('user_change' as const); : ('user_change' as const);
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 : '';
setSending(true);
try {
const response = await fetch(`/api/threads/${threadId}/message`, {
method: 'POST',
body: JSON.stringify({ content }),
});
if (!response.ok) {
const payload = (await response.json().catch(() => null)) as {
error?: string;
recoverable?: boolean;
} | null;
throw new Error(payload?.error ?? (await response.text()));
}
event.currentTarget.reset();
toast.success(activeJob ? 'Message sent to agent.' : 'Message added.');
} catch (error) {
console.error(error);
toast.error('Could not send message.');
} finally {
setSending(false);
}
};
const startRun = async () => { const startRun = async () => {
setQueueing(true); setQueueing(true);
try { try {
const jobId = await createJob({ threadId, jobType }); await createJob({ threadId, jobType });
toast.success('Workspace run queued.'); toast.success('Workspace run queued.');
window.location.href = `/spoons/${spoon?._id}/agent/${jobId}`; router.replace(`/threads/${threadId}`);
} catch (error) { } catch (error) {
console.error(error); console.error(error);
toast.error('Could not queue workspace run.'); toast.error('Could not queue workspace run.');
@@ -109,6 +92,29 @@ const ThreadDetailPage = () => {
} }
}; };
const removeThread = async () => {
if (
!window.confirm(
'Delete this thread and any terminal workspace records attached to it? This cannot be undone.',
)
) {
return;
}
setDeleting(true);
try {
await deleteThread({ threadId });
toast.success('Thread deleted.');
router.push('/threads');
} catch (error) {
console.error(error);
toast.error(
error instanceof Error ? error.message : 'Could not delete thread.',
);
} finally {
setDeleting(false);
}
};
return ( return (
<main className='space-y-6'> <main className='space-y-6'>
<div className='flex flex-col justify-between gap-4 md:flex-row md:items-start'> <div className='flex flex-col justify-between gap-4 md:flex-row md:items-start'>
@@ -142,11 +148,7 @@ const ThreadDetailPage = () => {
<div className='flex flex-wrap gap-2'> <div className='flex flex-wrap gap-2'>
{latestJob ? ( {latestJob ? (
<Button variant='outline' asChild> <Button variant='outline' asChild>
<Link <Link href={`/threads/${threadId}`}>Open workspace</Link>
href={`/spoons/${latestJob.spoonId}/agent/${latestJob._id}`}
>
Open workspace
</Link>
</Button> </Button>
) : null} ) : null}
{latestJob?.pullRequestUrl ? ( {latestJob?.pullRequestUrl ? (
@@ -194,57 +196,38 @@ const ThreadDetailPage = () => {
</Button> </Button>
</> </>
) : null} ) : null}
<Button
variant='destructive'
disabled={deleting}
onClick={() => void removeThread()}
>
<Trash2 className='size-4' />
{deleting ? 'Deleting...' : 'Delete'}
</Button>
</div> </div>
</div> </div>
<div className='grid gap-6 xl:grid-cols-[1fr_320px]'> <div className='grid gap-6 xl:grid-cols-[1fr_320px]'>
<Card className='shadow-none'> <Card className='shadow-none'>
<CardHeader> <CardHeader>
<CardTitle>Conversation</CardTitle> <CardTitle>Workspace</CardTitle>
</CardHeader> </CardHeader>
<CardContent className='space-y-4'> <CardContent className='space-y-4 text-sm'>
{messages.map((message) => ( <p className='text-muted-foreground'>
<div Threads open into a full workspace where you can review agent
key={message._id} activity, edit files, inspect diffs, and reply to the agent.
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={
activeJob
? 'Send instructions to the active agent workspace.'
: 'Add context or instructions for the next run.'
}
disabled={sending || terminalThread}
/>
<div className='flex flex-wrap items-center gap-2'>
<Button type='submit' disabled={sending || terminalThread}>
{sending
? 'Sending...'
: activeJob
? 'Send to agent'
: 'Add note'}
</Button>
{!activeJob ? (
<p className='text-muted-foreground text-xs'>
No active workspace is attached, so messages are saved as
thread notes until a run is started.
</p> </p>
) : null} {canQueueRun ? (
</div> <Button disabled={queueing} onClick={() => void startRun()}>
</form> <Play className='size-4' />
{latestJob ? 'Create new workspace run' : 'Start workspace run'}
</Button>
) : (
<p className='text-muted-foreground'>
This thread does not currently have a workspace that can be
opened.
</p>
)}
</CardContent> </CardContent>
</Card> </Card>
+73 -5
View File
@@ -4,7 +4,7 @@ import { useState } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { useRouter, useSearchParams } from 'next/navigation'; import { useRouter, useSearchParams } from 'next/navigation';
import { useMutation, useQuery } from 'convex/react'; import { useMutation, useQuery } from 'convex/react';
import { MessageSquare, Plus } from 'lucide-react'; import { MessageSquare, Plus, Trash2 } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import type { Id } from '@spoon/backend/convex/_generated/dataModel.js'; import type { Id } from '@spoon/backend/convex/_generated/dataModel.js';
@@ -43,7 +43,9 @@ const ThreadsPage = () => {
const [materializeEnvFile, setMaterializeEnvFile] = useState(false); const [materializeEnvFile, setMaterializeEnvFile] = useState(false);
const [envFilePath, setEnvFilePath] = useState('.env.local'); const [envFilePath, setEnvFilePath] = useState('.env.local');
const [creating, setCreating] = useState(false); const [creating, setCreating] = useState(false);
const [deletingThreadId, setDeletingThreadId] = useState<string>();
const createThread = useMutation(api.threads.createUserThread); const createThread = useMutation(api.threads.createUserThread);
const deleteThread = useMutation(api.threads.deleteThread);
const spoons = useQuery(api.spoons.listMineWithState, {}) ?? []; const spoons = useQuery(api.spoons.listMineWithState, {}) ?? [];
const profiles = useQuery(api.aiProviderProfiles.listMine, {}) ?? []; const profiles = useQuery(api.aiProviderProfiles.listMine, {}) ?? [];
const defaultProfile = profiles.find((profile) => profile.isDefault); const defaultProfile = profiles.find((profile) => profile.isDefault);
@@ -91,6 +93,20 @@ const ThreadsPage = () => {
router.push(next.size ? `/threads?${next.toString()}` : '/threads'); router.push(next.size ? `/threads?${next.toString()}` : '/threads');
}; };
const threadTarget = (thread: (typeof visibleThreads)[number]) =>
`/threads/${thread._id}`;
const canDeleteThread = (thread: (typeof visibleThreads)[number]) => {
const latestJobStatus = thread.latestJobStatus;
const latestWorkspaceStatus = thread.latestJobWorkspaceStatus;
if (!latestJobStatus && !latestWorkspaceStatus) return true;
return (
['failed', 'cancelled', 'timed_out', 'draft_pr_opened'].includes(
latestJobStatus ?? '',
) ||
['stopped', 'expired', 'failed'].includes(latestWorkspaceStatus ?? '')
);
};
const submitThread = async (event: React.FormEvent<HTMLFormElement>) => { const submitThread = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault(); event.preventDefault();
if (!spoonId || !prompt.trim()) return; if (!spoonId || !prompt.trim()) return;
@@ -113,6 +129,32 @@ const ThreadsPage = () => {
} }
}; };
const removeThread = async (
event: React.MouseEvent<HTMLButtonElement>,
threadId: Id<'threads'>,
) => {
event.stopPropagation();
if (
!window.confirm(
'Delete this thread and any terminal workspace records attached to it? This cannot be undone.',
)
) {
return;
}
setDeletingThreadId(threadId);
try {
await deleteThread({ threadId });
toast.success('Thread deleted.');
} catch (error) {
console.error(error);
toast.error(
error instanceof Error ? error.message : 'Could not delete thread.',
);
} finally {
setDeletingThreadId(undefined);
}
};
return ( return (
<main className='space-y-6'> <main className='space-y-6'>
<div className='flex flex-col justify-between gap-4 md:flex-row md:items-end'> <div className='flex flex-col justify-between gap-4 md:flex-row md:items-end'>
@@ -304,11 +346,11 @@ const ThreadsPage = () => {
role='link' role='link'
tabIndex={0} tabIndex={0}
className='hover:border-primary/50 cursor-pointer shadow-none transition-colors' className='hover:border-primary/50 cursor-pointer shadow-none transition-colors'
onClick={() => router.push(`/threads/${thread._id}`)} onClick={() => router.push(threadTarget(thread))}
onKeyDown={(event) => { onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') { if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault(); event.preventDefault();
router.push(`/threads/${thread._id}`); router.push(threadTarget(thread));
} }
}} }}
> >
@@ -340,14 +382,20 @@ const ThreadsPage = () => {
{thread.latestJobStatus ? ( {thread.latestJobStatus ? (
<p>{thread.latestJobStatus.replaceAll('_', ' ')}</p> <p>{thread.latestJobStatus.replaceAll('_', ' ')}</p>
) : null} ) : null}
{thread.latestJobWorkspaceStatus ? (
<p>
Workspace:{' '}
{thread.latestJobWorkspaceStatus.replaceAll('_', ' ')}
</p>
) : null}
<div className='mt-2 flex justify-start gap-2 md:justify-end'> <div className='mt-2 flex justify-start gap-2 md:justify-end'>
{thread.latestAgentJobId ? ( {thread.latestAgentJobId ? (
<Button size='sm' variant='outline' asChild> <Button size='sm' variant='outline' asChild>
<Link <Link
href={`/spoons/${thread.spoonId}/agent/${thread.latestAgentJobId}`} href={threadTarget(thread)}
onClick={(event) => event.stopPropagation()} onClick={(event) => event.stopPropagation()}
> >
Workspace Open workspace
</Link> </Link>
</Button> </Button>
) : null} ) : null}
@@ -363,6 +411,26 @@ const ThreadsPage = () => {
</a> </a>
</Button> </Button>
) : null} ) : null}
<Button
type='button'
size='sm'
variant='destructive'
disabled={
deletingThreadId === thread._id ||
!canDeleteThread(thread)
}
title={
canDeleteThread(thread)
? 'Delete thread'
: 'Stop or cancel the active workspace before deleting this thread.'
}
onClick={(event) =>
void removeThread(event, thread._id as Id<'threads'>)
}
>
<Trash2 className='size-3' />
Delete
</Button>
</div> </div>
</div> </div>
</CardContent> </CardContent>
@@ -1,30 +1,113 @@
'use client'; 'use client';
import { useState } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
import { Ban, Send } from 'lucide-react'; import {
Ban,
FilePenLine,
MessagesSquare,
Send,
Terminal,
TriangleAlert,
} from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import type { Doc } from '@spoon/backend/convex/_generated/dataModel.js'; import type { Doc } from '@spoon/backend/convex/_generated/dataModel.js';
import { Badge, Button, Textarea } from '@spoon/ui'; import { Badge, Button, Textarea } from '@spoon/ui';
import { extractFileDiff } from './diff-utils';
type ActivityFilter = 'all' | 'chat' | 'activity' | 'files' | 'errors';
const filters: { value: ActivityFilter; label: string }[] = [
{ value: 'all', label: 'All' },
{ value: 'chat', label: 'Chat' },
{ value: 'activity', label: 'Activity' },
{ value: 'files', label: 'Files' },
{ value: 'errors', label: 'Errors' },
];
const formatEventTime = (value: number) =>
new Date(value).toLocaleTimeString([], {
hour: 'numeric',
minute: '2-digit',
second: '2-digit',
});
const eventIcon = (event: Doc<'agentJobEvents'>) => {
if (event.level === 'error') return <TriangleAlert className='size-3' />;
if (event.phase === 'edit') return <FilePenLine className='size-3' />;
if (event.phase === 'check' || event.phase === 'test') {
return <Terminal className='size-3' />;
}
return <MessagesSquare className='size-3' />;
};
export const AgentThread = ({ export const AgentThread = ({
jobId, jobId,
messages, messages,
events, events,
interactions, interactions,
workspaceChanges,
disabled, disabled,
agentTurnActive, agentTurnActive,
onOpenFile,
onOpenDiff,
}: { }: {
jobId: string; jobId: string;
messages: Doc<'agentJobMessages'>[]; messages: Doc<'agentJobMessages'>[];
events: Doc<'agentJobEvents'>[]; events: Doc<'agentJobEvents'>[];
interactions: Doc<'agentInteractionRequests'>[]; interactions: Doc<'agentInteractionRequests'>[];
workspaceChanges: Doc<'agentWorkspaceChanges'>[];
disabled: boolean; disabled: boolean;
agentTurnActive: boolean; agentTurnActive: boolean;
onOpenFile: (path: string) => void;
onOpenDiff: (path: string) => void;
}) => { }) => {
const [content, setContent] = useState(''); const [content, setContent] = useState('');
const [sending, setSending] = useState(false); const [sending, setSending] = useState(false);
const [replying, setReplying] = useState<string>(); const [replying, setReplying] = useState<string>();
const [filter, setFilter] = useState<ActivityFilter>('all');
const scrollRef = useRef<HTMLDivElement>(null);
const failedMessages = useMemo(
() => messages.filter((message) => message.status === 'failed'),
[messages],
);
const visibleMessages =
filter === 'activity' || filter === 'files' || filter === 'errors'
? filter === 'errors'
? failedMessages
: []
: messages;
const visibleEvents =
filter === 'chat' || filter === 'files'
? []
: filter === 'errors'
? events.filter((event) => event.level === 'error')
: events;
const visibleChanges =
filter === 'chat' || filter === 'activity' || filter === 'errors'
? []
: workspaceChanges;
useEffect(() => {
const node = scrollRef.current;
if (!node) return;
const distanceFromBottom =
node.scrollHeight - node.scrollTop - node.clientHeight;
if (distanceFromBottom < 160 || agentTurnActive) {
if (typeof node.scrollTo === 'function') {
node.scrollTo({ top: node.scrollHeight, behavior: 'smooth' });
} else {
node.scrollTop = node.scrollHeight;
}
}
}, [
agentTurnActive,
events.length,
interactions.length,
messages.length,
workspaceChanges.length,
]);
const send = async () => { const send = async () => {
if (!content.trim()) return; if (!content.trim()) return;
@@ -84,10 +167,15 @@ export const AgentThread = ({
}; };
return ( return (
<div className='flex h-full min-h-[520px] flex-col'> <div className='flex h-full min-h-0 flex-col overflow-hidden'>
<div className='border-border flex items-start justify-between gap-3 border-b p-3'> <div className='border-border flex flex-none items-start justify-between gap-3 border-b p-3'>
<div> <div>
<div className='flex items-center gap-2'>
<h2 className='text-sm font-semibold'>Agent thread</h2> <h2 className='text-sm font-semibold'>Agent thread</h2>
{agentTurnActive ? (
<Badge variant='secondary'>Working</Badge>
) : null}
</div>
<p className='text-muted-foreground text-xs'> <p className='text-muted-foreground text-xs'>
Messages, tool activity, and requests persist with this workspace. Messages, tool activity, and requests persist with this workspace.
</p> </p>
@@ -103,8 +191,26 @@ export const AgentThread = ({
Abort Abort
</Button> </Button>
</div> </div>
<div className='min-h-0 flex-1 space-y-3 overflow-auto p-3'> <div className='border-border flex flex-none gap-1 overflow-x-auto border-b px-3 py-2'>
{interactions.map((interaction) => ( {filters.map((item) => (
<Button
key={item.value}
type='button'
variant={filter === item.value ? 'secondary' : 'ghost'}
size='sm'
className='h-7 flex-none text-xs'
onClick={() => setFilter(item.value)}
>
{item.label}
</Button>
))}
</div>
<div
ref={scrollRef}
className='min-h-0 flex-1 space-y-3 overflow-y-auto overscroll-contain p-3'
>
{(filter === 'all' || filter === 'chat') && interactions.length > 0
? interactions.map((interaction) => (
<article <article
key={interaction._id} key={interaction._id}
className='border-primary/40 bg-primary/5 rounded-md border p-3 text-sm' className='border-primary/40 bg-primary/5 rounded-md border p-3 text-sm'
@@ -115,7 +221,9 @@ export const AgentThread = ({
{interaction.status} {interaction.status}
</Badge> </Badge>
</div> </div>
<p className='text-sm whitespace-pre-wrap'>{interaction.body}</p> <p className='text-sm whitespace-pre-wrap'>
{interaction.body}
</p>
{interaction.status === 'pending' ? ( {interaction.status === 'pending' ? (
<div className='mt-3 flex gap-2'> <div className='mt-3 flex gap-2'>
<Button <Button
@@ -138,8 +246,9 @@ export const AgentThread = ({
</div> </div>
) : null} ) : null}
</article> </article>
))} ))
{messages.map((message) => ( : null}
{visibleMessages.map((message) => (
<article <article
key={message._id} key={message._id}
className={ className={
@@ -167,27 +276,107 @@ export const AgentThread = ({
</p> </p>
</article> </article>
))} ))}
{events.slice(-20).map((event) => ( {visibleChanges.map((change) => (
<article <article
key={event._id} key={change._id}
className='border-border text-muted-foreground rounded-md border border-dashed p-2 text-xs' className='border-border bg-background rounded-md border p-3 text-sm'
> >
<div className='flex items-center justify-between gap-2'> <div className='flex items-center justify-between gap-3'>
<span className='font-medium capitalize'> <div className='min-w-0'>
{event.phase} / {event.level} <div className='flex items-center gap-2'>
<FilePenLine className='text-primary size-4 flex-none' />
<span className='truncate font-mono text-xs'>
{change.path}
</span> </span>
<span>{new Date(event.createdAt).toLocaleTimeString()}</span>
</div> </div>
<p className='mt-1 whitespace-pre-wrap'>{event.message}</p> <p className='text-muted-foreground mt-1 text-xs capitalize'>
{change.source} {change.changeType}
</p>
</div>
<div className='flex flex-none items-center gap-2'>
{extractFileDiff(change.diff, change.path) ? (
<Button
type='button'
variant='outline'
size='sm'
onClick={() => onOpenDiff(change.path)}
>
View diff
</Button>
) : null}
{change.path !== '.' ? (
<Button
type='button'
variant='outline'
size='sm'
onClick={() => onOpenFile(change.path)}
>
Open
</Button>
) : null}
</div>
</div>
{extractFileDiff(change.diff, change.path) ? (
<details className='mt-3'>
<summary className='text-muted-foreground cursor-pointer text-xs'>
File diff
</summary>
<pre className='bg-muted mt-2 max-h-72 overflow-auto rounded p-2 text-xs whitespace-pre-wrap'>
{extractFileDiff(change.diff, change.path)}
</pre>
</details>
) : null}
</article> </article>
))} ))}
{visibleEvents.slice(-80).map((event) => (
<article
key={event._id}
className={
event.level === 'error'
? 'border-destructive/40 bg-destructive/5 rounded-md border p-2 text-xs'
: 'border-border text-muted-foreground rounded-md border border-dashed p-2 text-xs'
}
>
<div className='flex items-center justify-between gap-2'>
<span className='flex min-w-0 items-center gap-1 font-medium capitalize'>
{eventIcon(event)}
{event.phase} / {event.level}
</span>
<span>{formatEventTime(event.createdAt)}</span>
</div> </div>
<div className='border-border space-y-2 border-t p-3'> <p className='mt-1 whitespace-pre-wrap'>{event.message}</p>
{event.metadata ? (
<details className='mt-2'>
<summary className='cursor-pointer'>Details</summary>
<pre className='bg-muted mt-1 max-h-40 overflow-auto rounded p-2 whitespace-pre-wrap'>
{event.metadata}
</pre>
</details>
) : null}
</article>
))}
{visibleMessages.length === 0 &&
visibleEvents.length === 0 &&
visibleChanges.length === 0 &&
(filter !== 'chat' || interactions.length === 0) ? (
<p className='text-muted-foreground p-3 text-sm'>
No {filter === 'all' ? 'agent activity' : filter} has been recorded
yet.
</p>
) : null}
</div>
<div className='border-border flex-none space-y-2 border-t p-3'>
<Textarea <Textarea
value={content} value={content}
placeholder='Ask the agent to inspect, explain, or change this fork.' placeholder='Ask the agent to inspect, explain, or change this fork.'
disabled={disabled || sending} disabled={disabled || sending}
onChange={(event) => setContent(event.target.value)} onChange={(event) => setContent(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
void send();
}
}}
/> />
<Button <Button
type='button' type='button'
@@ -1,7 +1,9 @@
'use client'; 'use client';
import type { CSSProperties, PointerEvent as ReactPointerEvent } from 'react';
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { useMutation, useQuery } from 'convex/react'; import { useMutation, useQuery } from 'convex/react';
import { FileCode, GitCompare, MessagesSquare } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import type { Id } from '@spoon/backend/convex/_generated/dataModel.js'; import type { Id } from '@spoon/backend/convex/_generated/dataModel.js';
@@ -33,6 +35,8 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
useQuery(api.agentJobs.listMessages, { jobId, limit: 200 }) ?? []; useQuery(api.agentJobs.listMessages, { jobId, limit: 200 }) ?? [];
const events = const events =
useQuery(api.agentJobs.listEvents, { jobId, limit: 200 }) ?? []; useQuery(api.agentJobs.listEvents, { jobId, limit: 200 }) ?? [];
const workspaceChanges =
useQuery(api.agentJobs.listWorkspaceChanges, { jobId, limit: 200 }) ?? [];
const interactions = const interactions =
useQuery(api.agentJobs.listInteractionRequests, { useQuery(api.agentJobs.listInteractionRequests, {
jobId, jobId,
@@ -50,11 +54,16 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
const [expandedDirectoryPaths, setExpandedDirectoryPaths] = useState< const [expandedDirectoryPaths, setExpandedDirectoryPaths] = useState<
string[] string[]
>([]); >([]);
const [agentThreadWidth, setAgentThreadWidth] = useState(420);
const [vimEnabled, setVimEnabled] = useState(false); const [vimEnabled, setVimEnabled] = useState(false);
const [hydratedUiState, setHydratedUiState] = useState(false); const [hydratedUiState, setHydratedUiState] = useState(false);
const [diff, setDiff] = useState(''); const [diff, setDiff] = useState('');
const [focusedDiffPath, setFocusedDiffPath] = useState<string>();
const [workspaceError, setWorkspaceError] = useState<string>(); const [workspaceError, setWorkspaceError] = useState<string>();
const [agentTurnActive, setAgentTurnActive] = useState(false); const [agentTurnActive, setAgentTurnActive] = useState(false);
const [activeWorkspaceTab, setActiveWorkspaceTab] = useState<
'editor' | 'diff' | 'thread'
>('editor');
const workspaceDisabled = const workspaceDisabled =
!job || !job ||
@@ -177,6 +186,7 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
setOpenFilePaths(uiState.openFilePaths); setOpenFilePaths(uiState.openFilePaths);
setActiveFilePath(uiState.activeFilePath); setActiveFilePath(uiState.activeFilePath);
setExpandedDirectoryPaths(uiState.expandedDirectoryPaths); setExpandedDirectoryPaths(uiState.expandedDirectoryPaths);
setAgentThreadWidth(uiState.agentThreadWidth ?? 420);
setVimEnabled(uiState.vimEnabled); setVimEnabled(uiState.vimEnabled);
setHydratedUiState(true); setHydratedUiState(true);
}, 0); }, 0);
@@ -192,6 +202,7 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
activeFilePath, activeFilePath,
vimEnabled, vimEnabled,
expandedDirectoryPaths, expandedDirectoryPaths,
agentThreadWidth,
}).catch((error: unknown) => { }).catch((error: unknown) => {
console.error(error); console.error(error);
}); });
@@ -200,6 +211,7 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
}, [ }, [
activeFilePath, activeFilePath,
expandedDirectoryPaths, expandedDirectoryPaths,
agentThreadWidth,
hydratedUiState, hydratedUiState,
jobId, jobId,
openFilePaths, openFilePaths,
@@ -230,11 +242,11 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
const activeFile = activeFilePath ? files[activeFilePath] : undefined; const activeFile = activeFilePath ? files[activeFilePath] : undefined;
const recoverWorkspace = async () => { const recoverWorkspace = async () => {
if (!job.threadId) return; if (!job.threadId) return;
const newJobId = await createJobForThread({ await createJobForThread({
threadId: job.threadId, threadId: job.threadId,
jobType: job.jobType ?? 'user_change', jobType: job.jobType ?? 'user_change',
}); });
window.location.href = `/spoons/${job.spoonId}/agent/${newJobId}`; window.location.href = `/threads/${job.threadId}`;
}; };
const deleteStaleWorkspace = async () => { const deleteStaleWorkspace = async () => {
@@ -248,6 +260,33 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
const saveFile = async (content: string) => { const saveFile = async (content: string) => {
if (!activeFilePath) return; if (!activeFilePath) return;
const activeFileBeforeSave = files[activeFilePath];
if (activeFileBeforeSave) {
const latestResponse = await fetch(
`/api/agent-jobs/${jobId}/file?path=${encodeURIComponent(activeFilePath)}`,
);
if (latestResponse.ok) {
const latestData = (await latestResponse.json()) as FileResponse;
if (latestData.content !== activeFileBeforeSave.savedContent) {
const overwrite = window.confirm(
`${activeFilePath} changed in the workspace after you opened it. Overwrite those newer changes with your editor contents?`,
);
if (!overwrite) {
setFiles((current) => ({
...current,
[activeFilePath]: {
...activeFileBeforeSave,
content: latestData.content,
savedContent: latestData.content,
saving: false,
},
}));
toast.info('File reloaded with latest workspace contents.');
return;
}
}
}
}
setFiles((current) => ({ setFiles((current) => ({
...current, ...current,
[activeFilePath]: { [activeFilePath]: {
@@ -325,20 +364,54 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
); );
}; };
const openFileFromActivity = (path: string) => {
openFile(path);
setActiveWorkspaceTab('editor');
};
const openDiffFromActivity = (path: string) => {
setFocusedDiffPath(path);
setActiveWorkspaceTab('diff');
};
const resizeAgentThread = (event: ReactPointerEvent<HTMLDivElement>) => {
event.preventDefault();
const startX = event.clientX;
const startWidth = agentThreadWidth;
const move = (moveEvent: PointerEvent) => {
const nextWidth = Math.min(
Math.max(startWidth - (moveEvent.clientX - startX), 320),
720,
);
setAgentThreadWidth(Math.round(nextWidth));
};
const up = () => {
window.removeEventListener('pointermove', move);
window.removeEventListener('pointerup', up);
};
window.addEventListener('pointermove', move);
window.addEventListener('pointerup', up);
};
return ( return (
<main className='border-border bg-muted/20 flex h-[calc(100vh-8.5rem)] min-h-[720px] flex-col overflow-hidden rounded-md border'> <main className='border-border bg-muted/20 flex h-[calc(100vh-8.5rem)] min-h-[720px] flex-col overflow-hidden rounded-md border'>
<JobStatusBar job={job} /> <JobStatusBar job={job} />
{workspaceError ? ( {workspaceError ? (
<div className='border-border bg-background border-b p-4'> <div className='border-border bg-background border-b p-4'>
<div className='border-destructive/40 bg-destructive/5 rounded-md border p-4'> <div className='border-destructive/40 bg-destructive/5 rounded-md border p-4'>
<p className='font-medium'>Workspace not active on this worker</p> <p className='font-medium'>Thread workspace needs recovery</p>
<p className='text-muted-foreground mt-1 text-sm'> <p className='text-muted-foreground mt-1 text-sm'>
The saved workspace record exists, but this worker cannot reach
its active runtime. This usually happens after a worker restart or
local container cleanup.
</p>
<p className='text-muted-foreground mt-2 text-xs break-all'>
{workspaceError} {workspaceError}
</p> </p>
<div className='mt-3 flex flex-wrap gap-2'> <div className='mt-3 flex flex-wrap gap-2'>
{job.threadId ? ( {job.threadId ? (
<Button type='button' onClick={() => void recoverWorkspace()}> <Button type='button' onClick={() => void recoverWorkspace()}>
Recreate workspace run Start a fresh run
</Button> </Button>
) : null} ) : null}
<Button <Button
@@ -346,7 +419,7 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
variant='outline' variant='outline'
onClick={() => void deleteStaleWorkspace()} onClick={() => void deleteStaleWorkspace()}
> >
Delete stale workspace Delete stale record
</Button> </Button>
{job.threadId ? ( {job.threadId ? (
<Button type='button' variant='outline' asChild> <Button type='button' variant='outline' asChild>
@@ -360,7 +433,14 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
<div className='border-border bg-background flex items-center justify-end border-b px-4 py-2'> <div className='border-border bg-background flex items-center justify-end border-b px-4 py-2'>
<WorkspaceActions job={job} disabled={workspaceDisabled} /> <WorkspaceActions job={job} disabled={workspaceDisabled} />
</div> </div>
<div className='grid min-h-0 flex-1 grid-cols-1 lg:grid-cols-[280px_minmax(0,1fr)] 2xl:grid-cols-[300px_minmax(0,1fr)_420px]'> <div
className='grid min-h-0 flex-1 grid-cols-1 lg:grid-cols-[280px_minmax(0,1fr)] 2xl:grid-cols-[300px_minmax(0,1fr)_6px_var(--agent-thread-width)]'
style={
{
'--agent-thread-width': `${agentThreadWidth}px`,
} as CSSProperties
}
>
<aside className='border-border bg-background min-h-0 border-r'> <aside className='border-border bg-background min-h-0 border-r'>
<div className='border-border border-b p-3'> <div className='border-border border-b p-3'>
<h2 className='text-sm font-semibold'>Files</h2> <h2 className='text-sm font-semibold'>Files</h2>
@@ -374,15 +454,34 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
onToggleDirectory={toggleDirectory} onToggleDirectory={toggleDirectory}
/> />
</aside> </aside>
<section className='bg-background flex min-w-0 flex-col'> <section className='bg-background flex min-w-0 flex-col overflow-hidden'>
<Tabs defaultValue='editor' className='flex min-h-0 flex-1 flex-col'> <Tabs
<TabsList value={activeWorkspaceTab}
variant='line' onValueChange={(value) =>
className='border-border h-11 flex-none justify-start rounded-none border-b px-3' setActiveWorkspaceTab(value as 'editor' | 'diff' | 'thread')
}
className='flex min-h-0 flex-1 flex-col'
> >
<TabsTrigger value='editor'>Editor</TabsTrigger> <TabsList className='border-border bg-muted/30 h-12 flex-none justify-start rounded-none border-b px-3'>
<TabsTrigger value='diff'>Diff</TabsTrigger> <TabsTrigger
<TabsTrigger value='thread' className='2xl:hidden'> value='editor'
className='data-active:bg-background data-active:text-foreground data-active:shadow-sm'
>
<FileCode className='size-4' />
Editor
</TabsTrigger>
<TabsTrigger
value='diff'
className='data-active:bg-background data-active:text-foreground data-active:shadow-sm'
>
<GitCompare className='size-4' />
Diff viewer
</TabsTrigger>
<TabsTrigger
value='thread'
className='data-active:bg-background data-active:text-foreground data-active:shadow-sm 2xl:hidden'
>
<MessagesSquare className='size-4' />
Thread Thread
</TabsTrigger> </TabsTrigger>
</TabsList> </TabsList>
@@ -427,32 +526,50 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
/> />
</TabsContent> </TabsContent>
<TabsContent value='diff' className='m-0 min-h-0 flex-1'> <TabsContent value='diff' className='m-0 min-h-0 flex-1'>
<DiffViewer diff={diff} onRefresh={loadDiff} /> <DiffViewer
diff={diff}
focusedPath={focusedDiffPath}
onRefresh={loadDiff}
onClearFocusedPath={() => setFocusedDiffPath(undefined)}
/>
</TabsContent> </TabsContent>
<TabsContent <TabsContent
value='thread' value='thread'
className='m-0 min-h-0 flex-1 2xl:hidden' className='m-0 min-h-0 flex-1 overflow-hidden 2xl:hidden'
> >
<AgentThread <AgentThread
jobId={jobId} jobId={jobId}
messages={messages} messages={messages}
events={events} events={events}
interactions={interactions} interactions={interactions}
workspaceChanges={workspaceChanges}
disabled={workspaceDisabled} disabled={workspaceDisabled}
agentTurnActive={agentTurnActive} agentTurnActive={agentTurnActive}
onOpenFile={openFileFromActivity}
onOpenDiff={openDiffFromActivity}
/> />
</TabsContent> </TabsContent>
</Tabs> </Tabs>
<CommandPanel jobId={jobId} disabled={workspaceDisabled} /> <CommandPanel jobId={jobId} disabled={workspaceDisabled} />
</section> </section>
<aside className='border-border bg-muted/20 hidden min-w-0 border-l 2xl:block'> <div
role='separator'
aria-label='Resize agent thread'
aria-orientation='vertical'
className='bg-border hover:bg-primary/50 hidden cursor-col-resize transition-colors 2xl:block'
onPointerDown={resizeAgentThread}
/>
<aside className='border-border bg-muted/20 hidden min-h-0 min-w-0 overflow-hidden border-l 2xl:block'>
<AgentThread <AgentThread
jobId={jobId} jobId={jobId}
messages={messages} messages={messages}
events={events} events={events}
interactions={interactions} interactions={interactions}
workspaceChanges={workspaceChanges}
disabled={workspaceDisabled} disabled={workspaceDisabled}
agentTurnActive={agentTurnActive} agentTurnActive={agentTurnActive}
onOpenFile={openFileFromActivity}
onOpenDiff={openDiffFromActivity}
/> />
</aside> </aside>
</div> </div>
@@ -83,8 +83,11 @@ export const CodeEditor = ({
return ( return (
<div className='flex h-full min-h-0 flex-col'> <div className='flex h-full min-h-0 flex-col'>
<div className='border-border flex h-11 items-center justify-between gap-3 border-b px-3'> <div className='border-border flex h-14 items-center justify-between gap-3 border-b px-3'>
<div className='min-w-0'> <div className='min-w-0'>
<p className='text-muted-foreground text-[11px] font-medium tracking-wide uppercase'>
Editor
</p>
<p className='truncate font-mono text-xs'>{path}</p> <p className='truncate font-mono text-xs'>{path}</p>
{dirty ? ( {dirty ? (
<p className='text-muted-foreground text-xs'>Unsaved changes</p> <p className='text-muted-foreground text-xs'>Unsaved changes</p>
@@ -0,0 +1,26 @@
export const extractFileDiff = (diff: string | undefined, filePath: string) => {
if (!diff?.trim() || filePath === '.') return '';
const lines = diff.split('\n');
const sections: string[][] = [];
let current: string[] | null = null;
for (const line of lines) {
if (line.startsWith('diff --git ')) {
if (current) sections.push(current);
current = [line];
continue;
}
current?.push(line);
}
if (current) sections.push(current);
const normalizedPath = filePath.replace(/^\.\/+/, '');
const section = sections.find((item) => {
const header = item[0] ?? '';
return (
header.includes(` a/${normalizedPath} `) ||
header.endsWith(` a/${normalizedPath}`) ||
header.includes(` b/${normalizedPath}`) ||
header.endsWith(` b/${normalizedPath}`)
);
});
return section?.join('\n') ?? '';
};
@@ -4,6 +4,8 @@ import dynamic from 'next/dynamic';
import { Button } from '@spoon/ui'; import { Button } from '@spoon/ui';
import { extractFileDiff } from './diff-utils';
const MonacoEditor = dynamic(async () => await import('@monaco-editor/react'), { const MonacoEditor = dynamic(async () => await import('@monaco-editor/react'), {
ssr: false, ssr: false,
}); });
@@ -22,34 +24,56 @@ const diffStats = (diff: string) => {
export const DiffViewer = ({ export const DiffViewer = ({
diff, diff,
focusedPath,
onRefresh, onRefresh,
onClearFocusedPath,
}: { }: {
diff: string; diff: string;
focusedPath?: string;
onRefresh: () => Promise<void>; onRefresh: () => Promise<void>;
onClearFocusedPath?: () => void;
}) => { }) => {
const stats = diffStats(diff); const focusedDiff = focusedPath ? extractFileDiff(diff, focusedPath) : '';
const visibleDiff = focusedPath ? focusedDiff : diff;
const stats = diffStats(visibleDiff);
return ( return (
<div className='flex h-full min-h-0 flex-col'> <div className='flex h-full min-h-0 flex-col'>
<div className='border-border flex h-12 items-center justify-between gap-3 border-b px-3'> <div className='border-border flex h-12 items-center justify-between gap-3 border-b px-3'>
<div className='min-w-0'> <div className='min-w-0'>
<p className='text-sm font-medium'>Workspace diff</p> <p className='truncate text-sm font-medium'>
{focusedPath ? `Diff viewer: ${focusedPath}` : 'Diff viewer'}
</p>
<p className='text-muted-foreground truncate text-xs'> <p className='text-muted-foreground truncate text-xs'>
{diff.trim() {visibleDiff.trim()
? `${stats.files} files, +${stats.additions} -${stats.removals}` ? `${stats.files} files, +${stats.additions} -${stats.removals}`
: focusedPath
? 'No diff for this file'
: 'Current git diff'} : 'Current git diff'}
</p> </p>
</div> </div>
<div className='flex flex-none items-center gap-2'>
{focusedPath ? (
<Button
type='button'
variant='ghost'
size='sm'
onClick={onClearFocusedPath}
>
Show all
</Button>
) : null}
<Button type='button' variant='outline' size='sm' onClick={onRefresh}> <Button type='button' variant='outline' size='sm' onClick={onRefresh}>
Refresh Refresh
</Button> </Button>
</div> </div>
{diff.trim() ? ( </div>
{visibleDiff.trim() ? (
<MonacoEditor <MonacoEditor
height='100%' height='100%'
width='100%' width='100%'
language='diff' language='diff'
theme='vs-dark' theme='vs-dark'
value={diff} value={visibleDiff}
options={{ options={{
readOnly: true, readOnly: true,
minimap: { enabled: false }, minimap: { enabled: false },
@@ -61,7 +85,9 @@ export const DiffViewer = ({
/> />
) : ( ) : (
<div className='text-muted-foreground flex flex-1 items-center justify-center text-sm'> <div className='text-muted-foreground flex flex-1 items-center justify-center text-sm'>
No workspace diff yet. {focusedPath
? 'No diff is recorded for this file yet.'
: 'No workspace diff yet.'}
</div> </div>
)} )}
</div> </div>
@@ -23,6 +23,7 @@ export const WorkspaceActions = ({
}) => { }) => {
const router = useRouter(); const router = useRouter();
const deleteWorkspace = useMutation(api.agentJobs.deleteWorkspace); const deleteWorkspace = useMutation(api.agentJobs.deleteWorkspace);
const deleteThread = useMutation(api.threads.deleteThread);
const canDelete = const canDelete =
['failed', 'cancelled', 'timed_out'].includes(job.status) || ['failed', 'cancelled', 'timed_out'].includes(job.status) ||
['stopped', 'expired', 'failed'].includes(job.workspaceStatus ?? ''); ['stopped', 'expired', 'failed'].includes(job.workspaceStatus ?? '');
@@ -58,6 +59,27 @@ export const WorkspaceActions = ({
} }
}; };
const removeThread = async () => {
if (!job.threadId) return;
if (
!window.confirm(
'Delete this thread and any terminal workspace records attached to it? This cannot be undone.',
)
) {
return;
}
try {
await deleteThread({ threadId: job.threadId });
toast.success('Thread deleted.');
router.push('/threads');
} catch (error) {
console.error(error);
toast.error(
error instanceof Error ? error.message : 'Could not delete thread.',
);
}
};
const stop = async () => { const stop = async () => {
try { try {
const response = await fetch(`/api/agent-jobs/${job._id}/stop`, { const response = await fetch(`/api/agent-jobs/${job._id}/stop`, {
@@ -96,10 +118,23 @@ export const WorkspaceActions = ({
Stop Stop
</Button> </Button>
{canDelete ? ( {canDelete ? (
<Button type='button' variant='destructive' size='sm' onClick={remove}> <>
{job.threadId ? (
<Button
type='button'
variant='destructive'
size='sm'
onClick={removeThread}
>
<Trash2 className='size-4' />
Delete thread
</Button>
) : null}
<Button type='button' variant='outline' size='sm' onClick={remove}>
<Trash2 className='size-4' /> <Trash2 className='size-4' />
Delete workspace Delete workspace
</Button> </Button>
</>
) : null} ) : null}
</div> </div>
); );
@@ -1,51 +0,0 @@
'use client';
import { Copy } from 'lucide-react';
import { toast } from 'sonner';
import type { Doc } from '@spoon/backend/convex/_generated/dataModel.js';
import { Button } from '@spoon/ui';
export const AgentArtifactViewer = ({
artifacts,
}: {
artifacts: Doc<'agentJobArtifacts'>[];
}) => {
if (!artifacts.length) {
return (
<p className='text-muted-foreground text-sm'>
No artifacts captured yet.
</p>
);
}
return (
<div className='space-y-3'>
{artifacts.map((artifact) => (
<section key={artifact._id} className='border-border rounded-md border'>
<div className='flex items-center justify-between gap-3 border-b p-3'>
<div>
<h3 className='text-sm font-semibold'>{artifact.title}</h3>
<p className='text-muted-foreground text-xs'>{artifact.kind}</p>
</div>
<Button
type='button'
variant='outline'
size='icon'
aria-label='Copy artifact'
onClick={async () => {
await navigator.clipboard.writeText(artifact.content);
toast.success('Artifact copied.');
}}
>
<Copy className='size-4' />
</Button>
</div>
<pre className='bg-muted/40 max-h-96 overflow-auto p-3 text-xs whitespace-pre-wrap'>
{artifact.content}
</pre>
</section>
))}
</div>
);
};
@@ -1,47 +0,0 @@
'use client';
import type { Doc } from '@spoon/backend/convex/_generated/dataModel.js';
const formatTime = (value: number) =>
new Intl.DateTimeFormat(undefined, {
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
}).format(value);
export const AgentEventLog = ({
events,
}: {
events: Doc<'agentJobEvents'>[];
}) => {
if (!events.length) {
return (
<p className='text-muted-foreground text-sm'>No worker events yet.</p>
);
}
return (
<div className='divide-border overflow-hidden rounded-md border'>
{events.map((event) => (
<div key={event._id} className='grid gap-1 border-b p-3 text-sm'>
<div className='flex flex-wrap items-center gap-2'>
<span className='font-mono text-xs uppercase'>{event.phase}</span>
<span className='text-muted-foreground text-xs'>
{formatTime(event.createdAt)}
</span>
<span className='text-muted-foreground text-xs capitalize'>
{event.level}
</span>
</div>
<p className='whitespace-pre-wrap'>{event.message}</p>
{event.metadata ? (
<pre className='bg-muted overflow-auto rounded p-2 text-xs'>
{event.metadata}
</pre>
) : null}
</div>
))}
</div>
);
};
@@ -1,66 +0,0 @@
'use client';
import { useQuery } from 'convex/react';
import type { Doc } from '@spoon/backend/convex/_generated/dataModel.js';
import { api } from '@spoon/backend/convex/_generated/api.js';
import { Card, CardContent, CardHeader, CardTitle } from '@spoon/ui';
import { AgentArtifactViewer } from './agent-artifact-viewer';
import { AgentEventLog } from './agent-event-log';
export const AgentJobDetail = ({ job }: { job: Doc<'agentJobs'> }) => {
const events =
useQuery(api.agentJobs.listEvents, { jobId: job._id, limit: 200 }) ?? [];
const artifacts =
useQuery(api.agentJobs.listArtifacts, { jobId: job._id }) ?? [];
return (
<Card className='shadow-none'>
<CardHeader className='pb-3'>
<CardTitle className='text-base'>Job details</CardTitle>
</CardHeader>
<CardContent className='space-y-5'>
<div className='grid gap-3 text-sm md:grid-cols-3'>
<div>
<p className='text-muted-foreground text-xs'>Status</p>
<p className='font-medium capitalize'>
{job.status.replaceAll('_', ' ')}
</p>
</div>
<div>
<p className='text-muted-foreground text-xs'>Branch</p>
<p className='font-mono text-xs'>{job.workBranch}</p>
</div>
<div>
<p className='text-muted-foreground text-xs'>Model</p>
<p className='font-medium'>{job.model}</p>
</div>
</div>
{job.pullRequestUrl ? (
<a
href={job.pullRequestUrl}
target='_blank'
rel='noreferrer'
className='text-primary text-sm font-medium underline-offset-4 hover:underline'
>
Open draft PR #{job.pullRequestNumber}
</a>
) : null}
{job.error ? (
<pre className='border-destructive bg-destructive/5 text-destructive overflow-auto rounded-md border p-3 text-xs whitespace-pre-wrap'>
{job.error}
</pre>
) : null}
<section className='space-y-2'>
<h3 className='text-sm font-semibold'>Events</h3>
<AgentEventLog events={events} />
</section>
<section className='space-y-2'>
<h3 className='text-sm font-semibold'>Artifacts</h3>
<AgentArtifactViewer artifacts={artifacts} />
</section>
</CardContent>
</Card>
);
};
@@ -1,151 +0,0 @@
'use client';
import { useState } from 'react';
import Link from 'next/link';
import { useMutation } from 'convex/react';
import { ExternalLink, MonitorUp, Trash2, XCircle } from 'lucide-react';
import { toast } from 'sonner';
import type { Doc } from '@spoon/backend/convex/_generated/dataModel.js';
import { api } from '@spoon/backend/convex/_generated/api.js';
import { Badge, Button } from '@spoon/ui';
import { AgentJobDetail } from './agent-job-detail';
const formatTime = (value: number) =>
new Intl.DateTimeFormat(undefined, {
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
}).format(value);
export const AgentJobList = ({ jobs }: { jobs: Doc<'agentJobs'>[] }) => {
const cancel = useMutation(api.agentJobs.cancel);
const deleteWorkspace = useMutation(api.agentJobs.deleteWorkspace);
const [selectedJobId, setSelectedJobId] = useState<string | null>(
jobs[0]?._id ?? null,
);
const selectedJob = jobs.find((job) => job._id === selectedJobId) ?? jobs[0];
const selectedJobCanDelete = selectedJob
? ['failed', 'cancelled', 'timed_out'].includes(selectedJob.status) ||
['stopped', 'expired', 'failed'].includes(
selectedJob.workspaceStatus ?? '',
)
: false;
if (!jobs.length) {
return (
<div className='border-border rounded-md border p-5'>
<h3 className='text-sm font-semibold'>No agent jobs yet</h3>
<p className='text-muted-foreground mt-1 text-sm'>
Queue a job to have Spoon open a draft PR against this fork.
</p>
</div>
);
}
return (
<div className='grid gap-4 xl:grid-cols-[0.85fr_1.15fr]'>
<div className='divide-border overflow-hidden rounded-md border'>
{jobs.map((job) => (
<button
key={job._id}
type='button'
className='hover:bg-muted/40 data-[selected=true]:bg-muted/60 block w-full border-b p-3 text-left'
data-selected={job._id === selectedJob?._id}
onClick={() => setSelectedJobId(job._id)}
>
<div className='flex items-start justify-between gap-3'>
<div className='min-w-0'>
<p className='truncate text-sm font-medium'>{job.prompt}</p>
<p className='text-muted-foreground mt-1 font-mono text-xs'>
{job.workBranch}
</p>
</div>
<Badge variant='outline' className='capitalize'>
{job.status.replaceAll('_', ' ')}
</Badge>
</div>
<div className='text-muted-foreground mt-2 flex flex-wrap gap-2 text-xs'>
<span>{formatTime(job.createdAt)}</span>
{job.pullRequestUrl ? (
<a
href={job.pullRequestUrl}
target='_blank'
rel='noreferrer'
className='text-primary inline-flex items-center gap-1'
>
PR <ExternalLink className='size-3' />
</a>
) : null}
</div>
</button>
))}
</div>
{selectedJob ? (
<div className='space-y-3'>
{[
'queued',
'claimed',
'preparing',
'running',
'checks_running',
].includes(selectedJob.status) ? (
<Button
type='button'
variant='outline'
onClick={async () => {
try {
await cancel({ jobId: selectedJob._id });
toast.success('Agent job cancelled.');
} catch (error) {
console.error(error);
toast.error('Could not cancel job.');
}
}}
>
<XCircle className='size-4' />
Cancel job
</Button>
) : null}
<Button asChild>
<Link
href={`/spoons/${selectedJob.spoonId}/agent/${selectedJob._id}`}
>
<MonitorUp className='size-4' />
Open workspace
</Link>
</Button>
{selectedJobCanDelete ? (
<Button
type='button'
variant='destructive'
onClick={async () => {
if (
!window.confirm(
'Delete this workspace and its messages, events, artifacts, diffs, and UI state? This cannot be undone.',
)
) {
return;
}
try {
await deleteWorkspace({ jobId: selectedJob._id });
toast.success('Workspace deleted.');
setSelectedJobId(null);
} catch (error) {
console.error(error);
toast.error('Could not delete workspace.');
}
}}
>
<Trash2 className='size-4' />
Delete workspace
</Button>
) : null}
<AgentJobDetail job={selectedJob} />
</div>
) : null}
</div>
);
};
@@ -5,7 +5,9 @@ import { usePathname } from 'next/navigation';
export const AppShell = ({ children }: { children: ReactNode }) => { export const AppShell = ({ children }: { children: ReactNode }) => {
const pathname = usePathname(); const pathname = usePathname();
const isWorkspace = /\/spoons\/[^/]+\/agent\/[^/]+/.test(pathname); const isWorkspace =
/\/spoons\/[^/]+\/agent\/[^/]+/.test(pathname) ||
/^\/threads\/[^/]+/.test(pathname);
return ( return (
<div className='bg-muted/20 flex-1 border-t'> <div className='bg-muted/20 flex-1 border-t'>
@@ -176,7 +176,7 @@ export const SpoonAgentSettingsForm = ({
</CardHeader> </CardHeader>
<CardContent className='space-y-4'> <CardContent className='space-y-4'>
<div className='flex items-center justify-between gap-4'> <div className='flex items-center justify-between gap-4'>
<Label htmlFor='agentEnabled'>Enable agent jobs</Label> <Label htmlFor='agentEnabled'>Enable thread workspaces</Label>
<Switch <Switch
id='agentEnabled' id='agentEnabled'
checked={enabled} checked={enabled}
@@ -1,8 +1,9 @@
'use client'; 'use client';
import { useState } from 'react'; import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { useMutation, useQuery } from 'convex/react'; import { useMutation, useQuery } from 'convex/react';
import { Bot } from 'lucide-react'; import { MessageSquarePlus } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import type { Doc, Id } from '@spoon/backend/convex/_generated/dataModel.js'; import type { Doc, Id } from '@spoon/backend/convex/_generated/dataModel.js';
@@ -35,13 +36,14 @@ type AgentSettings = {
aiProviderProfileId?: Id<'aiProviderProfiles'>; aiProviderProfileId?: Id<'aiProviderProfiles'>;
}; };
export const AgentRequestForm = ({ export const ThreadWorkspaceForm = ({
spoon, spoon,
agentSettings, agentSettings,
}: { }: {
spoon: Doc<'spoons'>; spoon: Doc<'spoons'>;
agentSettings?: AgentSettings | null; agentSettings?: AgentSettings | null;
}) => { }) => {
const router = useRouter();
const secrets = const secrets =
useQuery(api.spoonSecrets.listForSpoon, { useQuery(api.spoonSecrets.listForSpoon, {
spoonId: spoon._id, spoonId: spoon._id,
@@ -90,7 +92,7 @@ export const AgentRequestForm = ({
event.preventDefault(); event.preventDefault();
setSubmitting(true); setSubmitting(true);
try { try {
await createThread({ const threadId = await createThread({
spoonId: spoon._id, spoonId: spoon._id,
prompt, prompt,
baseBranch, baseBranch,
@@ -105,9 +107,10 @@ export const AgentRequestForm = ({
setPrompt(''); setPrompt('');
setRequestedBranchName(''); setRequestedBranchName('');
toast.success('Thread created.'); toast.success('Thread created.');
router.push(`/threads/${threadId}`);
} catch (error) { } catch (error) {
console.error(error); console.error(error);
toast.error('Could not queue agent job.'); toast.error('Could not create thread workspace.');
} finally { } finally {
setSubmitting(false); setSubmitting(false);
} }
@@ -117,16 +120,16 @@ export const AgentRequestForm = ({
<Card className='shadow-none'> <Card className='shadow-none'>
<CardHeader className='pb-3'> <CardHeader className='pb-3'>
<CardTitle className='flex items-center gap-2 text-base'> <CardTitle className='flex items-center gap-2 text-base'>
<Bot className='size-4' /> <MessageSquarePlus className='size-4' />
Request agent work Create thread workspace
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<form onSubmit={submit} className='space-y-4'> <form onSubmit={submit} className='space-y-4'>
<div className='grid gap-2'> <div className='grid gap-2'>
<Label htmlFor='agentPrompt'>Prompt</Label> <Label htmlFor='threadPrompt'>Prompt</Label>
<Textarea <Textarea
id='agentPrompt' id='threadPrompt'
required required
minLength={12} minLength={12}
value={prompt} value={prompt}
+110 -3
View File
@@ -1,16 +1,27 @@
import { render, screen } from '@testing-library/react'; import { fireEvent, render, screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest'; import { describe, expect, it, vi } from 'vitest';
import ThreadDetailPage from '../../src/app/(app)/threads/[threadId]/page';
import { AgentThread } from '../../src/components/agent-workspace/agent-thread';
import { extractFileDiff } from '../../src/components/agent-workspace/diff-utils';
import { Hero } from '../../src/components/landing'; import { Hero } from '../../src/components/landing';
import { NewSpoonForm } from '../../src/components/spoons/new-spoon-form'; import { NewSpoonForm } from '../../src/components/spoons/new-spoon-form';
const { mockUseMutation, mockUseParams, mockUseQuery } = vi.hoisted(() => ({
mockUseMutation: vi.fn(),
mockUseParams: vi.fn(),
mockUseQuery: vi.fn(),
}));
vi.mock('convex/react', () => ({ vi.mock('convex/react', () => ({
useConvexAuth: () => ({ isAuthenticated: false }), useConvexAuth: () => ({ isAuthenticated: false }),
useMutation: () => vi.fn(), useMutation: mockUseMutation,
useQuery: mockUseQuery,
})); }));
vi.mock('next/navigation', () => ({ vi.mock('next/navigation', () => ({
useRouter: () => ({ push: vi.fn() }), useParams: mockUseParams,
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
})); }));
vi.mock('sonner', () => ({ vi.mock('sonner', () => ({
@@ -20,6 +31,12 @@ vi.mock('sonner', () => ({
}, },
})); }));
vi.mock('@/components/agent-workspace/agent-workspace-shell', () => ({
AgentWorkspaceShell: ({ jobId }: { jobId: string }) => (
<div>workspace shell {jobId}</div>
),
}));
describe('component test harness', () => { describe('component test harness', () => {
it('renders the Spoon landing headline', () => { it('renders the Spoon landing headline', () => {
render(<Hero />); render(<Hero />);
@@ -36,4 +53,94 @@ describe('component test harness', () => {
expect(screen.getByLabelText(/upstream owner/i)).toBeInTheDocument(); expect(screen.getByLabelText(/upstream owner/i)).toBeInTheDocument();
expect(screen.getByLabelText(/upstream repository/i)).toBeInTheDocument(); expect(screen.getByLabelText(/upstream repository/i)).toBeInTheDocument();
}); });
it('extracts a single file diff from a workspace diff', () => {
const diff = [
'diff --git a/apps/web/auth.ts b/apps/web/auth.ts',
'index 123..456 100644',
'--- a/apps/web/auth.ts',
'+++ b/apps/web/auth.ts',
'@@ -1 +1 @@',
'-github',
'+authentik',
'diff --git a/README.md b/README.md',
'--- a/README.md',
'+++ b/README.md',
'@@ -1 +1 @@',
'-old',
'+new',
].join('\n');
expect(extractFileDiff(diff, 'apps/web/auth.ts')).toContain('+authentik');
expect(extractFileDiff(diff, 'apps/web/auth.ts')).not.toContain(
'README.md',
);
});
it('renders workspace file activity and opens changed files', () => {
const onOpenFile = vi.fn();
const onOpenDiff = vi.fn();
render(
<AgentThread
jobId='job-1'
messages={[]}
events={[]}
interactions={[]}
workspaceChanges={[
{
_id: 'change-1',
_creationTime: 1,
jobId: 'job-1',
spoonId: 'spoon-1',
ownerId: 'user-1',
path: 'apps/web/auth.ts',
source: 'agent',
changeType: 'modified',
diff: 'diff --git a/apps/web/auth.ts b/apps/web/auth.ts\n+authentik',
createdAt: 1,
} as never,
]}
disabled={false}
agentTurnActive={false}
onOpenFile={onOpenFile}
onOpenDiff={onOpenDiff}
/>,
);
fireEvent.click(screen.getByRole('button', { name: 'Files' }));
expect(screen.getByText('apps/web/auth.ts')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'View diff' }));
expect(onOpenDiff).toHaveBeenCalledWith('apps/web/auth.ts');
fireEvent.click(screen.getByRole('button', { name: 'Open' }));
expect(onOpenFile).toHaveBeenCalledWith('apps/web/auth.ts');
});
it('renders thread workspaces on the canonical thread route', () => {
mockUseParams.mockReturnValue({ threadId: 'thread-1' });
mockUseQuery.mockReturnValue({
thread: {
_id: 'thread-1',
title: 'Update auth',
status: 'running',
source: 'user_request',
priority: 'normal',
summary: 'Use Authentik',
createdAt: 1,
updatedAt: 1,
},
spoon: { _id: 'spoon-1', name: 'useSend' },
latestJob: {
_id: 'job-1',
spoonId: 'spoon-1',
status: 'running',
workspaceStatus: 'active',
},
});
mockUseMutation.mockReturnValue(vi.fn());
render(<ThreadDetailPage />);
expect(screen.getByText('workspace shell job-1')).toBeInTheDocument();
expect(screen.queryByText('Thread state')).not.toBeInTheDocument();
});
}); });
+23 -1
View File
@@ -1,13 +1,35 @@
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { defineConfig } from 'vitest/config'; import { defineConfig } from 'vitest/config';
import { jsdomProject, nodeProject } from '@spoon/vitest-config'; import { jsdomProject, nodeProject } from '@spoon/vitest-config';
const dirname = path.dirname(fileURLToPath(import.meta.url));
const srcAlias = path.join(dirname, 'src');
const withNextAlias = <T extends object>(project: T) => ({
...project,
resolve: {
alias: {
'@': srcAlias,
},
},
});
export default defineConfig({ export default defineConfig({
resolve: {
alias: {
'@': srcAlias,
},
},
test: { test: {
projects: [ projects: [
nodeProject('unit', ['tests/unit/**/*.test.{ts,tsx}']), withNextAlias(nodeProject('unit', ['tests/unit/**/*.test.{ts,tsx}'])),
withNextAlias(
nodeProject('integration', ['tests/integration/**/*.test.{ts,tsx}']), nodeProject('integration', ['tests/integration/**/*.test.{ts,tsx}']),
),
withNextAlias(
jsdomProject('component', ['tests/component/**/*.test.{ts,tsx}']), jsdomProject('component', ['tests/component/**/*.test.{ts,tsx}']),
),
], ],
}, },
}); });
+61 -1
View File
@@ -160,6 +160,27 @@ const requireWorkerToken = (workerToken: string) => {
if (workerToken !== expected) throw new ConvexError('Invalid worker token.'); if (workerToken !== expected) throw new ConvexError('Invalid worker token.');
}; };
const mergeMessageMetadata = (
metadata: string | undefined,
patch: Record<string, unknown>,
) => {
if (!metadata) return JSON.stringify(patch);
try {
return JSON.stringify({ ...(JSON.parse(metadata) as object), ...patch });
} catch {
return JSON.stringify({ note: metadata, ...patch });
}
};
const parseMessageMetadata = (metadata: string | undefined) => {
if (!metadata) return null;
try {
return JSON.parse(metadata) as Record<string, unknown>;
} catch {
return null;
}
};
const slugify = (value: string) => const slugify = (value: string) =>
value value
.toLowerCase() .toLowerCase()
@@ -736,6 +757,7 @@ export const getWorkspaceUiState = query({
activeFilePath: undefined, activeFilePath: undefined,
vimEnabled: false, vimEnabled: false,
expandedDirectoryPaths: [], expandedDirectoryPaths: [],
agentThreadWidth: 420,
createdAt: Date.now(), createdAt: Date.now(),
updatedAt: Date.now(), updatedAt: Date.now(),
} }
@@ -750,6 +772,7 @@ export const patchWorkspaceUiState = mutation({
activeFilePath: v.optional(v.string()), activeFilePath: v.optional(v.string()),
vimEnabled: v.optional(v.boolean()), vimEnabled: v.optional(v.boolean()),
expandedDirectoryPaths: v.optional(v.array(v.string())), expandedDirectoryPaths: v.optional(v.array(v.string())),
agentThreadWidth: v.optional(v.number()),
}, },
handler: async (ctx, args) => { handler: async (ctx, args) => {
const ownerId = await getRequiredUserId(ctx); const ownerId = await getRequiredUserId(ctx);
@@ -780,6 +803,14 @@ export const patchWorkspaceUiState = mutation({
), ),
} }
: {}), : {}),
...(args.agentThreadWidth !== undefined
? {
agentThreadWidth: Math.min(
Math.max(Math.round(args.agentThreadWidth), 320),
720,
),
}
: {}),
updatedAt: now, updatedAt: now,
}; };
if (existing) { if (existing) {
@@ -794,6 +825,7 @@ export const patchWorkspaceUiState = mutation({
activeFilePath: patch.activeFilePath, activeFilePath: patch.activeFilePath,
vimEnabled: patch.vimEnabled ?? false, vimEnabled: patch.vimEnabled ?? false,
expandedDirectoryPaths: patch.expandedDirectoryPaths ?? [], expandedDirectoryPaths: patch.expandedDirectoryPaths ?? [],
agentThreadWidth: patch.agentThreadWidth ?? 420,
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
}); });
@@ -1537,7 +1569,9 @@ export const appendMessage = mutation({
role: args.role, role: args.role,
content: args.content, content: args.content,
status: args.status, status: args.status,
metadata: args.metadata, metadata: mergeMessageMetadata(args.metadata, {
agentJobMessageId: messageId,
}),
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
}); });
@@ -1570,6 +1604,32 @@ export const updateMessage = mutation({
if (args.status !== undefined) patch.status = args.status; if (args.status !== undefined) patch.status = args.status;
if (args.metadata !== undefined) patch.metadata = args.metadata; if (args.metadata !== undefined) patch.metadata = args.metadata;
await ctx.db.patch(args.messageId, patch); await ctx.db.patch(args.messageId, patch);
const threadId = job.threadId;
if (threadId) {
const threadMessages = await ctx.db
.query('threadMessages')
.withIndex('by_thread', (q) => q.eq('threadId', threadId))
.order('desc')
.take(300);
const mirrored = threadMessages.find(
(threadMessage) =>
parseMessageMetadata(threadMessage.metadata)?.agentJobMessageId ===
args.messageId,
);
if (mirrored) {
const threadPatch: Partial<Doc<'threadMessages'>> = {
updatedAt: patch.updatedAt,
};
if (args.content !== undefined) threadPatch.content = args.content;
if (args.status !== undefined) threadPatch.status = args.status;
if (args.metadata !== undefined) {
threadPatch.metadata = mergeMessageMetadata(args.metadata, {
agentJobMessageId: args.messageId,
});
}
await ctx.db.patch(mirrored._id, threadPatch);
}
}
return { success: true }; return { success: true };
}, },
}); });
+3
View File
@@ -444,6 +444,7 @@ const applicationTables = {
spoonId: v.id('spoons'), spoonId: v.id('spoons'),
ownerId: v.id('users'), ownerId: v.id('users'),
enabled: v.boolean(), enabled: v.boolean(),
// Legacy records may contain openai_direct. New writes use opencode only.
runtime: v.optional( runtime: v.optional(
v.union(v.literal('opencode'), v.literal('openai_direct')), v.union(v.literal('opencode'), v.literal('openai_direct')),
), ),
@@ -507,6 +508,7 @@ const applicationTables = {
v.literal('timed_out'), v.literal('timed_out'),
), ),
prompt: v.string(), prompt: v.string(),
// Legacy jobs may contain openai_direct. New jobs use opencode only.
runtime: v.optional( runtime: v.optional(
v.union(v.literal('openai_direct'), v.literal('opencode')), v.union(v.literal('openai_direct'), v.literal('opencode')),
), ),
@@ -603,6 +605,7 @@ const applicationTables = {
activeFilePath: v.optional(v.string()), activeFilePath: v.optional(v.string()),
vimEnabled: v.boolean(), vimEnabled: v.boolean(),
expandedDirectoryPaths: v.array(v.string()), expandedDirectoryPaths: v.array(v.string()),
agentThreadWidth: v.optional(v.number()),
createdAt: v.number(), createdAt: v.number(),
updatedAt: v.number(), updatedAt: v.number(),
}) })
+84
View File
@@ -1,6 +1,7 @@
import { ConvexError, v } from 'convex/values'; import { ConvexError, v } from 'convex/values';
import type { Doc } from './_generated/dataModel'; import type { Doc } from './_generated/dataModel';
import type { MutationCtx } from './_generated/server';
import { internal } from './_generated/api'; import { internal } from './_generated/api';
import { import {
internalMutation, internalMutation,
@@ -68,6 +69,53 @@ const titleFromPrompt = (prompt: string) => {
const publicThread = (thread: Doc<'threads'>) => thread; const publicThread = (thread: Doc<'threads'>) => thread;
const isDeletableThreadJob = (job: Doc<'agentJobs'>) =>
['failed', 'cancelled', 'timed_out', 'draft_pr_opened'].includes(
job.status,
) || ['stopped', 'expired', 'failed'].includes(job.workspaceStatus ?? '');
const deleteThreadJobRows = async (ctx: MutationCtx, job: Doc<'agentJobs'>) => {
const [messages, events, artifacts, changes, uiStates, interactions] =
await Promise.all([
ctx.db
.query('agentJobMessages')
.withIndex('by_job', (q) => q.eq('jobId', job._id))
.collect(),
ctx.db
.query('agentJobEvents')
.withIndex('by_job', (q) => q.eq('jobId', job._id))
.collect(),
ctx.db
.query('agentJobArtifacts')
.withIndex('by_job', (q) => q.eq('jobId', job._id))
.collect(),
ctx.db
.query('agentWorkspaceChanges')
.withIndex('by_job', (q) => q.eq('jobId', job._id))
.collect(),
ctx.db
.query('agentWorkspaceUiStates')
.withIndex('by_job', (q) => q.eq('jobId', job._id))
.collect(),
ctx.db
.query('agentInteractionRequests')
.withIndex('by_job', (q) => q.eq('jobId', job._id))
.collect(),
]);
for (const row of [
...messages,
...events,
...artifacts,
...changes,
...uiStates,
...interactions,
]) {
await ctx.db.delete(row._id);
}
await ctx.db.delete(job._id);
};
export const listMine = query({ export const listMine = query({
args: { args: {
status: v.optional(v.union(threadStatus, v.literal('all'))), status: v.optional(v.union(threadStatus, v.literal('all'))),
@@ -275,6 +323,42 @@ export const markResolved = mutation({
}, },
}); });
export const deleteThread = mutation({
args: { threadId: v.id('threads') },
handler: async (ctx, { threadId }) => {
const ownerId = await getRequiredUserId(ctx);
const thread = await ctx.db.get(threadId);
if (thread?.ownerId !== ownerId) throw new ConvexError('Thread not found.');
const jobs = (
await ctx.db
.query('agentJobs')
.withIndex('by_owner', (q) => q.eq('ownerId', ownerId))
.collect()
).filter((job) => job.threadId === threadId);
const activeJob = jobs.find((job) => !isDeletableThreadJob(job));
if (activeJob) {
throw new ConvexError(
'Stop or cancel active workspace runs before deleting this thread.',
);
}
const messages = await ctx.db
.query('threadMessages')
.withIndex('by_thread', (q) => q.eq('threadId', threadId))
.collect();
for (const job of jobs) {
await deleteThreadJobRows(ctx, job);
}
for (const message of messages) {
await ctx.db.delete(message._id);
}
await ctx.db.delete(threadId);
return { deletedJobs: jobs.length, deletedMessages: messages.length };
},
});
export const findOpenMaintenanceThread = internalQuery({ export const findOpenMaintenanceThread = internalQuery({
args: { args: {
spoonId: v.id('spoons'), spoonId: v.id('spoons'),
+122
View File
@@ -48,6 +48,7 @@ const createAgentJob = async (
spoonId: Id<'spoons'>; spoonId: Id<'spoons'>;
status: 'running' | 'failed' | 'cancelled'; status: 'running' | 'failed' | 'cancelled';
workspaceStatus?: 'active' | 'stopped' | 'failed' | 'expired'; workspaceStatus?: 'active' | 'stopped' | 'failed' | 'expired';
threadId?: Id<'threads'>;
}, },
) => ) =>
await t.mutation(async (ctx) => { await t.mutation(async (ctx) => {
@@ -64,6 +65,7 @@ const createAgentJob = async (
spoonId: args.spoonId, spoonId: args.spoonId,
ownerId: args.ownerId, ownerId: args.ownerId,
agentRequestId: requestId, agentRequestId: requestId,
threadId: args.threadId,
status: args.status, status: args.status,
prompt: 'Clean this workspace', prompt: 'Clean this workspace',
runtime: 'opencode', runtime: 'opencode',
@@ -299,6 +301,126 @@ describe('convex-test harness', () => {
).rejects.toThrow('Agent job not found.'); ).rejects.toThrow('Agent job not found.');
}); });
test('persists and clamps workspace agent thread width', async () => {
const t = convexTest(schema, modules);
const ownerId = (await createUser(t, 'owner@example.com')) as Id<'users'>;
const spoonId = await authed(t, ownerId).mutation(
api.spoons.createManual,
spoonInput,
);
const jobId = await createAgentJob(t, {
ownerId,
spoonId,
status: 'running',
workspaceStatus: 'active',
});
const defaults = await authed(t, ownerId).query(
api.agentJobs.getWorkspaceUiState,
{ jobId },
);
expect(defaults.agentThreadWidth).toBe(420);
await authed(t, ownerId).mutation(api.agentJobs.patchWorkspaceUiState, {
jobId,
agentThreadWidth: 999,
});
const wide = await authed(t, ownerId).query(
api.agentJobs.getWorkspaceUiState,
{ jobId },
);
expect(wide.agentThreadWidth).toBe(720);
await authed(t, ownerId).mutation(api.agentJobs.patchWorkspaceUiState, {
jobId,
agentThreadWidth: 100,
});
const narrow = await authed(t, ownerId).query(
api.agentJobs.getWorkspaceUiState,
{ jobId },
);
expect(narrow.agentThreadWidth).toBe(320);
});
test('deletes terminal threads and attached terminal workspace rows', async () => {
const t = convexTest(schema, modules);
const ownerId = (await createUser(t, 'owner@example.com')) as Id<'users'>;
const spoonId = await authed(t, ownerId).mutation(
api.spoons.createManual,
spoonInput,
);
const threadId = await t.mutation(async (ctx) => {
return await ctx.db.insert('threads', {
ownerId,
spoonId,
title: 'Failed attempt',
source: 'user_request',
status: 'failed',
priority: 'normal',
createdAt: Date.now(),
updatedAt: Date.now(),
});
});
const jobId = await createAgentJob(t, {
ownerId,
spoonId,
threadId,
status: 'failed',
workspaceStatus: 'failed',
});
await t.mutation(async (ctx) => {
await ctx.db.patch(threadId, { latestAgentJobId: jobId });
});
await authed(t, ownerId).mutation(api.threads.deleteThread, { threadId });
const [thread, job, messages] = await t.run(async (ctx) => {
const rows = await ctx.db
.query('agentJobMessages')
.withIndex('by_job', (q) => q.eq('jobId', jobId))
.collect();
return [await ctx.db.get(threadId), await ctx.db.get(jobId), rows];
});
expect(thread).toBeNull();
expect(job).toBeNull();
expect(messages).toHaveLength(0);
});
test('does not delete threads with active workspace runs', async () => {
const t = convexTest(schema, modules);
const ownerId = (await createUser(t, 'owner@example.com')) as Id<'users'>;
const spoonId = await authed(t, ownerId).mutation(
api.spoons.createManual,
spoonInput,
);
const threadId = await t.mutation(async (ctx) => {
return await ctx.db.insert('threads', {
ownerId,
spoonId,
title: 'Active attempt',
source: 'user_request',
status: 'running',
priority: 'normal',
createdAt: Date.now(),
updatedAt: Date.now(),
});
});
const jobId = await createAgentJob(t, {
ownerId,
spoonId,
threadId,
status: 'running',
workspaceStatus: 'active',
});
await t.mutation(async (ctx) => {
await ctx.db.patch(threadId, { latestAgentJobId: jobId });
});
await expect(
authed(t, ownerId).mutation(api.threads.deleteThread, { threadId }),
).rejects.toThrow('Stop or cancel active workspace runs');
});
test('queues a new thread job after the previous job is terminal', async () => { test('queues a new thread job after the previous job is terminal', async () => {
const t = convexTest(schema, modules); const t = convexTest(schema, modules);
const ownerId = (await createUser(t, 'owner@example.com')) as Id<'users'>; const ownerId = (await createUser(t, 'owner@example.com')) as Id<'users'>;
+1
View File
@@ -39,6 +39,7 @@
"SPOON_AGENT_JOB_IMAGE", "SPOON_AGENT_JOB_IMAGE",
"SPOON_AGENT_RUNTIME", "SPOON_AGENT_RUNTIME",
"SPOON_AGENT_CONTAINER_RUNTIME", "SPOON_AGENT_CONTAINER_RUNTIME",
"SPOON_AGENT_CONTAINER_VOLUME_OPTIONS",
"SPOON_CONTAINER_RUNTIME", "SPOON_CONTAINER_RUNTIME",
"SPOON_AGENT_CONTAINER_ACCESS", "SPOON_AGENT_CONTAINER_ACCESS",
"SPOON_AGENT_LOCAL_WORKDIR", "SPOON_AGENT_LOCAL_WORKDIR",