Move to threads based system.
This commit is contained in:
@@ -0,0 +1,83 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Send } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import type { Doc } from '@spoon/backend/convex/_generated/dataModel.js';
|
||||
import { Button, Textarea } from '@spoon/ui';
|
||||
|
||||
export const AgentThread = ({
|
||||
jobId,
|
||||
messages,
|
||||
disabled,
|
||||
}: {
|
||||
jobId: string;
|
||||
messages: Doc<'agentJobMessages'>[];
|
||||
disabled: boolean;
|
||||
}) => {
|
||||
const [content, setContent] = useState('');
|
||||
const [sending, setSending] = useState(false);
|
||||
|
||||
const send = async () => {
|
||||
if (!content.trim()) return;
|
||||
setSending(true);
|
||||
try {
|
||||
const response = await fetch(`/api/agent-jobs/${jobId}/message`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ content }),
|
||||
});
|
||||
if (!response.ok) throw new Error(await response.text());
|
||||
setContent('');
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error('Could not send message.');
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='flex h-full min-h-[520px] flex-col'>
|
||||
<div className='border-border border-b p-3'>
|
||||
<h2 className='text-sm font-semibold'>Agent thread</h2>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
Messages persist with this workspace.
|
||||
</p>
|
||||
</div>
|
||||
<div className='min-h-0 flex-1 space-y-3 overflow-auto p-3'>
|
||||
{messages.map((message) => (
|
||||
<article
|
||||
key={message._id}
|
||||
className='border-border bg-background rounded-md border p-3 text-sm'
|
||||
>
|
||||
<div className='mb-2 flex items-center justify-between gap-2'>
|
||||
<span className='font-medium capitalize'>{message.role}</span>
|
||||
<span className='text-muted-foreground text-xs capitalize'>
|
||||
{message.status}
|
||||
</span>
|
||||
</div>
|
||||
<p className='whitespace-pre-wrap'>{message.content}</p>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
<div className='border-border space-y-2 border-t p-3'>
|
||||
<Textarea
|
||||
value={content}
|
||||
placeholder='Ask the agent to inspect, explain, or change this fork.'
|
||||
disabled={disabled || sending}
|
||||
onChange={(event) => setContent(event.target.value)}
|
||||
/>
|
||||
<Button
|
||||
type='button'
|
||||
className='w-full'
|
||||
disabled={disabled || sending || !content.trim()}
|
||||
onClick={send}
|
||||
>
|
||||
<Send className='size-4' />
|
||||
{sending ? 'Sending...' : 'Send'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,153 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useQuery } from 'convex/react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import type { Id } from '@spoon/backend/convex/_generated/dataModel.js';
|
||||
import { api } from '@spoon/backend/convex/_generated/api.js';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@spoon/ui';
|
||||
|
||||
import type { DiffResponse, FileResponse, FileTreeNode } from './types';
|
||||
import { AgentThread } from './agent-thread';
|
||||
import { CodeEditor } from './code-editor';
|
||||
import { CommandPanel } from './command-panel';
|
||||
import { DiffViewer } from './diff-viewer';
|
||||
import { FileTree } from './file-tree';
|
||||
import { JobStatusBar } from './job-status-bar';
|
||||
import { WorkspaceActions } from './workspace-actions';
|
||||
|
||||
export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
|
||||
const job = useQuery(api.agentJobs.get, { jobId });
|
||||
const messages =
|
||||
useQuery(api.agentJobs.listMessages, { jobId, limit: 200 }) ?? [];
|
||||
const [tree, setTree] = useState<FileTreeNode | null>(null);
|
||||
const [selectedPath, setSelectedPath] = useState<string>();
|
||||
const [fileContent, setFileContent] = useState('');
|
||||
const [diff, setDiff] = useState('');
|
||||
|
||||
const workspaceDisabled =
|
||||
!job ||
|
||||
['draft_pr_opened', 'failed', 'cancelled', 'timed_out'].includes(
|
||||
job.status,
|
||||
) ||
|
||||
['stopped', 'expired', 'failed'].includes(job.workspaceStatus ?? '');
|
||||
|
||||
const loadTree = useCallback(async () => {
|
||||
const response = await fetch(`/api/agent-jobs/${jobId}/tree`);
|
||||
if (!response.ok) throw new Error(await response.text());
|
||||
const data = (await response.json()) as { tree: FileTreeNode | null };
|
||||
setTree(data.tree);
|
||||
}, [jobId]);
|
||||
|
||||
const loadDiff = useCallback(async () => {
|
||||
const response = await fetch(`/api/agent-jobs/${jobId}/diff`);
|
||||
if (!response.ok) throw new Error(await response.text());
|
||||
const data = (await response.json()) as DiffResponse;
|
||||
setDiff(data.diff);
|
||||
}, [jobId]);
|
||||
|
||||
const loadFile = useCallback(
|
||||
async (path: string) => {
|
||||
const response = await fetch(
|
||||
`/api/agent-jobs/${jobId}/file?path=${encodeURIComponent(path)}`,
|
||||
);
|
||||
if (!response.ok) throw new Error(await response.text());
|
||||
const data = (await response.json()) as FileResponse;
|
||||
setSelectedPath(data.path);
|
||||
setFileContent(data.content);
|
||||
},
|
||||
[jobId],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!job) return;
|
||||
const timeout = window.setTimeout(() => {
|
||||
void loadTree().catch((error: unknown) => {
|
||||
console.error(error);
|
||||
});
|
||||
void loadDiff().catch((error: unknown) => {
|
||||
console.error(error);
|
||||
});
|
||||
}, 0);
|
||||
return () => window.clearTimeout(timeout);
|
||||
}, [job, loadDiff, loadTree]);
|
||||
|
||||
if (job === undefined) {
|
||||
return (
|
||||
<main className='text-muted-foreground p-6'>Loading workspace...</main>
|
||||
);
|
||||
}
|
||||
|
||||
const saveFile = async (content: string) => {
|
||||
if (!selectedPath) return;
|
||||
const response = await fetch(`/api/agent-jobs/${jobId}/file`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ path: selectedPath, content }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
toast.error('Could not save file.');
|
||||
throw new Error(await response.text());
|
||||
}
|
||||
setFileContent(content);
|
||||
await loadDiff();
|
||||
toast.success('File saved.');
|
||||
};
|
||||
|
||||
return (
|
||||
<main className='border-border bg-muted/20 min-h-[calc(100vh-5rem)] overflow-hidden rounded-md border'>
|
||||
<JobStatusBar job={job} />
|
||||
<div className='border-border bg-background flex items-center justify-end border-b px-4 py-2'>
|
||||
<WorkspaceActions job={job} disabled={workspaceDisabled} />
|
||||
</div>
|
||||
<div className='grid min-h-[680px] grid-cols-1 xl:grid-cols-[260px_minmax(0,1fr)_360px]'>
|
||||
<aside className='border-border bg-background min-h-[260px] border-r'>
|
||||
<div className='border-border border-b p-3'>
|
||||
<h2 className='text-sm font-semibold'>Files</h2>
|
||||
<p className='text-muted-foreground text-xs'>Current workspace</p>
|
||||
</div>
|
||||
<FileTree
|
||||
tree={tree}
|
||||
selectedPath={selectedPath}
|
||||
onSelect={(path) => {
|
||||
void loadFile(path).catch((error) => {
|
||||
console.error(error);
|
||||
toast.error('Could not load file.');
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</aside>
|
||||
<section className='bg-background min-w-0'>
|
||||
<Tabs defaultValue='editor' className='h-full'>
|
||||
<TabsList
|
||||
variant='line'
|
||||
className='border-border h-11 w-full justify-start rounded-none border-b px-3'
|
||||
>
|
||||
<TabsTrigger value='editor'>Editor</TabsTrigger>
|
||||
<TabsTrigger value='diff'>Diff</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value='editor' className='m-0'>
|
||||
<CodeEditor
|
||||
path={selectedPath}
|
||||
content={fileContent}
|
||||
readOnly={workspaceDisabled}
|
||||
onSave={saveFile}
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value='diff' className='m-0'>
|
||||
<DiffViewer diff={diff} onRefresh={loadDiff} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
<CommandPanel jobId={jobId} disabled={workspaceDisabled} />
|
||||
</section>
|
||||
<aside className='border-border bg-muted/20 min-w-0 border-l'>
|
||||
<AgentThread
|
||||
jobId={jobId}
|
||||
messages={messages}
|
||||
disabled={workspaceDisabled}
|
||||
/>
|
||||
</aside>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,133 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import dynamic from 'next/dynamic';
|
||||
|
||||
import { Button, Switch } from '@spoon/ui';
|
||||
|
||||
const MonacoEditor = dynamic(async () => await import('@monaco-editor/react'), {
|
||||
ssr: false,
|
||||
});
|
||||
|
||||
type MonacoEditorInstance = {
|
||||
getModel?: () => unknown;
|
||||
};
|
||||
|
||||
type VimMode = {
|
||||
dispose: () => void;
|
||||
};
|
||||
|
||||
export const CodeEditor = ({
|
||||
path,
|
||||
content,
|
||||
readOnly,
|
||||
onSave,
|
||||
}: {
|
||||
path?: string;
|
||||
content: string;
|
||||
readOnly: boolean;
|
||||
onSave: (content: string) => Promise<void>;
|
||||
}) => {
|
||||
const [value, setValue] = useState(content);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [vimEnabled, setVimEnabled] = useState(false);
|
||||
const [dirty, setDirty] = useState(false);
|
||||
const editorRef = useRef<MonacoEditorInstance | null>(null);
|
||||
const vimRef = useRef<VimMode | null>(null);
|
||||
const statusRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setValue(content);
|
||||
setDirty(false);
|
||||
}, [content, path]);
|
||||
|
||||
useEffect(() => {
|
||||
const editor = editorRef.current;
|
||||
if (!editor) return;
|
||||
vimRef.current?.dispose();
|
||||
vimRef.current = null;
|
||||
if (!vimEnabled) return;
|
||||
void import('monaco-vim').then((module) => {
|
||||
const initVimMode = module.initVimMode as unknown as (
|
||||
editor: MonacoEditorInstance,
|
||||
statusNode?: HTMLElement | null,
|
||||
) => VimMode;
|
||||
vimRef.current = initVimMode(editor, statusRef.current);
|
||||
});
|
||||
return () => {
|
||||
vimRef.current?.dispose();
|
||||
vimRef.current = null;
|
||||
};
|
||||
}, [vimEnabled, path]);
|
||||
|
||||
if (!path) {
|
||||
return (
|
||||
<div className='text-muted-foreground flex h-full items-center justify-center text-sm'>
|
||||
Select a file to inspect or edit.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const save = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
await onSave(value);
|
||||
setDirty(false);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='flex h-full min-h-[520px] flex-col'>
|
||||
<div className='border-border flex h-11 items-center justify-between gap-3 border-b px-3'>
|
||||
<div className='min-w-0'>
|
||||
<p className='truncate font-mono text-xs'>{path}</p>
|
||||
{dirty ? (
|
||||
<p className='text-muted-foreground text-xs'>Unsaved changes</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className='flex items-center gap-3'>
|
||||
<label className='flex items-center gap-2 text-xs'>
|
||||
Vim
|
||||
<Switch checked={vimEnabled} onCheckedChange={setVimEnabled} />
|
||||
</label>
|
||||
<Button
|
||||
type='button'
|
||||
size='sm'
|
||||
disabled={readOnly || saving || !dirty}
|
||||
onClick={save}
|
||||
>
|
||||
{saving ? 'Saving...' : 'Save'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className='min-h-0 flex-1'>
|
||||
<MonacoEditor
|
||||
height='520px'
|
||||
path={path}
|
||||
value={value}
|
||||
theme='vs-dark'
|
||||
options={{
|
||||
readOnly,
|
||||
minimap: { enabled: false },
|
||||
fontSize: 13,
|
||||
scrollBeyondLastLine: false,
|
||||
wordWrap: 'on',
|
||||
}}
|
||||
onMount={(editor) => {
|
||||
editorRef.current = editor as MonacoEditorInstance;
|
||||
}}
|
||||
onChange={(next) => {
|
||||
setValue(next ?? '');
|
||||
setDirty((next ?? '') !== content);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
ref={statusRef}
|
||||
className='border-border text-muted-foreground h-6 border-t px-3 py-1 font-mono text-xs'
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,56 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Terminal } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { Button, Input } from '@spoon/ui';
|
||||
|
||||
export const CommandPanel = ({
|
||||
jobId,
|
||||
disabled,
|
||||
}: {
|
||||
jobId: string;
|
||||
disabled: boolean;
|
||||
}) => {
|
||||
const [command, setCommand] = useState('');
|
||||
const [running, setRunning] = useState(false);
|
||||
|
||||
const run = async () => {
|
||||
setRunning(true);
|
||||
try {
|
||||
const response = await fetch(`/api/agent-jobs/${jobId}/command`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ command }),
|
||||
});
|
||||
if (!response.ok) throw new Error(await response.text());
|
||||
toast.success('Command completed.');
|
||||
setCommand('');
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error('Command failed.');
|
||||
} finally {
|
||||
setRunning(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='border-border flex items-center gap-2 border-t p-3'>
|
||||
<Terminal className='text-muted-foreground size-4' />
|
||||
<Input
|
||||
value={command}
|
||||
placeholder='bun test, pnpm lint, npm run typecheck...'
|
||||
disabled={disabled || running}
|
||||
onChange={(event) => setCommand(event.target.value)}
|
||||
/>
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
disabled={disabled || running || !command.trim()}
|
||||
onClick={run}
|
||||
>
|
||||
{running ? 'Running...' : 'Run'}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,47 @@
|
||||
'use client';
|
||||
|
||||
import dynamic from 'next/dynamic';
|
||||
|
||||
import { Button } from '@spoon/ui';
|
||||
|
||||
const MonacoEditor = dynamic(async () => await import('@monaco-editor/react'), {
|
||||
ssr: false,
|
||||
});
|
||||
|
||||
export const DiffViewer = ({
|
||||
diff,
|
||||
onRefresh,
|
||||
}: {
|
||||
diff: string;
|
||||
onRefresh: () => Promise<void>;
|
||||
}) => (
|
||||
<div className='flex h-full min-h-[520px] flex-col'>
|
||||
<div className='border-border flex h-11 items-center justify-between border-b px-3'>
|
||||
<div>
|
||||
<p className='text-sm font-medium'>Workspace diff</p>
|
||||
<p className='text-muted-foreground text-xs'>Current git diff</p>
|
||||
</div>
|
||||
<Button type='button' variant='outline' size='sm' onClick={onRefresh}>
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
{diff.trim() ? (
|
||||
<MonacoEditor
|
||||
height='520px'
|
||||
language='diff'
|
||||
theme='vs-dark'
|
||||
value={diff}
|
||||
options={{
|
||||
readOnly: true,
|
||||
minimap: { enabled: false },
|
||||
fontSize: 13,
|
||||
scrollBeyondLastLine: false,
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className='text-muted-foreground flex flex-1 items-center justify-center text-sm'>
|
||||
No workspace diff yet.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@@ -0,0 +1,83 @@
|
||||
'use client';
|
||||
|
||||
import { ChevronRight, FileCode, Folder } from 'lucide-react';
|
||||
|
||||
import { Button } from '@spoon/ui';
|
||||
|
||||
import type { FileTreeNode } from './types';
|
||||
|
||||
const TreeNode = ({
|
||||
node,
|
||||
selectedPath,
|
||||
onSelect,
|
||||
depth = 0,
|
||||
}: {
|
||||
node: FileTreeNode;
|
||||
selectedPath?: string;
|
||||
onSelect: (path: string) => void;
|
||||
depth?: number;
|
||||
}) => {
|
||||
if (node.type === 'directory') {
|
||||
return (
|
||||
<div>
|
||||
{node.path ? (
|
||||
<div
|
||||
className='text-muted-foreground flex h-7 items-center gap-1 px-2 text-xs font-medium'
|
||||
style={{ paddingLeft: depth * 12 + 8 }}
|
||||
>
|
||||
<ChevronRight className='size-3' />
|
||||
<Folder className='size-3' />
|
||||
<span className='truncate'>{node.name}</span>
|
||||
</div>
|
||||
) : null}
|
||||
<div>
|
||||
{node.children?.map((child) => (
|
||||
<TreeNode
|
||||
key={`${child.type}:${child.path}`}
|
||||
node={child}
|
||||
selectedPath={selectedPath}
|
||||
onSelect={onSelect}
|
||||
depth={node.path ? depth + 1 : depth}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
type='button'
|
||||
variant={selectedPath === node.path ? 'secondary' : 'ghost'}
|
||||
className='h-7 w-full justify-start gap-2 rounded-none px-2 text-left text-xs font-normal'
|
||||
style={{ paddingLeft: depth * 12 + 8 }}
|
||||
onClick={() => onSelect(node.path)}
|
||||
>
|
||||
<FileCode className='size-3 flex-none' />
|
||||
<span className='truncate'>{node.name}</span>
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
export const FileTree = ({
|
||||
tree,
|
||||
selectedPath,
|
||||
onSelect,
|
||||
}: {
|
||||
tree: FileTreeNode | null;
|
||||
selectedPath?: string;
|
||||
onSelect: (path: string) => void;
|
||||
}) => {
|
||||
if (!tree) {
|
||||
return (
|
||||
<p className='text-muted-foreground p-3 text-sm'>
|
||||
Workspace files are not available yet.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className='overflow-auto py-2'>
|
||||
<TreeNode node={tree} selectedPath={selectedPath} onSelect={onSelect} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,24 @@
|
||||
'use client';
|
||||
|
||||
import type { Doc } from '@spoon/backend/convex/_generated/dataModel.js';
|
||||
import { Badge } from '@spoon/ui';
|
||||
|
||||
export const JobStatusBar = ({ job }: { job: Doc<'agentJobs'> }) => (
|
||||
<div className='border-border bg-background flex flex-wrap items-center justify-between gap-3 border-b px-4 py-3'>
|
||||
<div className='min-w-0'>
|
||||
<h1 className='truncate text-base font-semibold'>{job.forkRepo}</h1>
|
||||
<p className='text-muted-foreground truncate font-mono text-xs'>
|
||||
{job.baseBranch} {'->'} {job.workBranch}
|
||||
</p>
|
||||
</div>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Badge variant='outline' className='capitalize'>
|
||||
{job.status.replaceAll('_', ' ')}
|
||||
</Badge>
|
||||
<Badge variant='secondary' className='capitalize'>
|
||||
{(job.workspaceStatus ?? 'not_started').replaceAll('_', ' ')}
|
||||
</Badge>
|
||||
<Badge variant='outline'>{job.runtime ?? 'opencode'}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -0,0 +1,15 @@
|
||||
export type FileTreeNode = {
|
||||
name: string;
|
||||
path: string;
|
||||
type: 'file' | 'directory';
|
||||
children?: FileTreeNode[];
|
||||
};
|
||||
|
||||
export type FileResponse = {
|
||||
path: string;
|
||||
content: string;
|
||||
};
|
||||
|
||||
export type DiffResponse = {
|
||||
diff: string;
|
||||
};
|
||||
@@ -0,0 +1,68 @@
|
||||
'use client';
|
||||
|
||||
import { ExternalLink, GitPullRequestDraft, Square } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import type { Doc } from '@spoon/backend/convex/_generated/dataModel.js';
|
||||
import { Button } from '@spoon/ui';
|
||||
|
||||
export const WorkspaceActions = ({
|
||||
job,
|
||||
disabled,
|
||||
}: {
|
||||
job: Doc<'agentJobs'>;
|
||||
disabled: boolean;
|
||||
}) => {
|
||||
const openPr = async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/agent-jobs/${job._id}/open-pr`, {
|
||||
method: 'POST',
|
||||
});
|
||||
if (!response.ok) throw new Error(await response.text());
|
||||
toast.success('Draft PR opened.');
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error('Could not open draft PR.');
|
||||
}
|
||||
};
|
||||
|
||||
const stop = async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/agent-jobs/${job._id}/stop`, {
|
||||
method: 'POST',
|
||||
});
|
||||
if (!response.ok) throw new Error(await response.text());
|
||||
toast.success('Workspace stopped.');
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error('Could not stop workspace.');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='flex flex-wrap items-center gap-2'>
|
||||
{job.pullRequestUrl ? (
|
||||
<Button asChild variant='outline' size='sm'>
|
||||
<a href={job.pullRequestUrl} target='_blank' rel='noreferrer'>
|
||||
<ExternalLink className='size-4' />
|
||||
Open PR
|
||||
</a>
|
||||
</Button>
|
||||
) : null}
|
||||
<Button type='button' size='sm' disabled={disabled} onClick={openPr}>
|
||||
<GitPullRequestDraft className='size-4' />
|
||||
Open draft PR
|
||||
</Button>
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
size='sm'
|
||||
disabled={disabled}
|
||||
onClick={stop}
|
||||
>
|
||||
<Square className='size-4' />
|
||||
Stop
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user