Clean up old stuff & fix ui errors
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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'
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
+108
-24
@@ -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',
|
(await getWorktreeDiff(workspace.repoDir, workspace.redact)).output,
|
||||||
changeType: 'modified',
|
);
|
||||||
diff: truncate(
|
|
||||||
(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'
|
</p>
|
||||||
>
|
{canQueueRun ? (
|
||||||
<div className='mb-2 flex items-center justify-between gap-2'>
|
<Button disabled={queueing} onClick={() => void startRun()}>
|
||||||
<Badge variant='outline'>{message.role}</Badge>
|
<Play className='size-4' />
|
||||||
<span className='text-muted-foreground text-xs'>
|
{latestJob ? 'Create new workspace run' : 'Start workspace run'}
|
||||||
{new Date(message.createdAt).toLocaleString()}
|
</Button>
|
||||||
</span>
|
) : (
|
||||||
</div>
|
<p className='text-muted-foreground'>
|
||||||
<p className='text-sm whitespace-pre-wrap'>{message.content}</p>
|
This thread does not currently have a workspace that can be
|
||||||
</div>
|
opened.
|
||||||
))}
|
</p>
|
||||||
<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>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
<h2 className='text-sm font-semibold'>Agent thread</h2>
|
<div className='flex items-center gap-2'>
|
||||||
|
<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,43 +191,64 @@ 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) => (
|
||||||
<article
|
<Button
|
||||||
key={interaction._id}
|
key={item.value}
|
||||||
className='border-primary/40 bg-primary/5 rounded-md border p-3 text-sm'
|
type='button'
|
||||||
|
variant={filter === item.value ? 'secondary' : 'ghost'}
|
||||||
|
size='sm'
|
||||||
|
className='h-7 flex-none text-xs'
|
||||||
|
onClick={() => setFilter(item.value)}
|
||||||
>
|
>
|
||||||
<div className='mb-2 flex items-center justify-between gap-2'>
|
{item.label}
|
||||||
<span className='font-medium'>{interaction.title}</span>
|
</Button>
|
||||||
<Badge variant='outline' className='capitalize'>
|
|
||||||
{interaction.status}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
<p className='text-sm whitespace-pre-wrap'>{interaction.body}</p>
|
|
||||||
{interaction.status === 'pending' ? (
|
|
||||||
<div className='mt-3 flex gap-2'>
|
|
||||||
<Button
|
|
||||||
type='button'
|
|
||||||
size='sm'
|
|
||||||
disabled={replying === interaction._id}
|
|
||||||
onClick={() => void reply(interaction, 'once')}
|
|
||||||
>
|
|
||||||
Approve
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type='button'
|
|
||||||
size='sm'
|
|
||||||
variant='outline'
|
|
||||||
disabled={replying === interaction._id}
|
|
||||||
onClick={() => void reply(interaction, 'reject')}
|
|
||||||
>
|
|
||||||
Reject
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</article>
|
|
||||||
))}
|
))}
|
||||||
{messages.map((message) => (
|
</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
|
||||||
|
key={interaction._id}
|
||||||
|
className='border-primary/40 bg-primary/5 rounded-md border p-3 text-sm'
|
||||||
|
>
|
||||||
|
<div className='mb-2 flex items-center justify-between gap-2'>
|
||||||
|
<span className='font-medium'>{interaction.title}</span>
|
||||||
|
<Badge variant='outline' className='capitalize'>
|
||||||
|
{interaction.status}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<p className='text-sm whitespace-pre-wrap'>
|
||||||
|
{interaction.body}
|
||||||
|
</p>
|
||||||
|
{interaction.status === 'pending' ? (
|
||||||
|
<div className='mt-3 flex gap-2'>
|
||||||
|
<Button
|
||||||
|
type='button'
|
||||||
|
size='sm'
|
||||||
|
disabled={replying === interaction._id}
|
||||||
|
onClick={() => void reply(interaction, 'once')}
|
||||||
|
>
|
||||||
|
Approve
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type='button'
|
||||||
|
size='sm'
|
||||||
|
variant='outline'
|
||||||
|
disabled={replying === interaction._id}
|
||||||
|
onClick={() => void reply(interaction, 'reject')}
|
||||||
|
>
|
||||||
|
Reject
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</article>
|
||||||
|
))
|
||||||
|
: 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'>
|
||||||
</span>
|
<FilePenLine className='text-primary size-4 flex-none' />
|
||||||
<span>{new Date(event.createdAt).toLocaleTimeString()}</span>
|
<span className='truncate font-mono text-xs'>
|
||||||
|
{change.path}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<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>
|
</div>
|
||||||
<p className='mt-1 whitespace-pre-wrap'>{event.message}</p>
|
{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>
|
||||||
|
<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>
|
||||||
<div className='border-border space-y-2 border-t p-3'>
|
<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')
|
||||||
>
|
}
|
||||||
<TabsTrigger value='editor'>Editor</TabsTrigger>
|
className='flex min-h-0 flex-1 flex-col'
|
||||||
<TabsTrigger value='diff'>Diff</TabsTrigger>
|
>
|
||||||
<TabsTrigger value='thread' className='2xl:hidden'>
|
<TabsList className='border-border bg-muted/30 h-12 flex-none justify-start rounded-none border-b px-3'>
|
||||||
|
<TabsTrigger
|
||||||
|
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}`
|
||||||
: 'Current git diff'}
|
: focusedPath
|
||||||
|
? 'No diff for this file'
|
||||||
|
: 'Current git diff'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button type='button' variant='outline' size='sm' onClick={onRefresh}>
|
<div className='flex flex-none items-center gap-2'>
|
||||||
Refresh
|
{focusedPath ? (
|
||||||
</Button>
|
<Button
|
||||||
|
type='button'
|
||||||
|
variant='ghost'
|
||||||
|
size='sm'
|
||||||
|
onClick={onClearFocusedPath}
|
||||||
|
>
|
||||||
|
Show all
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
<Button type='button' variant='outline' size='sm' onClick={onRefresh}>
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{diff.trim() ? (
|
{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}>
|
<>
|
||||||
<Trash2 className='size-4' />
|
{job.threadId ? (
|
||||||
Delete workspace
|
<Button
|
||||||
</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' />
|
||||||
|
Delete workspace
|
||||||
|
</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}
|
||||||
|
|||||||
+11
-8
@@ -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}
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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}'])),
|
||||||
nodeProject('integration', ['tests/integration/**/*.test.{ts,tsx}']),
|
withNextAlias(
|
||||||
jsdomProject('component', ['tests/component/**/*.test.{ts,tsx}']),
|
nodeProject('integration', ['tests/integration/**/*.test.{ts,tsx}']),
|
||||||
|
),
|
||||||
|
withNextAlias(
|
||||||
|
jsdomProject('component', ['tests/component/**/*.test.{ts,tsx}']),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 };
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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(),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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'),
|
||||||
|
|||||||
@@ -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'>;
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user