Normalise API calls on FE (#173)

* tiny fix

* Move all api calls from components to lib/api.ts (vibe-kanban) (#165)

* unify loaders

* simplify scroll to bottom logic for logs

* better key for display entry

* finish normalising api calls

* remove withErrorHandling function

* cleanup
This commit is contained in:
Anastasiia Solop
2025-07-15 08:18:05 +02:00
committed by GitHub
parent acdf713378
commit a27207b75e
28 changed files with 1349 additions and 1011 deletions

View File

@@ -1,5 +1,5 @@
import { useState, useEffect } from 'react';
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import { useEffect, useState } from 'react';
import { BrowserRouter, Route, Routes } from 'react-router-dom';
import { Navbar } from '@/components/layout/navbar';
import { Projects } from '@/pages/projects';
import { ProjectTasks } from '@/pages/project-tasks';
@@ -11,13 +11,10 @@ import { OnboardingDialog } from '@/components/OnboardingDialog';
import { PrivacyOptInDialog } from '@/components/PrivacyOptInDialog';
import { ConfigProvider, useConfig } from '@/components/config-provider';
import { ThemeProvider } from '@/components/theme-provider';
import type {
Config,
ApiResponse,
ExecutorConfig,
EditorType,
} from 'shared/types';
import type { EditorType, ExecutorConfig } from 'shared/types';
import { configApi } from '@/lib/api';
import * as Sentry from '@sentry/react';
import { Loader } from '@/components/ui/loader';
import { GitHubLoginDialog } from '@/components/GitHubLoginDialog';
const SentryRoutes = Sentry.withSentryReactRouterV6Routing(Routes);
@@ -44,7 +41,7 @@ function AppContent() {
if (config.telemetry_acknowledged) {
const notAuthenticated =
!config.github?.username || !config.github?.token;
setShowGitHubLogin(notAuthenticated || githubTokenInvalid);
setShowGitHubLogin(notAuthenticated);
} else {
setShowGitHubLogin(false);
}
@@ -60,20 +57,9 @@ function AppContent() {
updateConfig({ disclaimer_acknowledged: true });
try {
const response = await fetch('/api/config', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ ...config, disclaimer_acknowledged: true }),
});
const data: ApiResponse<Config> = await response.json();
if (data.success) {
setShowDisclaimer(false);
setShowOnboarding(!config.onboarding_acknowledged);
}
await configApi.saveConfig({ ...config, disclaimer_acknowledged: true });
setShowDisclaimer(false);
setShowOnboarding(!config.onboarding_acknowledged);
} catch (err) {
console.error('Error saving config:', err);
}
@@ -95,19 +81,8 @@ function AppContent() {
updateConfig(updatedConfig);
try {
const response = await fetch('/api/config', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(updatedConfig),
});
const data: ApiResponse<Config> = await response.json();
if (data.success) {
setShowOnboarding(false);
}
await configApi.saveConfig(updatedConfig);
setShowOnboarding(false);
} catch (err) {
console.error('Error saving config:', err);
}
@@ -125,23 +100,12 @@ function AppContent() {
updateConfig(updatedConfig);
try {
const response = await fetch('/api/config', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(updatedConfig),
});
const data: ApiResponse<Config> = await response.json();
if (data.success) {
setShowPrivacyOptIn(false);
// Now show GitHub login after privacy choice is made
const notAuthenticated =
!updatedConfig.github?.username || !updatedConfig.github?.token;
setShowGitHubLogin(notAuthenticated);
}
await configApi.saveConfig(updatedConfig);
setShowPrivacyOptIn(false);
// Now show GitHub login after privacy choice is made
const notAuthenticated =
!updatedConfig.github?.username || !updatedConfig.github?.token;
setShowGitHubLogin(notAuthenticated);
} catch (err) {
console.error('Error saving config:', err);
}
@@ -150,10 +114,7 @@ function AppContent() {
if (loading) {
return (
<div className="min-h-screen bg-background flex items-center justify-center">
<div className="text-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto"></div>
<p className="mt-2 text-muted-foreground">Loading...</p>
</div>
<Loader message="Loading..." size={32} />
</div>
);
}

View File

@@ -10,6 +10,9 @@ import {
import { Button } from './ui/button';
import { useConfig } from './config-provider';
import { Check, Clipboard } from 'lucide-react';
import { Loader } from './ui/loader';
import { githubAuthApi } from '../lib/api';
import { StartGitHubDeviceFlowType } from 'shared/types.ts';
export function GitHubLoginDialog({
open,
@@ -21,13 +24,8 @@ export function GitHubLoginDialog({
const { config, loading, githubTokenInvalid } = useConfig();
const [fetching, setFetching] = useState(false);
const [error, setError] = useState<string | null>(null);
const [deviceState, setDeviceState] = useState<null | {
device_code: string;
user_code: string;
verification_uri: string;
expires_in: number;
interval: number;
}>(null);
const [deviceState, setDeviceState] =
useState<null | StartGitHubDeviceFlowType>(null);
const [polling, setPolling] = useState(false);
const [copied, setCopied] = useState(false);
@@ -40,19 +38,12 @@ export function GitHubLoginDialog({
setError(null);
setDeviceState(null);
try {
const res = await fetch('/api/auth/github/device/start', {
method: 'POST',
});
const data = await res.json();
if (data.success && data.data) {
setDeviceState(data.data);
setPolling(true);
} else {
setError(data.message || 'Failed to start GitHub login.');
}
} catch (e) {
const data = await githubAuthApi.start();
setDeviceState(data);
setPolling(true);
} catch (e: any) {
console.error(e);
setError('Network error');
setError(e?.message || 'Network error');
} finally {
setFetching(false);
}
@@ -64,35 +55,25 @@ export function GitHubLoginDialog({
if (polling && deviceState) {
const poll = async () => {
try {
const res = await fetch('/api/auth/github/device/poll', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ device_code: deviceState.device_code }),
});
const data = await res.json();
if (data.success) {
setPolling(false);
setDeviceState(null);
setError(null);
window.location.reload(); // reload config
} else if (data.message === 'authorization_pending') {
// keep polling
await githubAuthApi.poll(deviceState.device_code);
setPolling(false);
setDeviceState(null);
setError(null);
window.location.reload(); // reload config
} catch (e: any) {
if (e?.message === 'authorization_pending') {
timer = setTimeout(poll, (deviceState.interval || 5) * 1000);
} else if (data.message === 'slow_down') {
// increase interval
} else if (e?.message === 'slow_down') {
timer = setTimeout(poll, (deviceState.interval + 5) * 1000);
} else if (data.message === 'expired_token') {
} else if (e?.message === 'expired_token') {
setPolling(false);
setError('Device code expired. Please try again.');
setDeviceState(null);
} else {
setPolling(false);
setError(data.message || 'Login failed.');
setError(e?.message || 'Login failed.');
setDeviceState(null);
}
} catch (e) {
setPolling(false);
setError('Network error');
}
};
timer = setTimeout(poll, deviceState.interval * 1000);
@@ -149,7 +130,7 @@ export function GitHubLoginDialog({
</DialogDescription>
</DialogHeader>
{loading ? (
<div className="py-8 text-center">Loading</div>
<Loader message="Loading…" size={32} className="py-8" />
) : isAuthenticated ? (
<div className="py-8 text-center">
<div className="mb-2">

View File

@@ -6,7 +6,8 @@ import {
useEffect,
useState,
} from 'react';
import type { ApiResponse, Config } from 'shared/types';
import type { Config } from 'shared/types';
import { configApi, githubAuthApi } from '../lib/api';
interface ConfigContextType {
config: Config | null;
@@ -31,12 +32,8 @@ export function ConfigProvider({ children }: ConfigProviderProps) {
useEffect(() => {
const loadConfig = async () => {
try {
const response = await fetch('/api/config');
const data: ApiResponse<Config> = await response.json();
if (data.success && data.data) {
setConfig(data.data);
}
const config = await configApi.getConfig();
setConfig(config);
} catch (err) {
console.error('Error loading config:', err);
} finally {
@@ -51,13 +48,12 @@ export function ConfigProvider({ children }: ConfigProviderProps) {
useEffect(() => {
if (loading) return;
const checkToken = async () => {
const response = await fetch('/api/auth/github/check');
const data: ApiResponse<null> = await response.json();
if (!data.success && data.message === 'github_token_invalid') {
setGithubTokenInvalid(true);
} else {
setGithubTokenInvalid(false);
const valid = await githubAuthApi.checkGithubToken();
if (valid === undefined) {
// Network/server error: do not update githubTokenInvalid
return;
}
setGithubTokenInvalid(!valid);
};
checkToken();
}, [loading]);
@@ -68,18 +64,9 @@ export function ConfigProvider({ children }: ConfigProviderProps) {
const saveConfig = useCallback(async (): Promise<boolean> => {
if (!config) return false;
try {
const response = await fetch('/api/config', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(config),
});
const data: ApiResponse<Config> = await response.json();
return data.success;
await configApi.saveConfig(config);
return true;
} catch (err) {
console.error('Error saving config:', err);
return false;
@@ -92,19 +79,11 @@ export function ConfigProvider({ children }: ConfigProviderProps) {
const newConfig: Config | null = config
? { ...config, ...updates }
: null;
try {
const response = await fetch('/api/config', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(newConfig),
});
const data: ApiResponse<Config> = await response.json();
setConfig(data.data);
return data.success;
if (!newConfig) return false;
const saved = await configApi.saveConfig(newConfig);
setConfig(saved);
return true;
} catch (err) {
console.error('Error saving config:', err);
return false;

View File

@@ -10,18 +10,15 @@ import {
useState,
} from 'react';
import type {
ApiResponse,
AttemptData,
EditorType,
ExecutionProcess,
ExecutionProcessSummary,
TaskAttempt,
TaskAttemptActivityWithPrompt,
TaskAttemptState,
TaskWithAttemptStatus,
WorktreeDiff,
} from 'shared/types.ts';
import { makeRequest } from '@/lib/api.ts';
import { attemptsApi, executionProcessesApi } from '@/lib/api.ts';
import {
TaskAttemptDataContext,
TaskAttemptLoadingContext,
@@ -41,7 +38,6 @@ const TaskDetailsProvider: FC<{
activeTab: 'logs' | 'diffs';
setActiveTab: Dispatch<SetStateAction<'logs' | 'diffs'>>;
setShowEditorDialog: Dispatch<SetStateAction<boolean>>;
isOpen: boolean;
userSelectedTab: boolean;
projectHasDevScript?: boolean;
}> = ({
@@ -51,7 +47,6 @@ const TaskDetailsProvider: FC<{
activeTab,
setActiveTab,
setShowEditorDialog,
isOpen,
userSelectedTab,
projectHasDevScript,
}) => {
@@ -94,29 +89,26 @@ const TaskDetailsProvider: FC<{
return;
}
diffLoadingRef.current = true;
if (isBackgroundRefresh) {
setIsBackgroundRefreshing(true);
} else {
setDiffLoading(true);
}
setDiffError(null);
try {
diffLoadingRef.current = true;
if (isBackgroundRefresh) {
setIsBackgroundRefreshing(true);
} else {
setDiffLoading(true);
}
setDiffError(null);
const response = await makeRequest(
`/api/projects/${projectId}/tasks/${selectedAttempt.task_id}/attempts/${selectedAttempt.id}/diff`
const result = await attemptsApi.getDiff(
projectId,
selectedAttempt.task_id,
selectedAttempt.id
);
if (response.ok) {
const result: ApiResponse<WorktreeDiff> = await response.json();
if (result.success && result.data) {
setDiff(result.data);
} else {
setDiffError('Failed to load diff');
}
} else {
setDiffError('Failed to load diff');
if (result !== undefined) {
setDiff(result);
}
} catch (err) {
console.error('Failed to load diff:', err);
setDiffError('Failed to load diff');
} finally {
diffLoadingRef.current = false;
@@ -131,29 +123,21 @@ const TaskDetailsProvider: FC<{
);
useEffect(() => {
if (isOpen) {
fetchDiff();
}
}, [isOpen, fetchDiff]);
fetchDiff();
}, [fetchDiff]);
const fetchExecutionState = useCallback(
async (attemptId: string, taskId: string) => {
if (!task) return;
try {
const response = await makeRequest(
`/api/projects/${projectId}/tasks/${taskId}/attempts/${attemptId}`
);
const result = await attemptsApi.getState(projectId, taskId, attemptId);
if (response.ok) {
const result: ApiResponse<TaskAttemptState> = await response.json();
if (result.success && result.data) {
setExecutionState((prev) => {
if (JSON.stringify(prev) === JSON.stringify(result.data))
return prev;
return result.data;
});
}
if (result !== undefined) {
setExecutionState((prev) => {
if (JSON.stringify(prev) === JSON.stringify(result)) return prev;
return result;
});
}
} catch (err) {
console.error('Failed to fetch execution state:', err);
@@ -167,20 +151,15 @@ const TaskDetailsProvider: FC<{
if (!task || !selectedAttempt) return;
try {
const response = await makeRequest(
`/api/projects/${projectId}/tasks/${selectedAttempt.task_id}/attempts/${selectedAttempt.id}/open-editor`,
{
method: 'POST',
body: JSON.stringify(
editorType ? { editor_type: editorType } : null
),
}
const result = await attemptsApi.openEditor(
projectId,
selectedAttempt.task_id,
selectedAttempt.id,
editorType
);
if (!response.ok) {
if (!editorType) {
setShowEditorDialog(true);
}
if (result === undefined && !editorType) {
setShowEditorDialog(true);
}
} catch (err) {
console.error('Failed to open editor:', err);
@@ -197,91 +176,56 @@ const TaskDetailsProvider: FC<{
if (!task) return;
try {
const [activitiesResponse, processesResponse] = await Promise.all([
makeRequest(
`/api/projects/${projectId}/tasks/${taskId}/attempts/${attemptId}/activities`
),
makeRequest(
`/api/projects/${projectId}/tasks/${taskId}/attempts/${attemptId}/execution-processes`
),
const [activitiesResult, processesResult] = await Promise.all([
attemptsApi.getActivities(projectId, taskId, attemptId),
attemptsApi.getExecutionProcesses(projectId, taskId, attemptId),
]);
if (activitiesResponse.ok && processesResponse.ok) {
const activitiesResult: ApiResponse<TaskAttemptActivityWithPrompt[]> =
await activitiesResponse.json();
const processesResult: ApiResponse<ExecutionProcessSummary[]> =
await processesResponse.json();
if (activitiesResult !== undefined && processesResult !== undefined) {
const runningActivities = activitiesResult.filter(
(activity) =>
activity.status === 'setuprunning' ||
activity.status === 'executorrunning'
);
if (
activitiesResult.success &&
processesResult.success &&
activitiesResult.data &&
processesResult.data
) {
const runningActivities = activitiesResult.data.filter(
(activity) =>
activity.status === 'setuprunning' ||
activity.status === 'executorrunning'
const runningProcessDetails: Record<string, ExecutionProcess> = {};
// Fetch details for running activities
for (const activity of runningActivities) {
const result = await executionProcessesApi.getDetails(
projectId,
activity.execution_process_id
);
const runningProcessDetails: Record<string, ExecutionProcess> = {};
// Fetch details for running activities
for (const activity of runningActivities) {
try {
const detailResponse = await makeRequest(
`/api/projects/${projectId}/execution-processes/${activity.execution_process_id}`
);
if (detailResponse.ok) {
const detailResult: ApiResponse<ExecutionProcess> =
await detailResponse.json();
if (detailResult.success && detailResult.data) {
runningProcessDetails[activity.execution_process_id] =
detailResult.data;
}
}
} catch (err) {
console.error(
`Failed to fetch execution process ${activity.execution_process_id}:`,
err
);
}
if (result !== undefined) {
runningProcessDetails[activity.execution_process_id] = result;
}
// Also fetch setup script process details if it exists in the processes
const setupProcess = processesResult.data.find(
(process) => process.process_type === 'setupscript'
);
if (setupProcess && !runningProcessDetails[setupProcess.id]) {
try {
const detailResponse = await makeRequest(
`/api/projects/${projectId}/execution-processes/${setupProcess.id}`
);
if (detailResponse.ok) {
const detailResult: ApiResponse<ExecutionProcess> =
await detailResponse.json();
if (detailResult.success && detailResult.data) {
runningProcessDetails[setupProcess.id] = detailResult.data;
}
}
} catch (err) {
console.error(
`Failed to fetch setup process details ${setupProcess.id}:`,
err
);
}
}
setAttemptData((prev) => {
const newData = {
activities: activitiesResult.data || [],
processes: processesResult.data || [],
runningProcessDetails,
};
if (JSON.stringify(prev) === JSON.stringify(newData)) return prev;
return newData;
});
}
// Also fetch setup script process details if it exists in the processes
const setupProcess = processesResult.find(
(process) => process.process_type === 'setupscript'
);
if (setupProcess && !runningProcessDetails[setupProcess.id]) {
const result = await executionProcessesApi.getDetails(
projectId,
setupProcess.id
);
if (result !== undefined) {
runningProcessDetails[setupProcess.id] = result;
}
}
setAttemptData((prev) => {
const newData = {
activities: activitiesResult,
processes: processesResult,
runningProcessDetails,
};
if (JSON.stringify(prev) === JSON.stringify(newData)) return prev;
return newData;
});
}
} catch (err) {
console.error('Failed to fetch attempt data:', err);
@@ -331,7 +275,7 @@ const TaskDetailsProvider: FC<{
// Refresh diff when coding agent is running and making changes
useEffect(() => {
if (!executionState || !isOpen || !selectedAttempt) return;
if (!executionState || !selectedAttempt) return;
const isCodingAgentRunning =
executionState.execution_state === 'CodingAgentRunning';
@@ -349,11 +293,11 @@ const TaskDetailsProvider: FC<{
clearInterval(interval);
};
}
}, [executionState, isOpen, selectedAttempt, fetchDiff]);
}, [executionState, selectedAttempt, fetchDiff]);
// Refresh diff when coding agent completes or changes state
useEffect(() => {
if (!executionState?.execution_state || !isOpen || !selectedAttempt) return;
if (!executionState?.execution_state || !selectedAttempt) return;
const isCodingAgentComplete =
executionState.execution_state === 'CodingAgentComplete';
@@ -376,7 +320,6 @@ const TaskDetailsProvider: FC<{
}, [
executionState?.execution_state,
executionState?.has_changes,
isOpen,
selectedAttempt,
fetchDiff,
activeTab,

View File

@@ -1,4 +1,4 @@
import { useState, useEffect, useCallback } from 'react';
import { useCallback, useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Button } from '@/components/ui/button';
import {
@@ -10,18 +10,18 @@ import {
} from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { ProjectWithBranch, ApiResponse } from 'shared/types';
import { ProjectWithBranch } from 'shared/types';
import { ProjectForm } from './project-form';
import { makeRequest } from '@/lib/api';
import { projectsApi } from '@/lib/api';
import {
ArrowLeft,
Edit,
Trash2,
Calendar,
Clock,
AlertCircle,
Loader2,
ArrowLeft,
Calendar,
CheckSquare,
Clock,
Edit,
Loader2,
Trash2,
} from 'lucide-react';
interface ProjectDetailProps {
@@ -39,22 +39,17 @@ export function ProjectDetail({ projectId, onBack }: ProjectDetailProps) {
const fetchProject = useCallback(async () => {
setLoading(true);
setError('');
try {
const response = await makeRequest(
`/api/projects/${projectId}/with-branch`
);
const data: ApiResponse<ProjectWithBranch> = await response.json();
if (data.success && data.data) {
setProject(data.data);
} else {
setError('Project not found');
}
const result = await projectsApi.getWithBranch(projectId);
setProject(result);
} catch (error) {
console.error('Failed to fetch project:', error);
setError('Failed to load project');
} finally {
setLoading(false);
// @ts-expect-error it is type ApiError
setError(error.message || 'Failed to load project');
}
setLoading(false);
}, [projectId]);
const handleDelete = async () => {
@@ -67,15 +62,12 @@ export function ProjectDetail({ projectId, onBack }: ProjectDetailProps) {
return;
try {
const response = await makeRequest(`/api/projects/${projectId}`, {
method: 'DELETE',
});
if (response.ok) {
onBack();
}
await projectsApi.delete(projectId);
onBack();
} catch (error) {
console.error('Failed to delete project:', error);
setError('Failed to delete project');
// @ts-expect-error it is type ApiError
setError(error.message || 'Failed to delete project');
}
};

View File

@@ -1,4 +1,4 @@
import { useState, useEffect } from 'react';
import { useEffect, useState } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
@@ -12,9 +12,9 @@ import {
DialogTitle,
} from '@/components/ui/dialog';
import { FolderPicker } from '@/components/ui/folder-picker';
import { Project, CreateProject, UpdateProject } from 'shared/types';
import { CreateProject, Project, UpdateProject } from 'shared/types';
import { AlertCircle, Folder } from 'lucide-react';
import { makeRequest } from '@/lib/api';
import { projectsApi } from '@/lib/api';
interface ProjectFormProps {
open: boolean;
@@ -95,18 +95,12 @@ export function ProjectForm({
setup_script: setupScript.trim() || null,
dev_script: devScript.trim() || null,
};
const response = await makeRequest(`/api/projects/${project.id}`, {
method: 'PUT',
body: JSON.stringify(updateData),
});
if (!response.ok) {
throw new Error('Failed to update project');
}
const data = await response.json();
if (!data.success) {
throw new Error(data.message || 'Failed to update project');
try {
await projectsApi.update(project.id, updateData);
} catch (error) {
setError('Failed to update project');
return;
}
} else {
const createData: CreateProject = {
@@ -116,18 +110,12 @@ export function ProjectForm({
setup_script: setupScript.trim() || null,
dev_script: devScript.trim() || null,
};
const response = await makeRequest('/api/projects', {
method: 'POST',
body: JSON.stringify(createData),
});
if (!response.ok) {
throw new Error('Failed to create project');
}
const data = await response.json();
if (!data.success) {
throw new Error(data.message || 'Failed to create project');
try {
await projectsApi.create(createData);
} catch (error) {
setError('Failed to create project');
return;
}
}

View File

@@ -1,4 +1,4 @@
import { useState, useEffect } from 'react';
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Button } from '@/components/ui/button';
import {
@@ -10,19 +10,19 @@ import {
} from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Project, ApiResponse } from 'shared/types';
import { Project } from 'shared/types';
import { ProjectForm } from './project-form';
import { makeRequest } from '@/lib/api';
import { projectsApi } from '@/lib/api';
import {
Plus,
Edit,
Trash2,
Calendar,
AlertCircle,
Loader2,
MoreHorizontal,
Calendar,
Edit,
ExternalLink,
FolderOpen,
Loader2,
MoreHorizontal,
Plus,
Trash2,
} from 'lucide-react';
import {
DropdownMenu,
@@ -42,17 +42,13 @@ export function ProjectList() {
const fetchProjects = async () => {
setLoading(true);
setError('');
try {
const response = await makeRequest('/api/projects');
const data: ApiResponse<Project[]> = await response.json();
if (data.success && data.data) {
setProjects(data.data);
} else {
setError('Failed to load projects');
}
const result = await projectsApi.getAll();
setProjects(result);
} catch (error) {
console.error('Failed to fetch projects:', error);
setError('Failed to connect to server');
setError('Failed to fetch projects');
} finally {
setLoading(false);
}
@@ -67,12 +63,8 @@ export function ProjectList() {
return;
try {
const response = await makeRequest(`/api/projects/${id}`, {
method: 'DELETE',
});
if (response.ok) {
fetchProjects();
}
await projectsApi.delete(id);
fetchProjects();
} catch (error) {
console.error('Failed to delete project:', error);
setError('Failed to delete project');
@@ -86,20 +78,7 @@ export function ProjectList() {
const handleOpenInIDE = async (projectId: string) => {
try {
const response = await makeRequest(
`/api/projects/${projectId}/open-editor`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(null),
}
);
if (!response.ok) {
throw new Error('Failed to open project in IDE');
}
await projectsApi.openEditor(projectId);
} catch (error) {
console.error('Failed to open project in IDE:', error);
setError('Failed to open project in IDE');

View File

@@ -7,9 +7,8 @@ import {
DialogTitle,
} from '@/components/ui/dialog.tsx';
import { Button } from '@/components/ui/button.tsx';
import { makeRequest } from '@/lib/api.ts';
import { attemptsApi } from '@/lib/api.ts';
import { useContext } from 'react';
import { ApiResponse } from 'shared/types.ts';
import {
TaskDeletingFilesContext,
TaskDetailsContext,
@@ -28,29 +27,19 @@ function DeleteFileConfirmationDialog() {
if (!fileToDelete || !projectId || !task?.id || !selectedAttempt?.id)
return;
try {
setDeletingFiles((prev) => new Set(prev).add(fileToDelete));
const response = await makeRequest(
`/api/projects/${projectId}/tasks/${selectedAttempt.task_id}/attempts/${selectedAttempt.id}/delete-file?file_path=${encodeURIComponent(
fileToDelete
)}`,
{
method: 'POST',
}
);
setDeletingFiles((prev) => new Set(prev).add(fileToDelete));
if (response.ok) {
const result: ApiResponse<null> = await response.json();
if (result.success) {
fetchDiff();
} else {
setDiffError(result.message || 'Failed to delete file');
}
} else {
setDiffError('Failed to delete file');
}
} catch (err) {
setDiffError('Failed to delete file');
try {
await attemptsApi.deleteFile(
projectId!,
selectedAttempt.task_id,
selectedAttempt.id,
fileToDelete
);
await fetchDiff();
} catch (error: unknown) {
// @ts-expect-error it is type ApiError
setDiffError(error.message || 'Failed to delete file');
} finally {
setDeletingFiles((prev) => {
const newSet = new Set(prev);

View File

@@ -3,7 +3,7 @@ import { ChevronDown, ChevronUp, Clock, Code } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { Chip } from '@/components/ui/chip';
import { NormalizedConversationViewer } from './TaskDetails/NormalizedConversationViewer.tsx';
import { NormalizedConversationViewer } from './TaskDetails/LogsTab/NormalizedConversationViewer.tsx';
import type {
ExecutionProcess,
TaskAttempt,

View File

@@ -4,6 +4,7 @@ import { GitCompare } from 'lucide-react';
import type { WorktreeDiff } from 'shared/types.ts';
import { TaskBackgroundRefreshContext } from '@/components/context/taskDetailsContext.ts';
import DiffFile from '@/components/tasks/TaskDetails/DiffFile.tsx';
import { Loader } from '@/components/ui/loader';
interface DiffCardProps {
diff: WorktreeDiff | null;
@@ -57,10 +58,7 @@ export function DiffCard({
</div>
{isBackgroundRefreshing && (
<div className="flex items-center gap-1">
<div className="animate-spin h-3 w-3 border border-blue-500 border-t-transparent rounded-full"></div>
<span className="text-xs text-blue-600 dark:text-blue-400">
Updating...
</span>
<Loader size={12} className="mr-1" message="Updating..." />
</div>
)}
</div>

View File

@@ -1,6 +1,7 @@
import { DiffCard } from '@/components/tasks/TaskDetails/DiffCard.tsx';
import { useContext } from 'react';
import { TaskDiffContext } from '@/components/context/taskDetailsContext.ts';
import { Loader } from '@/components/ui/loader';
function DiffTab() {
const { diff, diffLoading, diffError } = useContext(TaskDiffContext);
@@ -8,8 +9,7 @@ function DiffTab() {
if (diffLoading) {
return (
<div className="flex items-center justify-center h-32">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-foreground mx-auto mb-4"></div>
<p className="text-muted-foreground ml-4">Loading changes...</p>
<Loader message="Loading changes..." size={32} />
</div>
);
}

View File

@@ -1,13 +1,15 @@
import { useCallback, useContext, useEffect, useRef, useState } from 'react';
import { useContext } from 'react';
import { MessageSquare } from 'lucide-react';
import { NormalizedConversationViewer } from '@/components/tasks/TaskDetails/NormalizedConversationViewer.tsx';
import { NormalizedConversationViewer } from '@/components/tasks/TaskDetails/LogsTab/NormalizedConversationViewer.tsx';
import {
TaskAttemptDataContext,
TaskAttemptLoadingContext,
TaskExecutionStateContext,
TaskSelectedAttemptContext,
} from '@/components/context/taskDetailsContext.ts';
import Conversation from '@/components/tasks/TaskDetails/Conversation.tsx';
import Conversation from '@/components/tasks/TaskDetails/LogsTab/Conversation.tsx';
import { Loader } from '@/components/ui/loader';
import SetupScriptRunning from '@/components/tasks/TaskDetails/LogsTab/SetupScriptRunning.tsx';
function LogsTab() {
const { loading } = useContext(TaskAttemptLoadingContext);
@@ -15,27 +17,10 @@ function LogsTab() {
const { selectedAttempt } = useContext(TaskSelectedAttemptContext);
const { attemptData } = useContext(TaskAttemptDataContext);
const [conversationUpdateTrigger, setConversationUpdateTrigger] = useState(0);
const setupScrollRef = useRef<HTMLDivElement>(null);
// Auto-scroll setup script logs to bottom
useEffect(() => {
if (setupScrollRef.current) {
setupScrollRef.current.scrollTop = setupScrollRef.current.scrollHeight;
}
}, [attemptData.runningProcessDetails]);
// Callback to trigger auto-scroll when conversation updates
const handleConversationUpdate = useCallback(() => {
setConversationUpdateTrigger((prev) => prev + 1);
}, []);
if (loading) {
return (
<div className="flex items-center justify-center h-full">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-foreground mx-auto mb-4"></div>
<p className="text-muted-foreground ml-4">Loading...</p>
<Loader message="Loading..." size={32} />
</div>
);
}
@@ -80,30 +65,11 @@ function LogsTab() {
// When setup script is running, show setup execution stdio
if (isSetupRunning) {
// Find the setup script process in runningProcessDetails first, then fallback to processes
const setupProcess = executionState.setup_process_id
? attemptData.runningProcessDetails[executionState.setup_process_id]
: Object.values(attemptData.runningProcessDetails).find(
(process) => process.process_type === 'setupscript'
);
return (
<div ref={setupScrollRef} className="h-full overflow-y-auto">
<div className="mb-4">
<p className="text-lg font-semibold mb-2">Setup Script Running</p>
<p className="text-muted-foreground mb-4">
Preparing the environment for the coding agent...
</p>
</div>
{setupProcess && (
<div className="font-mono text-sm whitespace-pre-wrap text-muted-foreground">
{[setupProcess.stdout || '', setupProcess.stderr || '']
.filter(Boolean)
.join('\n') || 'Waiting for setup script output...'}
</div>
)}
</div>
<SetupScriptRunning
setupProcessId={executionState.setup_process_id}
runningProcessDetails={attemptData.runningProcessDetails}
/>
);
}
@@ -127,10 +93,7 @@ function LogsTab() {
</div>
{setupProcess && (
<NormalizedConversationViewer
executionProcess={setupProcess}
onConversationUpdate={handleConversationUpdate}
/>
<NormalizedConversationViewer executionProcess={setupProcess} />
)}
</div>
);
@@ -158,10 +121,7 @@ function LogsTab() {
</div>
{codingAgentProcess && (
<NormalizedConversationViewer
executionProcess={codingAgentProcess}
onConversationUpdate={handleConversationUpdate}
/>
<NormalizedConversationViewer executionProcess={codingAgentProcess} />
)}
</div>
);
@@ -199,12 +159,7 @@ function LogsTab() {
// When coding agent is running or complete, show conversation
if (isCodingAgentRunning || isCodingAgentComplete || hasChanges) {
return (
<Conversation
conversationUpdateTrigger={conversationUpdateTrigger}
handleConversationUpdate={handleConversationUpdate}
/>
);
return <Conversation />;
}
// Default case - unexpected state

View File

@@ -1,4 +1,4 @@
import { NormalizedConversationViewer } from '@/components/tasks/TaskDetails/NormalizedConversationViewer.tsx';
import { NormalizedConversationViewer } from '@/components/tasks/TaskDetails/LogsTab/NormalizedConversationViewer.tsx';
import {
useCallback,
useContext,
@@ -8,21 +8,20 @@ import {
useState,
} from 'react';
import { TaskAttemptDataContext } from '@/components/context/taskDetailsContext.ts';
import { Loader } from '@/components/ui/loader.tsx';
type Props = {
conversationUpdateTrigger: number;
handleConversationUpdate: () => void;
};
function Conversation({
conversationUpdateTrigger,
handleConversationUpdate,
}: Props) {
function Conversation() {
const { attemptData } = useContext(TaskAttemptDataContext);
const [shouldAutoScrollLogs, setShouldAutoScrollLogs] = useState(true);
const [conversationUpdateTrigger, setConversationUpdateTrigger] = useState(0);
const scrollContainerRef = useRef<HTMLDivElement>(null);
// Callback to trigger auto-scroll when conversation updates
const handleConversationUpdate = useCallback(() => {
setConversationUpdateTrigger((prev) => prev + 1);
}, []);
useEffect(() => {
if (shouldAutoScrollLogs && scrollContainerRef.current) {
scrollContainerRef.current.scrollTop =
@@ -130,11 +129,17 @@ function Conversation({
))}
</div>
) : (
<div className="text-center py-8 text-muted-foreground">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500 mx-auto mb-4"></div>
<p className="text-lg font-semibold mb-2">Coding Agent Starting</p>
<p>Initializing conversation...</p>
</div>
<Loader
message={
<>
Coding Agent Starting
<br />
Initializing conversation...
</>
}
size={48}
className="py-8"
/>
)}
</div>
);

View File

@@ -1,9 +1,9 @@
import { useCallback, useContext, useEffect, useMemo, useState } from 'react';
import { Bot, Hammer, ToggleLeft, ToggleRight } from 'lucide-react';
import { makeRequest } from '@/lib/api.ts';
import { Loader } from '@/components/ui/loader.tsx';
import { executionProcessesApi } from '@/lib/api.ts';
import { MarkdownRenderer } from '@/components/ui/markdown-renderer.tsx';
import type {
ApiResponse,
ExecutionProcess,
NormalizedConversation,
NormalizedEntry,
@@ -132,37 +132,22 @@ export function NormalizedConversationViewer({
setLoading(true);
setError(null);
}
const response = await makeRequest(
`/api/projects/${projectId}/execution-processes/${executionProcess.id}/normalized-logs`
const result = await executionProcessesApi.getNormalizedLogs(
projectId,
executionProcess.id
);
if (response.ok) {
const result: ApiResponse<NormalizedConversation> =
await response.json();
if (result.success && result.data) {
setConversation((prev) => {
// Only update if content actually changed
if (
!prev ||
JSON.stringify(prev) !== JSON.stringify(result.data)
) {
// Notify parent component of conversation update
if (onConversationUpdate) {
// Use setTimeout to ensure state update happens first
setTimeout(onConversationUpdate, 0);
}
return result.data;
}
return prev;
});
} else if (!isPolling) {
setError(result.message || 'Failed to fetch normalized logs');
setConversation((prev) => {
// Only update if content actually changed
if (!prev || JSON.stringify(prev) !== JSON.stringify(result)) {
// Notify parent component of conversation update
if (onConversationUpdate) {
// Use setTimeout to ensure state update happens first
setTimeout(onConversationUpdate, 0);
}
return result;
}
} else if (!isPolling) {
const errorText = await response.text();
setError(`Failed to fetch logs: ${errorText || response.statusText}`);
}
return prev;
});
} catch (err) {
if (!isPolling) {
setError(
@@ -216,9 +201,7 @@ export function NormalizedConversationViewer({
if (loading) {
return (
<div className="text-xs text-muted-foreground italic text-center">
Loading conversation...
</div>
<Loader message="Loading conversation..." size={24} className="py-4" />
);
}
@@ -299,7 +282,7 @@ export function NormalizedConversationViewer({
<div className="space-y-2">
{displayEntries.map((entry, index) => (
<DisplayConversationEntry
key={index}
key={entry.timestamp || index}
entry={entry}
index={index}
diffDeletable={diffDeletable}

View File

@@ -0,0 +1,49 @@
import { useEffect, useMemo, useRef } from 'react';
import { ExecutionProcess } from 'shared/types.ts';
type Props = {
setupProcessId: string | null;
runningProcessDetails: Record<string, ExecutionProcess>;
};
function SetupScriptRunning({ setupProcessId, runningProcessDetails }: Props) {
const setupScrollRef = useRef<HTMLDivElement>(null);
// Auto-scroll setup script logs to bottom
useEffect(() => {
if (setupScrollRef.current) {
setupScrollRef.current.scrollTop = setupScrollRef.current.scrollHeight;
}
}, [runningProcessDetails]);
const setupProcess = useMemo(
() =>
setupProcessId
? runningProcessDetails[setupProcessId]
: Object.values(runningProcessDetails).find(
(process) => process.process_type === 'setupscript'
),
[setupProcessId, runningProcessDetails]
);
return (
<div ref={setupScrollRef} className="h-full overflow-y-auto">
<div className="mb-4">
<p className="text-lg font-semibold mb-2">Setup Script Running</p>
<p className="text-muted-foreground mb-4">
Preparing the environment for the coding agent...
</p>
</div>
{setupProcess && (
<div className="font-mono text-sm whitespace-pre-wrap text-muted-foreground">
{[setupProcess.stdout || '', setupProcess.stderr || '']
.filter(Boolean)
.join('\n') || 'Waiting for setup script output...'}
</div>
)}
</div>
);
}
export default SetupScriptRunning;

View File

@@ -18,7 +18,6 @@ interface TaskDetailsPanelProps {
task: TaskWithAttemptStatus | null;
projectHasDevScript?: boolean;
projectId: string;
isOpen: boolean;
onClose: () => void;
onEditTask?: (task: TaskWithAttemptStatus) => void;
onDeleteTask?: (taskId: string) => void;
@@ -29,7 +28,6 @@ export function TaskDetailsPanel({
task,
projectHasDevScript,
projectId,
isOpen,
onClose,
onEditTask,
onDeleteTask,
@@ -51,7 +49,7 @@ export function TaskDetailsPanel({
// Handle ESC key locally to prevent global navigation
useEffect(() => {
if (!isOpen || isDialogOpen) return;
if (isDialogOpen) return;
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
@@ -63,18 +61,17 @@ export function TaskDetailsPanel({
document.addEventListener('keydown', handleKeyDown, true);
return () => document.removeEventListener('keydown', handleKeyDown, true);
}, [isOpen, onClose, isDialogOpen]);
}, [onClose, isDialogOpen]);
return (
<>
{!task || !isOpen ? null : (
{!task ? null : (
<TaskDetailsProvider
task={task}
projectId={projectId}
setShowEditorDialog={setShowEditorDialog}
activeTab={activeTab}
setActiveTab={setActiveTab}
isOpen={isOpen}
userSelectedTab={userSelectedTab}
projectHasDevScript={projectHasDevScript}
>

View File

@@ -2,8 +2,8 @@ import { useCallback, useContext, useEffect, useState } from 'react';
import { Play } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { useConfig } from '@/components/config-provider';
import { makeRequest } from '@/lib/api';
import type { ApiResponse, GitBranch, TaskAttempt } from 'shared/types';
import { attemptsApi, projectsApi } from '@/lib/api';
import type { GitBranch, TaskAttempt } from 'shared/types';
import {
TaskAttemptDataContext,
TaskAttemptLoadingContext,
@@ -61,21 +61,13 @@ function TaskDetailsToolbar() {
const [error, setError] = useState<string | null>(null);
const fetchProjectBranches = useCallback(async () => {
try {
const response = await makeRequest(`/api/projects/${projectId}/branches`);
if (response.ok) {
const result: ApiResponse<GitBranch[]> = await response.json();
if (result.success && result.data) {
setBranches(result.data);
// Set current branch as default
const currentBranch = result.data.find((b) => b.is_current);
if (currentBranch && !selectedBranch) {
setSelectedBranch(currentBranch.name);
}
}
}
} catch (err) {
console.error('Failed to fetch project branches:', err);
const result = await projectsApi.getBranches(projectId);
setBranches(result);
// Set current branch as default
const currentBranch = result.find((b) => b.is_current);
if (currentBranch && !selectedBranch) {
setSelectedBranch(currentBranch.name);
}
}, [projectId, selectedBranch]);
@@ -127,44 +119,36 @@ function TaskDetailsToolbar() {
try {
setLoading(true);
const response = await makeRequest(
`/api/projects/${projectId}/tasks/${task.id}/attempts`
);
const result = await attemptsApi.getAll(projectId, task.id);
if (response.ok) {
const result: ApiResponse<TaskAttempt[]> = await response.json();
if (result.success && result.data) {
setTaskAttempts((prev) => {
if (JSON.stringify(prev) === JSON.stringify(result.data))
return prev;
return result.data || prev;
});
setTaskAttempts((prev) => {
if (JSON.stringify(prev) === JSON.stringify(result)) return prev;
return result || prev;
});
if (result.data.length > 0) {
const latestAttempt = result.data.reduce((latest, current) =>
new Date(current.created_at) > new Date(latest.created_at)
? current
: latest
);
setSelectedAttempt((prev) => {
if (JSON.stringify(prev) === JSON.stringify(latestAttempt))
return prev;
return latestAttempt;
});
fetchAttemptData(latestAttempt.id, latestAttempt.task_id);
fetchExecutionState(latestAttempt.id, latestAttempt.task_id);
} else {
setSelectedAttempt(null);
setAttemptData({
activities: [],
processes: [],
runningProcessDetails: {},
});
}
}
if (result.length > 0) {
const latestAttempt = result.reduce((latest, current) =>
new Date(current.created_at) > new Date(latest.created_at)
? current
: latest
);
setSelectedAttempt((prev) => {
if (JSON.stringify(prev) === JSON.stringify(latestAttempt))
return prev;
return latestAttempt;
});
fetchAttemptData(latestAttempt.id, latestAttempt.task_id);
fetchExecutionState(latestAttempt.id, latestAttempt.task_id);
} else {
setSelectedAttempt(null);
setAttemptData({
activities: [],
processes: [],
runningProcessDetails: {},
});
}
} catch (err) {
console.error('Failed to fetch task attempts:', err);
} catch (error) {
// we already logged error
} finally {
setLoading(false);
}

View File

@@ -3,12 +3,13 @@ import { Button } from '@/components/ui/button';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { FileSearchTextarea } from '@/components/ui/file-search-textarea';
import { useContext, useMemo, useState } from 'react';
import { makeRequest } from '@/lib/api.ts';
import { attemptsApi } from '@/lib/api.ts';
import {
TaskAttemptDataContext,
TaskDetailsContext,
TaskSelectedAttemptContext,
} from '@/components/context/taskDetailsContext.ts';
import { Loader } from '@/components/ui/loader';
export function TaskFollowUpSection() {
const { task, projectId } = useContext(TaskDetailsContext);
@@ -49,36 +50,19 @@ export function TaskFollowUpSection() {
try {
setIsSendingFollowUp(true);
setFollowUpError(null);
const response = await makeRequest(
`/api/projects/${projectId}/tasks/${selectedAttempt.task_id}/attempts/${selectedAttempt.id}/follow-up`,
await attemptsApi.followUp(
projectId!,
selectedAttempt.task_id,
selectedAttempt.id,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
prompt: followUpMessage.trim(),
}),
prompt: followUpMessage.trim(),
}
);
if (response.ok) {
setFollowUpMessage('');
fetchAttemptData(selectedAttempt.id, selectedAttempt.task_id);
} else {
const errorText = await response.text();
setFollowUpError(
`Failed to start follow-up execution: ${
errorText || response.statusText
}`
);
}
} catch (err) {
setFollowUpError(
`Failed to send follow-up: ${
err instanceof Error ? err.message : 'Unknown error'
}`
);
setFollowUpMessage('');
fetchAttemptData(selectedAttempt.id, selectedAttempt.task_id);
} catch (error: unknown) {
// @ts-expect-error it is type ApiError
setFollowUpError(`Failed to start follow-up execution: ${error.message}`);
} finally {
setIsSendingFollowUp(false);
}
@@ -127,7 +111,7 @@ export function TaskFollowUpSection() {
size="sm"
>
{isSendingFollowUp ? (
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-current" />
<Loader size={16} className="mr-2" />
) : (
<>
<Send className="h-4 w-4 mr-2" />

View File

@@ -17,7 +17,7 @@ import {
} from '@/components/ui/dropdown-menu.tsx';
import { Input } from '@/components/ui/input.tsx';
import type { GitBranch, TaskAttempt } from 'shared/types.ts';
import { makeRequest } from '@/lib/api.ts';
import { attemptsApi } from '@/lib/api.ts';
import {
TaskAttemptDataContext,
TaskDetailsContext,
@@ -72,22 +72,13 @@ function CreateAttempt({
const onCreateNewAttempt = async (executor?: string, baseBranch?: string) => {
try {
const response = await makeRequest(
`/api/projects/${projectId}/tasks/${task.id}/attempts`,
{
method: 'POST',
body: JSON.stringify({
executor: executor || selectedExecutor,
base_branch: baseBranch || selectedBranch,
}),
}
);
if (response.ok) {
fetchTaskAttempts();
}
} catch (err) {
console.error('Failed to create new attempt:', err);
await attemptsApi.create(projectId!, task.id, {
executor: executor || selectedExecutor,
base_branch: baseBranch || selectedBranch,
});
fetchTaskAttempts();
} catch (error) {
// Optionally handle error
}
};

View File

@@ -22,9 +22,9 @@ import {
TaskDetailsContext,
TaskSelectedAttemptContext,
} from '@/components/context/taskDetailsContext.ts';
import { makeRequest } from '@/lib/api.ts';
import { ApiError, attemptsApi } from '@/lib/api.ts';
import { ProvidePatDialog } from '@/components/ProvidePatDialog';
import { ApiResponse, GitBranch } from 'shared/types.ts';
import { GitBranch } from 'shared/types.ts';
type Props = {
showCreatePRDialog: boolean;
@@ -71,59 +71,51 @@ function CreatePrDialog({
const handleConfirmCreatePR = useCallback(async () => {
if (!projectId || !selectedAttempt?.id || !selectedAttempt?.task_id) return;
setCreatingPR(true);
try {
setCreatingPR(true);
const response = await makeRequest(
`/api/projects/${projectId}/tasks/${selectedAttempt.task_id}/attempts/${selectedAttempt.id}/create-pr`,
const prUrl = await attemptsApi.createPR(
projectId!,
selectedAttempt.task_id,
selectedAttempt.id,
{
method: 'POST',
body: JSON.stringify({
title: prTitle,
body: prBody || null,
base_branch: prBaseBranch || null,
}),
title: prTitle,
body: prBody || null,
base_branch: prBaseBranch || null,
}
);
if (response.ok) {
const result: ApiResponse<string> = await response.json();
console.log(result);
if (result.success && result.data) {
// Open the PR URL in a new tab
window.open(result.data, '_blank');
setShowCreatePRDialog(false);
// Reset form
setPrTitle('');
setPrBody('');
setPrBaseBranch(selectedAttempt?.base_branch || 'main');
} else if (result.message === 'insufficient_github_permissions') {
setShowCreatePRDialog(false);
setPatDialogError(null);
setShowPatDialog(true);
} else if (result.message === 'github_repo_not_found_or_no_access') {
setShowCreatePRDialog(false);
setPatDialogError(
'Your token does not have access to this repository, or the repository does not exist. Please check the repository URL and/or provide a Personal Access Token with access.'
);
setShowPatDialog(true);
} else {
setError(result.message || 'Failed to create GitHub PR');
}
} else if (response.status === 403) {
// Open the PR URL in a new tab
window.open(prUrl, '_blank');
setShowCreatePRDialog(false);
// Reset form
setPrTitle('');
setPrBody('');
setPrBaseBranch(selectedAttempt?.base_branch || 'main');
} catch (err) {
const error = err as ApiError;
if (error.message === 'insufficient_github_permissions') {
setShowCreatePRDialog(false);
setPatDialogError(null);
setShowPatDialog(true);
} else if (response.status === 404) {
} else if (error.message === 'github_repo_not_found_or_no_access') {
setShowCreatePRDialog(false);
setPatDialogError(
'Your token does not have access to this repository, or the repository does not exist. Please check the repository URL and/or provide a Personal Access Token with access.'
);
setShowPatDialog(true);
} else if (error.status === 403) {
setShowCreatePRDialog(false);
setPatDialogError(null);
setShowPatDialog(true);
} else if (error.status === 404) {
setShowCreatePRDialog(false);
setPatDialogError(
'Your token does not have access to this repository, or the repository does not exist. Please check the repository URL and/or provide a Personal Access Token with access.'
);
setShowPatDialog(true);
} else {
setError('Failed to create GitHub PR');
setError(error.message || 'Failed to create GitHub PR');
}
} catch (err) {
setError('Failed to create GitHub PR');
} finally {
setCreatingPR(false);
}
@@ -136,6 +128,8 @@ function CreatePrDialog({
setCreatingPR,
setError,
setShowCreatePRDialog,
setPatDialogError,
setShowPatDialog,
]);
const handleCancelCreatePR = useCallback(() => {

View File

@@ -21,7 +21,7 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu.tsx';
import { makeRequest } from '@/lib/api.ts';
import { attemptsApi, executionProcessesApi } from '@/lib/api.ts';
import {
Dispatch,
SetStateAction,
@@ -32,7 +32,6 @@ import {
useState,
} from 'react';
import type {
ApiResponse,
BranchStatus,
ExecutionProcess,
TaskAttempt,
@@ -132,15 +131,11 @@ function CurrentAttempt({
if (!runningDevServer || !task || !selectedAttempt) return;
try {
const response = await makeRequest(
`/api/projects/${projectId}/execution-processes/${runningDevServer.id}`
const result = await executionProcessesApi.getDetails(
projectId,
runningDevServer.id
);
if (response.ok) {
const result: ApiResponse<ExecutionProcess> = await response.json();
if (result.success && result.data) {
setDevServerDetails(result.data);
}
}
setDevServerDetails(result);
} catch (err) {
console.error('Failed to fetch dev server details:', err);
}
@@ -163,23 +158,11 @@ function CurrentAttempt({
setIsStartingDevServer(true);
try {
const response = await makeRequest(
`/api/projects/${projectId}/tasks/${selectedAttempt.task_id}/attempts/${selectedAttempt.id}/start-dev-server`,
{
method: 'POST',
}
await attemptsApi.startDevServer(
projectId,
selectedAttempt.task_id,
selectedAttempt.id
);
if (!response.ok) {
throw new Error('Failed to start dev server');
}
const data: ApiResponse<null> = await response.json();
if (!data.success) {
throw new Error(data.message || 'Failed to start dev server');
}
fetchAttemptData(selectedAttempt.id, selectedAttempt.task_id);
} catch (err) {
console.error('Failed to start dev server:', err);
@@ -194,17 +177,12 @@ function CurrentAttempt({
setIsStartingDevServer(true);
try {
const response = await makeRequest(
`/api/projects/${projectId}/tasks/${selectedAttempt.task_id}/attempts/${selectedAttempt.id}/execution-processes/${runningDevServer.id}/stop`,
{
method: 'POST',
}
await attemptsApi.stopExecutionProcess(
projectId,
selectedAttempt.task_id,
selectedAttempt.id,
runningDevServer.id
);
if (!response.ok) {
throw new Error('Failed to stop dev server');
}
fetchAttemptData(selectedAttempt.id, selectedAttempt.task_id);
} catch (err) {
console.error('Failed to stop dev server:', err);
@@ -218,19 +196,15 @@ function CurrentAttempt({
try {
setIsStopping(true);
const response = await makeRequest(
`/api/projects/${projectId}/tasks/${selectedAttempt.task_id}/attempts/${selectedAttempt.id}/stop`,
{
method: 'POST',
}
await attemptsApi.stop(
projectId,
selectedAttempt.task_id,
selectedAttempt.id
);
if (response.ok) {
await fetchAttemptData(selectedAttempt.id, selectedAttempt.task_id);
setTimeout(() => {
fetchAttemptData(selectedAttempt.id, selectedAttempt.task_id);
}, 1000);
}
await fetchAttemptData(selectedAttempt.id, selectedAttempt.task_id);
setTimeout(() => {
fetchAttemptData(selectedAttempt.id, selectedAttempt.task_id);
}, 1000);
} catch (err) {
console.error('Failed to stop executions:', err);
} finally {
@@ -259,30 +233,21 @@ function CurrentAttempt({
try {
setBranchStatusLoading(true);
const response = await makeRequest(
`/api/projects/${projectId}/tasks/${selectedAttempt.task_id}/attempts/${selectedAttempt.id}/branch-status`
const result = await attemptsApi.getBranchStatus(
projectId,
selectedAttempt.task_id,
selectedAttempt.id
);
if (response.ok) {
const result: ApiResponse<BranchStatus> = await response.json();
if (result.success && result.data) {
setBranchStatus((prev) => {
if (JSON.stringify(prev) === JSON.stringify(result.data))
return prev;
return result.data;
});
} else {
setError('Failed to load branch status');
}
} else {
setError('Failed to load branch status');
}
setBranchStatus((prev) => {
if (JSON.stringify(prev) === JSON.stringify(result)) return prev;
return result;
});
} catch (err) {
setError('Failed to load branch status');
} finally {
setBranchStatusLoading(false);
}
}, [projectId, selectedAttempt?.id, selectedAttempt?.task_id]);
}, [projectId, selectedAttempt?.id, selectedAttempt?.task_id, setError]);
// Fetch branch status when selected attempt changes
useEffect(() => {
@@ -296,26 +261,17 @@ function CurrentAttempt({
try {
setMerging(true);
const response = await makeRequest(
`/api/projects/${projectId}/tasks/${selectedAttempt.task_id}/attempts/${selectedAttempt.id}/merge`,
{
method: 'POST',
}
await attemptsApi.merge(
projectId,
selectedAttempt.task_id,
selectedAttempt.id
);
if (response.ok) {
const result: ApiResponse<string> = await response.json();
if (result.success) {
// Refetch branch status to show updated state
fetchBranchStatus();
} else {
setError(result.message || 'Failed to merge changes');
}
} else {
setError('Failed to merge changes');
}
} catch (err) {
setError('Failed to merge changes');
// Refetch branch status to show updated state
fetchBranchStatus();
} catch (error) {
console.error('Failed to merge changes:', error);
// @ts-expect-error it is type ApiError
setError(error.message || 'Failed to merge changes');
} finally {
setMerging(false);
}
@@ -326,24 +282,13 @@ function CurrentAttempt({
try {
setRebasing(true);
const response = await makeRequest(
`/api/projects/${projectId}/tasks/${selectedAttempt.task_id}/attempts/${selectedAttempt.id}/rebase`,
{
method: 'POST',
}
await attemptsApi.rebase(
projectId,
selectedAttempt.task_id,
selectedAttempt.id
);
if (response.ok) {
const result: ApiResponse<string> = await response.json();
if (result.success) {
// Refresh branch status after rebase
fetchBranchStatus();
} else {
setError(result.message || 'Failed to rebase branch');
}
} else {
setError('Failed to rebase branch');
}
// Refresh branch status after rebase
fetchBranchStatus();
} catch (err) {
setError('Failed to rebase branch');
} finally {

View File

@@ -1,8 +1,7 @@
import { KeyboardEvent, useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { Textarea } from '@/components/ui/textarea';
import { makeRequest } from '@/lib/api';
import { ApiResponse } from 'shared/types.ts';
import { projectsApi } from '@/lib/api';
interface FileSearchResult {
path: string;
@@ -51,19 +50,12 @@ export function FileSearchTextarea({
const searchFiles = async () => {
setIsLoading(true);
try {
const response = await makeRequest(
`/api/projects/${projectId}/search?q=${encodeURIComponent(searchQuery)}`
);
if (response.ok) {
const result: ApiResponse<FileSearchResult[]> = await response.json();
if (result.success && result.data) {
setSearchResults(result.data);
setShowDropdown(true);
setSelectedIndex(-1);
}
}
try {
const result = await projectsApi.searchFiles(projectId, searchQuery);
setSearchResults(result);
setShowDropdown(true);
setSelectedIndex(-1);
} catch (error) {
console.error('Failed to search files:', error);
} finally {

View File

@@ -1,4 +1,4 @@
import React, { useState, useEffect, useMemo } from 'react';
import React, { useEffect, useMemo, useState } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
@@ -11,15 +11,15 @@ import {
} from '@/components/ui/dialog';
import { Alert, AlertDescription } from '@/components/ui/alert';
import {
AlertCircle,
ChevronUp,
File,
Folder,
FolderOpen,
File,
AlertCircle,
Home,
ChevronUp,
Search,
} from 'lucide-react';
import { makeRequest } from '@/lib/api';
import { fileSystemApi } from '@/lib/api';
import { DirectoryEntry } from 'shared/types';
interface FolderPickerProps {
@@ -65,25 +65,13 @@ export function FolderPicker({
setError('');
try {
const queryParam = path ? `?path=${encodeURIComponent(path)}` : '';
const response = await makeRequest(`/api/filesystem/list${queryParam}`);
if (!response.ok) {
throw new Error('Failed to load directory');
}
const data = await response.json();
if (data.success) {
setEntries(data.data || []);
const newPath = path || data.message || '';
setCurrentPath(newPath);
// Update manual path if we have a specific path (not for initial home directory load)
if (path) {
setManualPath(newPath);
}
} else {
setError(data.message || 'Failed to load directory');
const result = await fileSystemApi.list(path);
setEntries(result.entries || []);
const newPath = result.current_path || '';
setCurrentPath(newPath);
// Update manual path if we have a specific path (not for initial home directory load)
if (path) {
setManualPath(newPath);
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load directory');

View File

@@ -0,0 +1,24 @@
import { Loader2 } from 'lucide-react';
import React from 'react';
interface LoaderProps {
message?: string | React.ReactElement;
size?: number;
className?: string;
}
export const Loader: React.FC<LoaderProps> = ({
message,
size = 32,
className = '',
}) => (
<div className={`flex flex-col items-center justify-center ${className}`}>
<Loader2
className="animate-spin text-muted-foreground mb-2"
style={{ width: size, height: size }}
/>
{!!message && (
<div className="text-center text-muted-foreground">{message}</div>
)}
</div>
);

View File

@@ -1,3 +1,31 @@
// Import all necessary types from shared types
import {
BranchStatus,
Config,
CreateFollowUpAttempt,
CreateProject,
CreateTask,
CreateTaskAndStart,
CreateTaskAttempt,
DirectoryEntry,
type EditorType,
ExecutionProcess,
ExecutionProcessSummary,
GitBranch,
NormalizedConversation,
Project,
ProjectWithBranch,
StartGitHubDeviceFlowType,
Task,
TaskAttempt,
TaskAttemptActivityWithPrompt,
TaskAttemptState,
TaskWithAttemptStatus,
UpdateProject,
UpdateTask,
WorktreeDiff,
} from 'shared/types';
export const makeRequest = async (url: string, options: RequestInit = {}) => {
const headers = {
'Content-Type': 'application/json',
@@ -9,3 +37,521 @@ export const makeRequest = async (url: string, options: RequestInit = {}) => {
headers,
});
};
export interface ApiResponse<T> {
success: boolean;
data?: T;
message?: string;
}
// Additional interface for file search results
export interface FileSearchResult {
path: string;
name: string;
}
// Directory listing response
export interface DirectoryListResponse {
entries: DirectoryEntry[];
current_path: string;
}
export class ApiError extends Error {
constructor(
message: string,
public status?: number,
public response?: Response
) {
super(message);
this.name = 'ApiError';
}
}
const handleApiResponse = async <T>(response: Response): Promise<T> => {
if (!response.ok) {
let errorMessage = `Request failed with status ${response.status}`;
try {
const errorData = await response.json();
if (errorData.message) {
errorMessage = errorData.message;
}
} catch {
// Fallback to status text if JSON parsing fails
errorMessage = response.statusText || errorMessage;
}
console.error('[API Error]', {
message: errorMessage,
status: response.status,
response,
endpoint: response.url,
timestamp: new Date().toISOString(),
});
throw new ApiError(errorMessage, response.status, response);
}
const result: ApiResponse<T> = await response.json();
if (!result.success) {
console.error('[API Error]', {
message: result.message || 'API request failed',
status: response.status,
response,
endpoint: response.url,
timestamp: new Date().toISOString(),
});
throw new ApiError(result.message || 'API request failed');
}
return result.data as T;
};
// Project Management APIs
export const projectsApi = {
getAll: async (): Promise<Project[]> => {
const response = await makeRequest('/api/projects');
return handleApiResponse<Project[]>(response);
},
getById: async (id: string): Promise<Project> => {
const response = await makeRequest(`/api/projects/${id}`);
return handleApiResponse<Project>(response);
},
getWithBranch: async (id: string): Promise<ProjectWithBranch> => {
const response = await makeRequest(`/api/projects/${id}/with-branch`);
return handleApiResponse<ProjectWithBranch>(response);
},
create: async (data: CreateProject): Promise<Project> => {
const response = await makeRequest('/api/projects', {
method: 'POST',
body: JSON.stringify(data),
});
return handleApiResponse<Project>(response);
},
update: async (id: string, data: UpdateProject): Promise<Project> => {
const response = await makeRequest(`/api/projects/${id}`, {
method: 'PUT',
body: JSON.stringify(data),
});
return handleApiResponse<Project>(response);
},
delete: async (id: string): Promise<void> => {
const response = await makeRequest(`/api/projects/${id}`, {
method: 'DELETE',
});
return handleApiResponse<void>(response);
},
openEditor: async (id: string): Promise<void> => {
const response = await makeRequest(`/api/projects/${id}/open-editor`, {
method: 'POST',
});
return handleApiResponse<void>(response);
},
getBranches: async (id: string): Promise<GitBranch[]> => {
const response = await makeRequest(`/api/projects/${id}/branches`);
return handleApiResponse<GitBranch[]>(response);
},
searchFiles: async (
id: string,
query: string
): Promise<FileSearchResult[]> => {
const response = await makeRequest(
`/api/projects/${id}/search?q=${encodeURIComponent(query)}`
);
return handleApiResponse<FileSearchResult[]>(response);
},
};
// Task Management APIs
export const tasksApi = {
getAll: async (projectId: string): Promise<TaskWithAttemptStatus[]> => {
const response = await makeRequest(`/api/projects/${projectId}/tasks`);
return handleApiResponse<TaskWithAttemptStatus[]>(response);
},
create: async (projectId: string, data: CreateTask): Promise<Task> => {
const response = await makeRequest(`/api/projects/${projectId}/tasks`, {
method: 'POST',
body: JSON.stringify(data),
});
return handleApiResponse<Task>(response);
},
createAndStart: async (
projectId: string,
data: CreateTaskAndStart
): Promise<TaskWithAttemptStatus> => {
const response = await makeRequest(
`/api/projects/${projectId}/tasks/create-and-start`,
{
method: 'POST',
body: JSON.stringify(data),
}
);
return handleApiResponse<TaskWithAttemptStatus>(response);
},
update: async (
projectId: string,
taskId: string,
data: UpdateTask
): Promise<Task> => {
const response = await makeRequest(
`/api/projects/${projectId}/tasks/${taskId}`,
{
method: 'PUT',
body: JSON.stringify(data),
}
);
return handleApiResponse<Task>(response);
},
delete: async (projectId: string, taskId: string): Promise<void> => {
const response = await makeRequest(
`/api/projects/${projectId}/tasks/${taskId}`,
{
method: 'DELETE',
}
);
return handleApiResponse<void>(response);
},
};
// Task Attempts APIs
export const attemptsApi = {
getAll: async (projectId: string, taskId: string): Promise<TaskAttempt[]> => {
const response = await makeRequest(
`/api/projects/${projectId}/tasks/${taskId}/attempts`
);
return handleApiResponse<TaskAttempt[]>(response);
},
create: async (
projectId: string,
taskId: string,
data: CreateTaskAttempt
): Promise<TaskAttempt> => {
const response = await makeRequest(
`/api/projects/${projectId}/tasks/${taskId}/attempts`,
{
method: 'POST',
body: JSON.stringify(data),
}
);
return handleApiResponse<TaskAttempt>(response);
},
getState: async (
projectId: string,
taskId: string,
attemptId: string
): Promise<TaskAttemptState> => {
const response = await makeRequest(
`/api/projects/${projectId}/tasks/${taskId}/attempts/${attemptId}`
);
return handleApiResponse<TaskAttemptState>(response);
},
stop: async (
projectId: string,
taskId: string,
attemptId: string
): Promise<void> => {
const response = await makeRequest(
`/api/projects/${projectId}/tasks/${taskId}/attempts/${attemptId}/stop`,
{
method: 'POST',
}
);
return handleApiResponse<void>(response);
},
followUp: async (
projectId: string,
taskId: string,
attemptId: string,
data: CreateFollowUpAttempt
): Promise<void> => {
const response = await makeRequest(
`/api/projects/${projectId}/tasks/${taskId}/attempts/${attemptId}/follow-up`,
{
method: 'POST',
body: JSON.stringify(data),
}
);
return handleApiResponse<void>(response);
},
getActivities: async (
projectId: string,
taskId: string,
attemptId: string
): Promise<TaskAttemptActivityWithPrompt[]> => {
const response = await makeRequest(
`/api/projects/${projectId}/tasks/${taskId}/attempts/${attemptId}/activities`
);
return handleApiResponse<TaskAttemptActivityWithPrompt[]>(response);
},
getDiff: async (
projectId: string,
taskId: string,
attemptId: string
): Promise<WorktreeDiff> => {
const response = await makeRequest(
`/api/projects/${projectId}/tasks/${taskId}/attempts/${attemptId}/diff`
);
return handleApiResponse<WorktreeDiff>(response);
},
deleteFile: async (
projectId: string,
taskId: string,
attemptId: string,
fileToDelete: string
): Promise<void> => {
const response = await makeRequest(
`/api/projects/${projectId}/tasks/${taskId}/attempts/${attemptId}/delete-filefile_path=${encodeURIComponent(
fileToDelete
)}`,
{
method: 'POST',
}
);
return handleApiResponse<void>(response);
},
openEditor: async (
projectId: string,
taskId: string,
attemptId: string,
editorType?: EditorType
): Promise<void> => {
const response = await makeRequest(
`/api/projects/${projectId}/tasks/${taskId}/attempts/${attemptId}/open-editor`,
{
method: 'POST',
body: JSON.stringify(editorType ? { editor_type: editorType } : null),
}
);
return handleApiResponse<void>(response);
},
getBranchStatus: async (
projectId: string,
taskId: string,
attemptId: string
): Promise<BranchStatus> => {
const response = await makeRequest(
`/api/projects/${projectId}/tasks/${taskId}/attempts/${attemptId}/branch-status`
);
return handleApiResponse<BranchStatus>(response);
},
merge: async (
projectId: string,
taskId: string,
attemptId: string
): Promise<void> => {
const response = await makeRequest(
`/api/projects/${projectId}/tasks/${taskId}/attempts/${attemptId}/merge`,
{
method: 'POST',
}
);
return handleApiResponse<void>(response);
},
rebase: async (
projectId: string,
taskId: string,
attemptId: string
): Promise<void> => {
const response = await makeRequest(
`/api/projects/${projectId}/tasks/${taskId}/attempts/${attemptId}/rebase`,
{
method: 'POST',
}
);
return handleApiResponse<void>(response);
},
createPR: async (
projectId: string,
taskId: string,
attemptId: string,
data: {
title: string;
body: string | null;
base_branch: string | null;
}
): Promise<string> => {
const response = await makeRequest(
`/api/projects/${projectId}/tasks/${taskId}/attempts/${attemptId}/create-pr`,
{
method: 'POST',
body: JSON.stringify(data),
}
);
return handleApiResponse<string>(response);
},
startDevServer: async (
projectId: string,
taskId: string,
attemptId: string
): Promise<void> => {
const response = await makeRequest(
`/api/projects/${projectId}/tasks/${taskId}/attempts/${attemptId}/start-dev-server`,
{
method: 'POST',
}
);
return handleApiResponse<void>(response);
},
getExecutionProcesses: async (
projectId: string,
taskId: string,
attemptId: string
): Promise<ExecutionProcessSummary[]> => {
const response = await makeRequest(
`/api/projects/${projectId}/tasks/${taskId}/attempts/${attemptId}/execution-processes`
);
return handleApiResponse<ExecutionProcessSummary[]>(response);
},
stopExecutionProcess: async (
projectId: string,
taskId: string,
attemptId: string,
processId: string
): Promise<void> => {
const response = await makeRequest(
`/api/projects/${projectId}/tasks/${taskId}/attempts/${attemptId}/execution-processes/${processId}/stop`,
{
method: 'POST',
}
);
return handleApiResponse<void>(response);
},
};
// Execution Process APIs
export const executionProcessesApi = {
getDetails: async (
projectId: string,
processId: string
): Promise<ExecutionProcess> => {
const response = await makeRequest(
`/api/projects/${projectId}/execution-processes/${processId}`
);
return handleApiResponse<ExecutionProcess>(response);
},
getNormalizedLogs: async (
projectId: string,
processId: string
): Promise<NormalizedConversation> => {
const response = await makeRequest(
`/api/projects/${projectId}/execution-processes/${processId}/normalized-logs`
);
return handleApiResponse<NormalizedConversation>(response);
},
};
// File System APIs
export const fileSystemApi = {
list: async (path?: string): Promise<DirectoryListResponse> => {
const queryParam = path ? `?path=${encodeURIComponent(path)}` : '';
const response = await makeRequest(`/api/filesystem/list${queryParam}`);
return handleApiResponse<DirectoryListResponse>(response);
},
};
// Config APIs
export const configApi = {
getConfig: async (): Promise<Config> => {
const response = await makeRequest('/api/config');
return handleApiResponse<Config>(response);
},
saveConfig: async (config: Config): Promise<Config> => {
const response = await makeRequest('/api/config', {
method: 'POST',
body: JSON.stringify(config),
});
return handleApiResponse<Config>(response);
},
};
// GitHub Device Auth APIs
export const githubAuthApi = {
checkGithubToken: async (): Promise<boolean | undefined> => {
try {
const response = await makeRequest('/api/auth/github/check');
const result: ApiResponse<null> = await response.json();
if (!result.success && result.message === 'github_token_invalid') {
return false;
}
return result.success;
} catch (err) {
// On network/server error, return undefined (unknown)
return undefined;
}
},
start: async (): Promise<StartGitHubDeviceFlowType> => {
const response = await makeRequest('/api/auth/github/device/start', {
method: 'POST',
});
return handleApiResponse<StartGitHubDeviceFlowType>(response);
},
poll: async (device_code: string): Promise<string> => {
const response = await makeRequest('/api/auth/github/device/poll', {
method: 'POST',
body: JSON.stringify({ device_code }),
headers: { 'Content-Type': 'application/json' },
});
return handleApiResponse<string>(response);
},
};
// MCP Servers APIs
export const mcpServersApi = {
load: async (executor: string): Promise<any> => {
const response = await makeRequest(
`/api/mcp-servers?executor=${encodeURIComponent(executor)}`
);
return handleApiResponse<any>(response);
},
save: async (executor: string, serversConfig: any): Promise<void> => {
const response = await makeRequest(
`/api/mcp-servers?executor=${encodeURIComponent(executor)}`,
{
method: 'POST',
body: JSON.stringify(serversConfig),
}
);
if (!response.ok) {
const errorData = await response.json();
console.error('[API Error] Failed to save MCP servers', {
message: errorData.message,
status: response.status,
response,
timestamp: new Date().toISOString(),
});
throw new ApiError(
errorData.message || 'Failed to save MCP servers',
response.status,
response
);
}
},
};

View File

@@ -20,6 +20,7 @@ import { Textarea } from '@/components/ui/textarea';
import { Loader2 } from 'lucide-react';
import { EXECUTOR_TYPES, EXECUTOR_LABELS } from 'shared/types';
import { useConfig } from '@/components/config-provider';
import { mcpServersApi } from '../lib/api';
export function McpServers() {
const { config } = useConfig();
@@ -55,45 +56,31 @@ export function McpServers() {
try {
// Load MCP servers for the selected executor
const response = await fetch(
`/api/mcp-servers?executor=${executorType}`
);
if (response.ok) {
const result = await response.json();
if (result.success) {
// Handle new response format with servers and config_path
const data = result.data || {};
const servers = data.servers || {};
const configPath = data.config_path || '';
const result = await mcpServersApi.load(executorType);
// Handle new response format with servers and config_path
const data = result || {};
const servers = data.servers || {};
const configPath = data.config_path || '';
// Create the full configuration structure based on executor type
let fullConfig;
if (executorType === 'amp') {
// For AMP, use the amp.mcpServers structure
fullConfig = { 'amp.mcpServers': servers };
} else {
// For other executors, use the standard mcpServers structure
fullConfig = { mcpServers: servers };
}
const configJson = JSON.stringify(fullConfig, null, 2);
setMcpServers(configJson);
setMcpConfigPath(configPath);
}
// Create the full configuration structure based on executor type
let fullConfig;
if (executorType === 'amp') {
// For AMP, use the amp.mcpServers structure
fullConfig = { 'amp.mcpServers': servers };
} else {
const result = await response.json();
if (
result.message &&
result.message.includes('does not support MCP')
) {
// This executor doesn't support MCP - show warning message
setMcpError(result.message);
} else {
console.warn('Failed to load MCP servers:', response.statusText);
}
// For other executors, use the standard mcpServers structure
fullConfig = { mcpServers: servers };
}
const configJson = JSON.stringify(fullConfig, null, 2);
setMcpServers(configJson);
setMcpConfigPath(configPath);
} catch (err: any) {
if (err?.message && err.message.includes('does not support MCP')) {
setMcpError(err.message);
} else {
console.error('Error loading MCP servers:', err);
}
} catch (err) {
console.error('Error loading MCP servers:', err);
} finally {
setMcpLoading(false);
}
@@ -215,21 +202,7 @@ export function McpServers() {
mcpServersConfig = fullConfig.mcpServers;
}
const mcpResponse = await fetch(
`/api/mcp-servers?executor=${selectedMcpExecutor}`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(mcpServersConfig),
}
);
if (!mcpResponse.ok) {
const errorData = await mcpResponse.json();
throw new Error(errorData.message || 'Failed to save MCP servers');
}
await mcpServersApi.save(selectedMcpExecutor, mcpServersConfig);
// Show success feedback
setSuccess(true);

View File

@@ -4,7 +4,8 @@ import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { FolderOpen, Plus, Settings } from 'lucide-react';
import { makeRequest } from '@/lib/api';
import { Loader } from '@/components/ui/loader';
import { projectsApi, tasksApi } from '@/lib/api';
import { TaskFormDialog } from '@/components/tasks/TaskFormDialog';
import { ProjectForm } from '@/components/projects/project-form';
import { useKeyboardShortcuts } from '@/lib/keyboard-shortcuts';
@@ -16,7 +17,6 @@ import {
import { TaskKanbanBoard } from '@/components/tasks/TaskKanbanBoard';
import { TaskDetailsPanel } from '@/components/tasks/TaskDetailsPanel';
import type {
ApiResponse,
CreateTaskAndStart,
ExecutorConfig,
ProjectWithBranch,
@@ -56,20 +56,7 @@ export function ProjectTasks() {
if (!projectId) return;
try {
const response = await makeRequest(
`/api/projects/${projectId}/open-editor`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(null),
}
);
if (!response.ok) {
throw new Error('Failed to open project in IDE');
}
await projectsApi.openEditor(projectId);
} catch (error) {
console.error('Failed to open project in IDE:', error);
setError('Failed to open project in IDE');
@@ -116,19 +103,8 @@ export function ProjectTasks() {
const fetchProject = useCallback(async () => {
try {
const response = await makeRequest(
`/api/projects/${projectId}/with-branch`
);
if (response.ok) {
const result: ApiResponse<ProjectWithBranch> = await response.json();
if (result.success && result.data) {
setProject(result.data);
}
} else if (response.status === 404) {
setError('Project not found');
navigate('/projects');
}
const result = await projectsApi.getWithBranch(projectId!);
setProject(result);
} catch (err) {
setError('Failed to load project');
}
@@ -140,38 +116,28 @@ export function ProjectTasks() {
if (!skipLoading) {
setLoading(true);
}
const response = await makeRequest(`/api/projects/${projectId}/tasks`);
if (response.ok) {
const result: ApiResponse<Task[]> = await response.json();
if (result.success && result.data) {
// Only update if data has actually changed
setTasks((prevTasks) => {
const newTasks = result.data!;
if (JSON.stringify(prevTasks) === JSON.stringify(newTasks)) {
return prevTasks; // Return same reference to prevent re-render
}
setSelectedTask((prev) => {
if (!prev) return prev;
const updatedSelectedTask = newTasks.find(
(task) => task.id === prev.id
);
if (
JSON.stringify(prev) === JSON.stringify(updatedSelectedTask)
)
return prev;
return updatedSelectedTask || prev;
});
return newTasks;
});
const result = await tasksApi.getAll(projectId!);
// Only update if data has actually changed
setTasks((prevTasks) => {
const newTasks = result;
if (JSON.stringify(prevTasks) === JSON.stringify(newTasks)) {
return prevTasks; // Return same reference to prevent re-render
}
} else {
setError('Failed to load tasks');
}
setSelectedTask((prev) => {
if (!prev) return prev;
const updatedSelectedTask = newTasks.find(
(task) => task.id === prev.id
);
if (JSON.stringify(prev) === JSON.stringify(updatedSelectedTask))
return prev;
return updatedSelectedTask || prev;
});
return newTasks;
});
} catch (err) {
setError('Failed to load tasks');
} finally {
@@ -186,20 +152,12 @@ export function ProjectTasks() {
const handleCreateTask = useCallback(
async (title: string, description: string) => {
try {
const response = await makeRequest(`/api/projects/${projectId}/tasks`, {
method: 'POST',
body: JSON.stringify({
project_id: projectId,
title,
description: description || null,
}),
await tasksApi.create(projectId!, {
project_id: projectId!,
title,
description: description || null,
});
if (response.ok) {
await fetchTasks();
} else {
setError('Failed to create task');
}
await fetchTasks();
} catch (err) {
setError('Failed to create task');
}
@@ -216,25 +174,10 @@ export function ProjectTasks() {
description: description || null,
executor: executor || null,
};
const response = await makeRequest(
`/api/projects/${projectId}/tasks/create-and-start`,
{
method: 'POST',
body: JSON.stringify(payload),
}
);
if (response.ok) {
const result: ApiResponse<Task> = await response.json();
if (result.success && result.data) {
await fetchTasks();
// Open the newly created task in the details panel
handleViewTaskDetails(result.data);
}
} else {
setError('Failed to create and start task');
}
const result = await tasksApi.createAndStart(projectId!, payload);
await fetchTasks();
// Open the newly created task in the details panel
handleViewTaskDetails(result);
} catch (err) {
setError('Failed to create and start task');
}
@@ -247,24 +190,13 @@ export function ProjectTasks() {
if (!editingTask) return;
try {
const response = await makeRequest(
`/api/projects/${projectId}/tasks/${editingTask.id}`,
{
method: 'PUT',
body: JSON.stringify({
title,
description: description || null,
status,
}),
}
);
if (response.ok) {
await fetchTasks();
setEditingTask(null);
} else {
setError('Failed to update task');
}
await tasksApi.update(projectId!, editingTask.id, {
title,
description: description || null,
status,
});
await fetchTasks();
setEditingTask(null);
} catch (err) {
setError('Failed to update task');
}
@@ -277,19 +209,9 @@ export function ProjectTasks() {
if (!confirm('Are you sure you want to delete this task?')) return;
try {
const response = await makeRequest(
`/api/projects/${projectId}/tasks/${taskId}`,
{
method: 'DELETE',
}
);
if (response.ok) {
await fetchTasks();
} else {
setError('Failed to delete task');
}
} catch (err) {
await tasksApi.delete(projectId!, taskId);
await fetchTasks();
} catch (error) {
setError('Failed to delete task');
}
},
@@ -303,8 +225,8 @@ export function ProjectTasks() {
const handleViewTaskDetails = useCallback(
(task: Task) => {
setSelectedTask(task);
setIsPanelOpen(true);
// setSelectedTask(task);
// setIsPanelOpen(true);
// Update URL to include task ID
navigate(`/projects/${projectId}/tasks/${task.id}`, { replace: true });
},
@@ -312,8 +234,8 @@ export function ProjectTasks() {
);
const handleClosePanel = useCallback(() => {
setIsPanelOpen(false);
setSelectedTask(null);
// setIsPanelOpen(false);
// setSelectedTask(null);
// Remove task ID from URL when closing panel
navigate(`/projects/${projectId}/tasks`, { replace: true });
}, [projectId, navigate]);
@@ -342,27 +264,11 @@ export function ProjectTasks() {
);
try {
const response = await makeRequest(
`/api/projects/${projectId}/tasks/${taskId}`,
{
method: 'PUT',
body: JSON.stringify({
title: task.title,
description: task.description,
status: newStatus,
}),
}
);
if (!response.ok) {
// Revert the optimistic update if the API call failed
setTasks((prev) =>
prev.map((t) =>
t.id === taskId ? { ...t, status: previousStatus } : t
)
);
setError('Failed to update task status');
}
await tasksApi.update(projectId!, taskId, {
title: task.title,
description: task.description,
status: newStatus,
});
} catch (err) {
// Revert the optimistic update if the API call failed
setTasks((prev) =>
@@ -377,13 +283,12 @@ export function ProjectTasks() {
);
if (loading) {
return <div className="text-center py-8">Loading tasks...</div>;
return <Loader message="Loading tasks..." size={32} className="py-8" />;
}
if (error) {
return <div className="text-center py-8 text-destructive">{error}</div>;
}
console.log('selectedTask', selectedTask);
return (
<div className={getMainContainerClasses(isPanelOpen)}>
@@ -470,7 +375,6 @@ export function ProjectTasks() {
task={selectedTask}
projectHasDevScript={!!project?.dev_script}
projectId={projectId!}
isOpen={isPanelOpen}
onClose={handleClosePanel}
onEditTask={handleEditTask}
onDeleteTask={handleDeleteTask}