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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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}
|
||||
@@ -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;
|
||||
@@ -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}
|
||||
>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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');
|
||||
|
||||
24
frontend/src/components/ui/loader.tsx
Normal file
24
frontend/src/components/ui/loader.tsx
Normal 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>
|
||||
);
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user