fix(FN-000): scope dashboard project flows
This commit is contained in:
@@ -153,13 +153,13 @@ function AppInner() {
|
||||
|
||||
// Initial data fetch
|
||||
useEffect(() => {
|
||||
fetchConfig()
|
||||
fetchConfig(currentProject?.id)
|
||||
.then((cfg) => {
|
||||
setMaxConcurrent(cfg.maxConcurrent);
|
||||
setRootDir(cfg.rootDir);
|
||||
})
|
||||
.catch(() => {/* keep default */});
|
||||
fetchSettings()
|
||||
fetchSettings(currentProject?.id)
|
||||
.then((s) => {
|
||||
setAutoMerge(!!s.autoMerge);
|
||||
setGlobalPaused(!!s.globalPause);
|
||||
@@ -175,7 +175,7 @@ function AppInner() {
|
||||
}
|
||||
})
|
||||
.catch(() => {/* fail silently */});
|
||||
}, []);
|
||||
}, [currentProject?.id]);
|
||||
|
||||
// Fetch available models
|
||||
useEffect(() => {
|
||||
@@ -207,7 +207,8 @@ function AppInner() {
|
||||
}
|
||||
|
||||
// After project context is resolved (or if no project param), fetch the task
|
||||
fetchTaskDetail(taskId)
|
||||
const taskProjectId = projectParam ?? currentProject?.id;
|
||||
fetchTaskDetail(taskId, taskProjectId)
|
||||
.then((detail) => {
|
||||
setDetailTask(detail);
|
||||
})
|
||||
@@ -346,31 +347,31 @@ function AppInner() {
|
||||
const next = !autoMerge;
|
||||
setAutoMerge(next);
|
||||
try {
|
||||
await updateSettings({ autoMerge: next });
|
||||
await updateSettings({ autoMerge: next }, currentProject?.id);
|
||||
} catch {
|
||||
setAutoMerge(!next); // revert on failure
|
||||
}
|
||||
}, [autoMerge]);
|
||||
}, [autoMerge, currentProject?.id]);
|
||||
|
||||
const handleToggleGlobalPause = useCallback(async () => {
|
||||
const next = !globalPaused;
|
||||
setGlobalPaused(next);
|
||||
try {
|
||||
await updateSettings({ globalPause: next });
|
||||
await updateSettings({ globalPause: next }, currentProject?.id);
|
||||
} catch {
|
||||
setGlobalPaused(!next); // revert on failure
|
||||
}
|
||||
}, [globalPaused]);
|
||||
}, [globalPaused, currentProject?.id]);
|
||||
|
||||
const handleToggleEnginePause = useCallback(async () => {
|
||||
const next = !enginePaused;
|
||||
setEnginePaused(next);
|
||||
try {
|
||||
await updateSettings({ enginePaused: next });
|
||||
await updateSettings({ enginePaused: next }, currentProject?.id);
|
||||
} catch {
|
||||
setEnginePaused(!next); // revert on failure
|
||||
}
|
||||
}, [enginePaused]);
|
||||
}, [enginePaused, currentProject?.id]);
|
||||
|
||||
const handleDetailOpen = useCallback((task: TaskDetail) => {
|
||||
setDetailTask(task);
|
||||
@@ -447,13 +448,14 @@ function AppInner() {
|
||||
|
||||
// Project view
|
||||
if (taskView === "agents") {
|
||||
return <AgentsView addToast={addToast} />;
|
||||
return <AgentsView addToast={addToast} projectId={currentProject?.id} />;
|
||||
}
|
||||
|
||||
if (taskView === "board") {
|
||||
return (
|
||||
<Board
|
||||
tasks={tasks}
|
||||
projectId={currentProject?.id}
|
||||
maxConcurrent={maxConcurrent}
|
||||
onMoveTask={moveTask}
|
||||
onOpenDetail={handleDetailOpen}
|
||||
@@ -480,6 +482,7 @@ function AppInner() {
|
||||
return (
|
||||
<ListView
|
||||
tasks={tasks}
|
||||
projectId={currentProject?.id}
|
||||
onMoveTask={moveTask}
|
||||
onOpenDetail={handleDetailOpen}
|
||||
addToast={addToast}
|
||||
@@ -504,8 +507,8 @@ function AppInner() {
|
||||
onOpenSchedules={handleOpenSchedules}
|
||||
onOpenGitManager={handleOpenGitManager}
|
||||
onOpenWorkflowSteps={() => setWorkflowStepsOpen(true)}
|
||||
onOpenMissions={() => setMissionsOpen(true)}
|
||||
onOpenAgents={handleOpenAgents}
|
||||
onOpenMissions={viewMode === "project" && currentProject ? () => setMissionsOpen(true) : undefined}
|
||||
onOpenAgents={viewMode === "project" && currentProject ? handleOpenAgents : undefined}
|
||||
onOpenScripts={handleOpenScripts}
|
||||
onRunScript={handleRunScript}
|
||||
onToggleTerminal={handleToggleTerminal}
|
||||
@@ -516,13 +519,14 @@ function AppInner() {
|
||||
onToggleGlobalPause={handleToggleGlobalPause}
|
||||
onToggleEnginePause={handleToggleEnginePause}
|
||||
view={taskView}
|
||||
onChangeView={handleChangeTaskView}
|
||||
onChangeView={viewMode === "project" && currentProject ? handleChangeTaskView : undefined}
|
||||
searchQuery={searchQuery}
|
||||
onSearchChange={setSearchQuery}
|
||||
projects={projects}
|
||||
currentProject={currentProject}
|
||||
onSelectProject={handleSelectProject}
|
||||
onViewAllProjects={handleViewAllProjects}
|
||||
projectId={currentProject?.id}
|
||||
/>
|
||||
{renderMainContent()}
|
||||
{viewMode === "project" && currentProject && (
|
||||
@@ -531,6 +535,7 @@ function AppInner() {
|
||||
{detailTask && (
|
||||
<TaskDetailModal
|
||||
task={detailTask}
|
||||
projectId={currentProject?.id}
|
||||
tasks={tasks}
|
||||
onClose={handleDetailClose}
|
||||
onOpenDetail={handleDetailOpen}
|
||||
@@ -551,6 +556,7 @@ function AppInner() {
|
||||
}}
|
||||
addToast={addToast}
|
||||
initialSection={settingsInitialSection}
|
||||
projectId={currentProject?.id}
|
||||
themeMode={themeMode}
|
||||
colorTheme={colorTheme}
|
||||
onThemeModeChange={setThemeMode}
|
||||
@@ -569,12 +575,14 @@ function AppInner() {
|
||||
onTaskCreated={handlePlanningTaskCreated}
|
||||
tasks={tasks}
|
||||
initialPlan={planningInitialPlan ?? undefined}
|
||||
projectId={currentProject?.id}
|
||||
/>
|
||||
<SubtaskBreakdownModal
|
||||
isOpen={isSubtaskOpen}
|
||||
onClose={handleSubtaskClose}
|
||||
initialDescription={subtaskInitialDescription ?? ""}
|
||||
onTasksCreated={handleSubtaskTasksCreated}
|
||||
projectId={currentProject?.id}
|
||||
/>
|
||||
<TerminalModal
|
||||
isOpen={terminalOpen}
|
||||
@@ -586,6 +594,7 @@ function AppInner() {
|
||||
onClose={handleCloseScripts}
|
||||
addToast={addToast}
|
||||
onRunScript={handleRunScript}
|
||||
projectId={currentProject?.id}
|
||||
/>
|
||||
{filesOpen && (
|
||||
<FileBrowserModal
|
||||
@@ -600,6 +609,7 @@ function AppInner() {
|
||||
taskId={changedFilesState.taskId}
|
||||
worktree={changedFilesState.worktree}
|
||||
column={changedFilesState.column}
|
||||
projectId={currentProject?.id}
|
||||
isOpen={true}
|
||||
onClose={handleCloseChangedFiles}
|
||||
/>
|
||||
@@ -620,6 +630,7 @@ function AppInner() {
|
||||
tasks={tasks}
|
||||
onCreateTask={handleModalCreate}
|
||||
addToast={addToast}
|
||||
projectId={currentProject?.id}
|
||||
onPlanningMode={handleNewTaskPlanningMode}
|
||||
onSubtaskBreakdown={handleSubtaskBreakdown}
|
||||
/>
|
||||
@@ -644,11 +655,13 @@ function AppInner() {
|
||||
isOpen={workflowStepsOpen}
|
||||
onClose={() => setWorkflowStepsOpen(false)}
|
||||
addToast={addToast}
|
||||
projectId={currentProject?.id}
|
||||
/>
|
||||
<MissionManager
|
||||
isOpen={missionsOpen}
|
||||
onClose={() => setMissionsOpen(false)}
|
||||
addToast={addToast}
|
||||
projectId={currentProject?.id}
|
||||
availableTasks={tasks.map((t) => ({ id: t.id, title: t.title }))}
|
||||
onSelectTask={(taskId) => {
|
||||
const task = tasks.find((t) => t.id === taskId);
|
||||
@@ -661,6 +674,7 @@ function AppInner() {
|
||||
isOpen={agentsOpen}
|
||||
onClose={handleCloseAgents}
|
||||
addToast={addToast}
|
||||
projectId={currentProject?.id}
|
||||
/>
|
||||
{setupWizardOpen && (
|
||||
<SetupWizardModal
|
||||
|
||||
@@ -74,18 +74,20 @@ async function api<T = unknown>(path: string, opts: RequestInit = {}): Promise<T
|
||||
return data as T;
|
||||
}
|
||||
|
||||
export function fetchTasks(limit?: number, offset?: number): Promise<Task[]> {
|
||||
export function fetchTasks(limit?: number, offset?: number, projectId?: string): Promise<Task[]> {
|
||||
const search = new URLSearchParams();
|
||||
if (limit !== undefined) search.set("limit", String(limit));
|
||||
if (offset !== undefined) search.set("offset", String(offset));
|
||||
if (projectId) search.set("projectId", projectId);
|
||||
const suffix = search.size > 0 ? `?${search.toString()}` : "";
|
||||
return api<Task[]>(`/tasks${suffix}`);
|
||||
}
|
||||
|
||||
export async function fetchTaskDetail(id: string): Promise<TaskDetail> {
|
||||
export async function fetchTaskDetail(id: string, projectId?: string): Promise<TaskDetail> {
|
||||
const maxAttempts = 2; // 1 initial + 1 retry
|
||||
const url = buildApiUrl(withProjectId(`/tasks/${id}`, projectId));
|
||||
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
||||
const res = await fetch(`/api/tasks/${id}`, {
|
||||
const res = await fetch(url, {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
const data = await res.json();
|
||||
@@ -98,7 +100,7 @@ export async function fetchTaskDetail(id: string): Promise<TaskDetail> {
|
||||
throw new Error("Request failed");
|
||||
}
|
||||
|
||||
export function createTask(input: TaskCreateInput): Promise<Task> {
|
||||
export function createTask(input: TaskCreateInput, projectId?: string): Promise<Task> {
|
||||
const {
|
||||
title,
|
||||
description,
|
||||
@@ -113,7 +115,7 @@ export function createTask(input: TaskCreateInput): Promise<Task> {
|
||||
validatorModelId,
|
||||
} = input;
|
||||
|
||||
return api<Task>("/tasks", {
|
||||
return api<Task>(withProjectId("/tasks", projectId), {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
title,
|
||||
@@ -131,8 +133,8 @@ export function createTask(input: TaskCreateInput): Promise<Task> {
|
||||
});
|
||||
}
|
||||
|
||||
export function updateTask(id: string, updates: { title?: string; description?: string; prompt?: string; dependencies?: string[]; modelProvider?: string | null; modelId?: string | null; validatorModelProvider?: string | null; validatorModelId?: string | null }): Promise<Task> {
|
||||
return api<Task>(`/tasks/${id}`, {
|
||||
export function updateTask(id: string, updates: { title?: string; description?: string; prompt?: string; dependencies?: string[]; modelProvider?: string | null; modelId?: string | null; validatorModelProvider?: string | null; validatorModelId?: string | null }, projectId?: string): Promise<Task> {
|
||||
return api<Task>(withProjectId(`/tasks/${id}`, projectId), {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(updates),
|
||||
});
|
||||
@@ -153,8 +155,9 @@ export function batchUpdateTaskModels(
|
||||
modelId?: string | null,
|
||||
validatorModelProvider?: string | null,
|
||||
validatorModelId?: string | null,
|
||||
projectId?: string,
|
||||
): Promise<{ updated: Task[]; count: number }> {
|
||||
return api<{ updated: Task[]; count: number }>("/tasks/batch-update-models", {
|
||||
return api<{ updated: Task[]; count: number }>(withProjectId("/tasks/batch-update-models", projectId), {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
taskIds,
|
||||
@@ -166,69 +169,69 @@ export function batchUpdateTaskModels(
|
||||
});
|
||||
}
|
||||
|
||||
export function moveTask(id: string, column: Column): Promise<Task> {
|
||||
return api<Task>(`/tasks/${id}/move`, {
|
||||
export function moveTask(id: string, column: Column, projectId?: string): Promise<Task> {
|
||||
return api<Task>(withProjectId(`/tasks/${id}/move`, projectId), {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ column }),
|
||||
});
|
||||
}
|
||||
|
||||
export function deleteTask(id: string): Promise<Task> {
|
||||
return api<Task>(`/tasks/${id}`, { method: "DELETE" });
|
||||
export function deleteTask(id: string, projectId?: string): Promise<Task> {
|
||||
return api<Task>(withProjectId(`/tasks/${id}`, projectId), { method: "DELETE" });
|
||||
}
|
||||
|
||||
export function mergeTask(id: string): Promise<MergeResult> {
|
||||
return api<MergeResult>(`/tasks/${id}/merge`, { method: "POST" });
|
||||
export function mergeTask(id: string, projectId?: string): Promise<MergeResult> {
|
||||
return api<MergeResult>(withProjectId(`/tasks/${id}/merge`, projectId), { method: "POST" });
|
||||
}
|
||||
|
||||
export function retryTask(id: string): Promise<Task> {
|
||||
return api<Task>(`/tasks/${id}/retry`, { method: "POST" });
|
||||
export function retryTask(id: string, projectId?: string): Promise<Task> {
|
||||
return api<Task>(withProjectId(`/tasks/${id}/retry`, projectId), { method: "POST" });
|
||||
}
|
||||
|
||||
export function duplicateTask(id: string): Promise<Task> {
|
||||
return api<Task>(`/tasks/${id}/duplicate`, { method: "POST" });
|
||||
export function duplicateTask(id: string, projectId?: string): Promise<Task> {
|
||||
return api<Task>(withProjectId(`/tasks/${id}/duplicate`, projectId), { method: "POST" });
|
||||
}
|
||||
|
||||
export function pauseTask(id: string): Promise<Task> {
|
||||
return api<Task>(`/tasks/${id}/pause`, { method: "POST" });
|
||||
export function pauseTask(id: string, projectId?: string): Promise<Task> {
|
||||
return api<Task>(withProjectId(`/tasks/${id}/pause`, projectId), { method: "POST" });
|
||||
}
|
||||
|
||||
export function unpauseTask(id: string): Promise<Task> {
|
||||
return api<Task>(`/tasks/${id}/unpause`, { method: "POST" });
|
||||
export function unpauseTask(id: string, projectId?: string): Promise<Task> {
|
||||
return api<Task>(withProjectId(`/tasks/${id}/unpause`, projectId), { method: "POST" });
|
||||
}
|
||||
|
||||
export function archiveTask(id: string): Promise<Task> {
|
||||
return api<Task>(`/tasks/${id}/archive`, { method: "POST" });
|
||||
export function archiveTask(id: string, projectId?: string): Promise<Task> {
|
||||
return api<Task>(withProjectId(`/tasks/${id}/archive`, projectId), { method: "POST" });
|
||||
}
|
||||
|
||||
export function unarchiveTask(id: string): Promise<Task> {
|
||||
return api<Task>(`/tasks/${id}/unarchive`, { method: "POST" });
|
||||
export function unarchiveTask(id: string, projectId?: string): Promise<Task> {
|
||||
return api<Task>(withProjectId(`/tasks/${id}/unarchive`, projectId), { method: "POST" });
|
||||
}
|
||||
|
||||
export function archiveAllDone(): Promise<Task[]> {
|
||||
return api<{ archived: Task[] }>("/tasks/archive-all-done", { method: "POST" }).then(
|
||||
export function archiveAllDone(projectId?: string): Promise<Task[]> {
|
||||
return api<{ archived: Task[] }>(withProjectId("/tasks/archive-all-done", projectId), { method: "POST" }).then(
|
||||
(response) => response.archived
|
||||
);
|
||||
}
|
||||
|
||||
export function approvePlan(id: string): Promise<Task> {
|
||||
return api<Task>(`/tasks/${id}/approve-plan`, { method: "POST" });
|
||||
export function approvePlan(id: string, projectId?: string): Promise<Task> {
|
||||
return api<Task>(withProjectId(`/tasks/${id}/approve-plan`, projectId), { method: "POST" });
|
||||
}
|
||||
|
||||
export function rejectPlan(id: string): Promise<Task> {
|
||||
return api<Task>(`/tasks/${id}/reject-plan`, { method: "POST" });
|
||||
export function rejectPlan(id: string, projectId?: string): Promise<Task> {
|
||||
return api<Task>(withProjectId(`/tasks/${id}/reject-plan`, projectId), { method: "POST" });
|
||||
}
|
||||
|
||||
export function fetchConfig(): Promise<{ maxConcurrent: number; rootDir: string }> {
|
||||
return api<{ maxConcurrent: number; rootDir: string }>("/config");
|
||||
export function fetchConfig(projectId?: string): Promise<{ maxConcurrent: number; rootDir: string }> {
|
||||
return api<{ maxConcurrent: number; rootDir: string }>(withProjectId("/config", projectId));
|
||||
}
|
||||
|
||||
export function fetchSettings(): Promise<Settings> {
|
||||
return api<Settings>("/settings");
|
||||
export function fetchSettings(projectId?: string): Promise<Settings> {
|
||||
return api<Settings>(withProjectId("/settings", projectId));
|
||||
}
|
||||
|
||||
export function updateSettings(settings: Partial<Settings>): Promise<Settings> {
|
||||
return api<Settings>("/settings", {
|
||||
export function updateSettings(settings: Partial<Settings>, projectId?: string): Promise<Settings> {
|
||||
return api<Settings>(withProjectId("/settings", projectId), {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(settings),
|
||||
});
|
||||
@@ -248,21 +251,21 @@ export function updateGlobalSettings(settings: Partial<GlobalSettings>): Promise
|
||||
}
|
||||
|
||||
/** Fetch settings separated by scope: { global, project } */
|
||||
export function fetchSettingsByScope(): Promise<{ global: GlobalSettings; project: Partial<ProjectSettings> }> {
|
||||
return api<{ global: GlobalSettings; project: Partial<ProjectSettings> }>("/settings/scopes");
|
||||
export function fetchSettingsByScope(projectId?: string): Promise<{ global: GlobalSettings; project: Partial<ProjectSettings> }> {
|
||||
return api<{ global: GlobalSettings; project: Partial<ProjectSettings> }>(withProjectId("/settings/scopes", projectId));
|
||||
}
|
||||
|
||||
export function testNtfyNotification(config?: { ntfyEnabled?: boolean; ntfyTopic?: string }): Promise<{ success: boolean }> {
|
||||
return api<{ success: boolean }>("/settings/test-ntfy", {
|
||||
export function testNtfyNotification(config?: { ntfyEnabled?: boolean; ntfyTopic?: string }, projectId?: string): Promise<{ success: boolean }> {
|
||||
return api<{ success: boolean }>(withProjectId("/settings/test-ntfy", projectId), {
|
||||
method: "POST",
|
||||
body: config ? JSON.stringify(config) : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
export async function uploadAttachment(id: string, file: File): Promise<TaskAttachment> {
|
||||
export async function uploadAttachment(id: string, file: File, projectId?: string): Promise<TaskAttachment> {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
const res = await fetch(`/api/tasks/${id}/attachments`, {
|
||||
const res = await fetch(buildApiUrl(withProjectId(`/tasks/${id}/attachments`, projectId)), {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
@@ -271,58 +274,58 @@ export async function uploadAttachment(id: string, file: File): Promise<TaskAtta
|
||||
return data as TaskAttachment;
|
||||
}
|
||||
|
||||
export async function deleteAttachment(id: string, filename: string): Promise<Task> {
|
||||
return api<Task>(`/tasks/${id}/attachments/${filename}`, { method: "DELETE" });
|
||||
export async function deleteAttachment(id: string, filename: string, projectId?: string): Promise<Task> {
|
||||
return api<Task>(withProjectId(`/tasks/${id}/attachments/${filename}`, projectId), { method: "DELETE" });
|
||||
}
|
||||
|
||||
export function fetchAgentLogs(taskId: string): Promise<AgentLogEntry[]> {
|
||||
return api<AgentLogEntry[]>(`/tasks/${taskId}/logs`);
|
||||
export function fetchAgentLogs(taskId: string, projectId?: string): Promise<AgentLogEntry[]> {
|
||||
return api<AgentLogEntry[]>(withProjectId(`/tasks/${taskId}/logs`, projectId));
|
||||
}
|
||||
|
||||
export function fetchSessionFiles(taskId: string): Promise<string[]> {
|
||||
return api<string[]>(`/tasks/${taskId}/session-files`);
|
||||
export function fetchSessionFiles(taskId: string, projectId?: string): Promise<string[]> {
|
||||
return api<string[]>(withProjectId(`/tasks/${taskId}/session-files`, projectId));
|
||||
}
|
||||
|
||||
export function fetchTaskComments(id: string): Promise<TaskComment[]> {
|
||||
return api<TaskComment[]>(`/tasks/${id}/comments`);
|
||||
export function fetchTaskComments(id: string, projectId?: string): Promise<TaskComment[]> {
|
||||
return api<TaskComment[]>(withProjectId(`/tasks/${id}/comments`, projectId));
|
||||
}
|
||||
|
||||
export function addTaskComment(id: string, text: string, author?: string): Promise<Task> {
|
||||
return api<Task>(`/tasks/${id}/comments`, {
|
||||
export function addTaskComment(id: string, text: string, author?: string, projectId?: string): Promise<Task> {
|
||||
return api<Task>(withProjectId(`/tasks/${id}/comments`, projectId), {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ text, author }),
|
||||
});
|
||||
}
|
||||
|
||||
export function updateTaskComment(id: string, commentId: string, text: string): Promise<Task> {
|
||||
return api<Task>(`/tasks/${id}/comments/${commentId}`, {
|
||||
export function updateTaskComment(id: string, commentId: string, text: string, projectId?: string): Promise<Task> {
|
||||
return api<Task>(withProjectId(`/tasks/${id}/comments/${commentId}`, projectId), {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ text }),
|
||||
});
|
||||
}
|
||||
|
||||
export function deleteTaskComment(id: string, commentId: string): Promise<Task> {
|
||||
return api<Task>(`/tasks/${id}/comments/${commentId}`, {
|
||||
export function deleteTaskComment(id: string, commentId: string, projectId?: string): Promise<Task> {
|
||||
return api<Task>(withProjectId(`/tasks/${id}/comments/${commentId}`, projectId), {
|
||||
method: "DELETE",
|
||||
});
|
||||
}
|
||||
|
||||
export function addSteeringComment(id: string, text: string): Promise<Task> {
|
||||
return api<Task>(`/tasks/${id}/steer`, {
|
||||
export function addSteeringComment(id: string, text: string, projectId?: string): Promise<Task> {
|
||||
return api<Task>(withProjectId(`/tasks/${id}/steer`, projectId), {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ text }),
|
||||
});
|
||||
}
|
||||
|
||||
export function requestSpecRevision(id: string, feedback: string): Promise<Task> {
|
||||
return api<Task>(`/tasks/${id}/spec/revise`, {
|
||||
export function requestSpecRevision(id: string, feedback: string, projectId?: string): Promise<Task> {
|
||||
return api<Task>(withProjectId(`/tasks/${id}/spec/revise`, projectId), {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ feedback }),
|
||||
});
|
||||
}
|
||||
|
||||
export function refineTask(id: string, feedback: string): Promise<Task> {
|
||||
return api<Task>(`/tasks/${id}/refine`, {
|
||||
export function refineTask(id: string, feedback: string, projectId?: string): Promise<Task> {
|
||||
return api<Task>(withProjectId(`/tasks/${id}/refine`, projectId), {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ feedback }),
|
||||
});
|
||||
@@ -601,22 +604,23 @@ export interface PrRefreshResponse {
|
||||
/** Create a GitHub PR for a task */
|
||||
export function createPr(
|
||||
id: string,
|
||||
params: { title: string; body?: string; base?: string }
|
||||
params: { title: string; body?: string; base?: string },
|
||||
projectId?: string,
|
||||
): Promise<PrInfo> {
|
||||
return api<PrInfo>(`/tasks/${id}/pr/create`, {
|
||||
return api<PrInfo>(withProjectId(`/tasks/${id}/pr/create`, projectId), {
|
||||
method: "POST",
|
||||
body: JSON.stringify(params),
|
||||
});
|
||||
}
|
||||
|
||||
/** Fetch cached PR status for a task */
|
||||
export function fetchPrStatus(id: string): Promise<PrStatusResponse> {
|
||||
return api<PrStatusResponse>(`/tasks/${id}/pr/status`);
|
||||
export function fetchPrStatus(id: string, projectId?: string): Promise<PrStatusResponse> {
|
||||
return api<PrStatusResponse>(withProjectId(`/tasks/${id}/pr/status`, projectId));
|
||||
}
|
||||
|
||||
/** Force refresh PR status from GitHub */
|
||||
export function refreshPrStatus(id: string): Promise<PrRefreshResponse> {
|
||||
return api<PrRefreshResponse>(`/tasks/${id}/pr/refresh`, {
|
||||
export function refreshPrStatus(id: string, projectId?: string): Promise<PrRefreshResponse> {
|
||||
return api<PrRefreshResponse>(withProjectId(`/tasks/${id}/pr/refresh`, projectId), {
|
||||
method: "POST",
|
||||
});
|
||||
}
|
||||
@@ -627,20 +631,20 @@ export function refreshPrStatus(id: string): Promise<PrRefreshResponse> {
|
||||
export type { IssueInfo, BatchStatusResult, BatchStatusEntry } from "@fusion/core";
|
||||
|
||||
/** Fetch cached issue status for a task */
|
||||
export function fetchIssueStatus(id: string): Promise<{ issueInfo: import("@fusion/core").IssueInfo; stale: boolean }> {
|
||||
return api<{ issueInfo: import("@fusion/core").IssueInfo; stale: boolean }>(`/tasks/${id}/issue/status`);
|
||||
export function fetchIssueStatus(id: string, projectId?: string): Promise<{ issueInfo: import("@fusion/core").IssueInfo; stale: boolean }> {
|
||||
return api<{ issueInfo: import("@fusion/core").IssueInfo; stale: boolean }>(withProjectId(`/tasks/${id}/issue/status`, projectId));
|
||||
}
|
||||
|
||||
/** Force refresh issue status from GitHub */
|
||||
export function refreshIssueStatus(id: string): Promise<import("@fusion/core").IssueInfo> {
|
||||
return api<import("@fusion/core").IssueInfo>(`/tasks/${id}/issue/refresh`, {
|
||||
export function refreshIssueStatus(id: string, projectId?: string): Promise<import("@fusion/core").IssueInfo> {
|
||||
return api<import("@fusion/core").IssueInfo>(withProjectId(`/tasks/${id}/issue/refresh`, projectId), {
|
||||
method: "POST",
|
||||
});
|
||||
}
|
||||
|
||||
/** Batch-refresh cached GitHub badge status for multiple tasks. */
|
||||
export async function fetchBatchStatus(taskIds: string[]): Promise<BatchStatusResult> {
|
||||
const response = await api<BatchStatusResponse>("/github/batch/status", {
|
||||
export async function fetchBatchStatus(taskIds: string[], projectId?: string): Promise<BatchStatusResult> {
|
||||
const response = await api<BatchStatusResponse>(withProjectId("/github/batch/status", projectId), {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ taskIds }),
|
||||
});
|
||||
@@ -1075,16 +1079,16 @@ export type PlanningStreamEvent =
|
||||
| { type: "complete"; data: Record<string, never> };
|
||||
|
||||
/** Start a new planning session with an initial plan */
|
||||
export function startPlanning(initialPlan: string): Promise<PlanningSession> {
|
||||
return api<PlanningSession>("/planning/start", {
|
||||
export function startPlanning(initialPlan: string, projectId?: string): Promise<PlanningSession> {
|
||||
return api<PlanningSession>(withProjectId("/planning/start", projectId), {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ initialPlan }),
|
||||
});
|
||||
}
|
||||
|
||||
/** Start a new planning session with AI streaming support */
|
||||
export function startPlanningStreaming(initialPlan: string): Promise<{ sessionId: string }> {
|
||||
return api<{ sessionId: string }>("/planning/start-streaming", {
|
||||
export function startPlanningStreaming(initialPlan: string, projectId?: string): Promise<{ sessionId: string }> {
|
||||
return api<{ sessionId: string }>(withProjectId("/planning/start-streaming", projectId), {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ initialPlan }),
|
||||
});
|
||||
@@ -1093,33 +1097,34 @@ export function startPlanningStreaming(initialPlan: string): Promise<{ sessionId
|
||||
/** Submit a response to the current planning question */
|
||||
export function respondToPlanning(
|
||||
sessionId: string,
|
||||
responses: Record<string, unknown>
|
||||
responses: Record<string, unknown>,
|
||||
projectId?: string
|
||||
): Promise<PlanningSession> {
|
||||
return api<PlanningSession>("/planning/respond", {
|
||||
return api<PlanningSession>(withProjectId("/planning/respond", projectId), {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ sessionId, responses }),
|
||||
});
|
||||
}
|
||||
|
||||
/** Cancel an active planning session */
|
||||
export function cancelPlanning(sessionId: string): Promise<void> {
|
||||
return api<void>("/planning/cancel", {
|
||||
export function cancelPlanning(sessionId: string, projectId?: string): Promise<void> {
|
||||
return api<void>(withProjectId("/planning/cancel", projectId), {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ sessionId }),
|
||||
});
|
||||
}
|
||||
|
||||
/** Create a task from a completed planning session */
|
||||
export function createTaskFromPlanning(sessionId: string): Promise<Task> {
|
||||
return api<Task>("/planning/create-task", {
|
||||
export function createTaskFromPlanning(sessionId: string, projectId?: string): Promise<Task> {
|
||||
return api<Task>(withProjectId("/planning/create-task", projectId), {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ sessionId }),
|
||||
});
|
||||
}
|
||||
|
||||
/** Get the SSE stream URL for a planning session */
|
||||
export function getPlanningStreamUrl(sessionId: string): string {
|
||||
return `/api/planning/${encodeURIComponent(sessionId)}/stream`;
|
||||
export function getPlanningStreamUrl(sessionId: string, projectId?: string): string {
|
||||
return buildApiUrl(withProjectId(`/planning/${encodeURIComponent(sessionId)}/stream`, projectId));
|
||||
}
|
||||
|
||||
/** Connect to planning session SSE stream and handle events
|
||||
@@ -1130,6 +1135,7 @@ export function getPlanningStreamUrl(sessionId: string): string {
|
||||
*/
|
||||
export function connectPlanningStream(
|
||||
sessionId: string,
|
||||
projectId: string | undefined,
|
||||
handlers: {
|
||||
onThinking?: (data: string) => void;
|
||||
onQuestion?: (data: PlanningQuestion) => void;
|
||||
@@ -1138,7 +1144,7 @@ export function connectPlanningStream(
|
||||
onComplete?: () => void;
|
||||
}
|
||||
): { close: () => void; isConnected: () => boolean } {
|
||||
const url = getPlanningStreamUrl(sessionId);
|
||||
const url = getPlanningStreamUrl(sessionId, projectId);
|
||||
const eventSource = new EventSource(url);
|
||||
let isClosed = false;
|
||||
|
||||
@@ -1301,41 +1307,41 @@ export function clearActivityLog(): Promise<{ success: boolean }> {
|
||||
// ── Workflow Steps ─────────────────────────────────────────────────────
|
||||
|
||||
/** Fetch all workflow step definitions */
|
||||
export function fetchWorkflowSteps(): Promise<WorkflowStep[]> {
|
||||
return api<WorkflowStep[]>("/workflow-steps");
|
||||
export function fetchWorkflowSteps(projectId?: string): Promise<WorkflowStep[]> {
|
||||
return api<WorkflowStep[]>(withProjectId("/workflow-steps", projectId));
|
||||
}
|
||||
|
||||
/** Create a new workflow step */
|
||||
export function createWorkflowStep(input: WorkflowStepInput): Promise<WorkflowStep> {
|
||||
return api<WorkflowStep>("/workflow-steps", {
|
||||
export function createWorkflowStep(input: WorkflowStepInput, projectId?: string): Promise<WorkflowStep> {
|
||||
return api<WorkflowStep>(withProjectId("/workflow-steps", projectId), {
|
||||
method: "POST",
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
}
|
||||
|
||||
/** Update a workflow step */
|
||||
export function updateWorkflowStep(id: string, updates: Partial<WorkflowStepInput>): Promise<WorkflowStep> {
|
||||
return api<WorkflowStep>(`/workflow-steps/${id}`, {
|
||||
export function updateWorkflowStep(id: string, updates: Partial<WorkflowStepInput>, projectId?: string): Promise<WorkflowStep> {
|
||||
return api<WorkflowStep>(withProjectId(`/workflow-steps/${id}`, projectId), {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(updates),
|
||||
});
|
||||
}
|
||||
|
||||
/** Delete a workflow step */
|
||||
export function deleteWorkflowStep(id: string): Promise<void> {
|
||||
return api<void>(`/workflow-steps/${id}`, { method: "DELETE" });
|
||||
export function deleteWorkflowStep(id: string, projectId?: string): Promise<void> {
|
||||
return api<void>(withProjectId(`/workflow-steps/${id}`, projectId), { method: "DELETE" });
|
||||
}
|
||||
|
||||
/** Refine a workflow step's prompt using AI */
|
||||
export function refineWorkflowStepPrompt(id: string): Promise<{ prompt: string; workflowStep: WorkflowStep }> {
|
||||
return api<{ prompt: string; workflowStep: WorkflowStep }>(`/workflow-steps/${id}/refine`, {
|
||||
export function refineWorkflowStepPrompt(id: string, projectId?: string): Promise<{ prompt: string; workflowStep: WorkflowStep }> {
|
||||
return api<{ prompt: string; workflowStep: WorkflowStep }>(withProjectId(`/workflow-steps/${id}/refine`, projectId), {
|
||||
method: "POST",
|
||||
});
|
||||
}
|
||||
|
||||
/** Fetch workflow step results for a task */
|
||||
export function fetchWorkflowResults(taskId: string): Promise<WorkflowStepResult[]> {
|
||||
return api<WorkflowStepResult[]>(`/tasks/${encodeURIComponent(taskId)}/workflow-results`);
|
||||
export function fetchWorkflowResults(taskId: string, projectId?: string): Promise<WorkflowStepResult[]> {
|
||||
return api<WorkflowStepResult[]>(withProjectId(`/tasks/${encodeURIComponent(taskId)}/workflow-results`, projectId));
|
||||
}
|
||||
|
||||
// ── Workflow Step Templates ──────────────────────────────────────────────
|
||||
@@ -1349,8 +1355,8 @@ export function fetchWorkflowStepTemplates(): Promise<{ templates: import("@fusi
|
||||
}
|
||||
|
||||
/** Create a workflow step from a built-in template */
|
||||
export function createWorkflowStepFromTemplate(templateId: string): Promise<WorkflowStep> {
|
||||
return api<WorkflowStep>(`/workflow-step-templates/${encodeURIComponent(templateId)}/create`, {
|
||||
export function createWorkflowStepFromTemplate(templateId: string, projectId?: string): Promise<WorkflowStep> {
|
||||
return api<WorkflowStep>(withProjectId(`/workflow-step-templates/${encodeURIComponent(templateId)}/create`, projectId), {
|
||||
method: "POST",
|
||||
});
|
||||
}
|
||||
@@ -1370,26 +1376,26 @@ export interface ScriptRunResult {
|
||||
}
|
||||
|
||||
/** Fetch all saved scripts from project settings */
|
||||
export function fetchScripts(): Promise<Record<string, string>> {
|
||||
return api<Record<string, string>>("/scripts");
|
||||
export function fetchScripts(projectId?: string): Promise<Record<string, string>> {
|
||||
return api<Record<string, string>>(withProjectId("/scripts", projectId));
|
||||
}
|
||||
|
||||
/** Add or update a script */
|
||||
export function addScript(name: string, command: string): Promise<ScriptEntry> {
|
||||
return api<ScriptEntry>("/scripts", {
|
||||
export function addScript(name: string, command: string, projectId?: string): Promise<ScriptEntry> {
|
||||
return api<ScriptEntry>(withProjectId("/scripts", projectId), {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ name, command }),
|
||||
});
|
||||
}
|
||||
|
||||
/** Remove a script by name */
|
||||
export function removeScript(name: string): Promise<void> {
|
||||
return api<void>(`/scripts/${encodeURIComponent(name)}`, { method: "DELETE" });
|
||||
export function removeScript(name: string, projectId?: string): Promise<void> {
|
||||
return api<void>(withProjectId(`/scripts/${encodeURIComponent(name)}`, projectId), { method: "DELETE" });
|
||||
}
|
||||
|
||||
/** Run a saved script by name */
|
||||
export function runScript(name: string, args?: string[]): Promise<ScriptRunResult> {
|
||||
return api<ScriptRunResult>(`/scripts/${encodeURIComponent(name)}/run`, {
|
||||
export function runScript(name: string, args?: string[], projectId?: string): Promise<ScriptRunResult> {
|
||||
return api<ScriptRunResult>(withProjectId(`/scripts/${encodeURIComponent(name)}/run`, projectId), {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ args }),
|
||||
});
|
||||
@@ -1468,19 +1474,20 @@ export function getRefineErrorMessage(error: unknown): string {
|
||||
}
|
||||
|
||||
|
||||
export function startSubtaskBreakdown(description: string): Promise<{ sessionId: string }> {
|
||||
return api<{ sessionId: string }>("/subtasks/start-streaming", {
|
||||
export function startSubtaskBreakdown(description: string, projectId?: string): Promise<{ sessionId: string }> {
|
||||
return api<{ sessionId: string }>(withProjectId("/subtasks/start-streaming", projectId), {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ description }),
|
||||
});
|
||||
}
|
||||
|
||||
export function getSubtaskStreamUrl(sessionId: string): string {
|
||||
return `/api/subtasks/${encodeURIComponent(sessionId)}/stream`;
|
||||
export function getSubtaskStreamUrl(sessionId: string, projectId?: string): string {
|
||||
return buildApiUrl(withProjectId(`/subtasks/${encodeURIComponent(sessionId)}/stream`, projectId));
|
||||
}
|
||||
|
||||
export function connectSubtaskStream(
|
||||
sessionId: string,
|
||||
projectId: string | undefined,
|
||||
handlers: {
|
||||
onThinking?: (data: string) => void;
|
||||
onSubtasks?: (data: SubtaskItem[]) => void;
|
||||
@@ -1488,7 +1495,7 @@ export function connectSubtaskStream(
|
||||
onComplete?: () => void;
|
||||
}
|
||||
): { close: () => void; isConnected: () => boolean } {
|
||||
const eventSource = new EventSource(getSubtaskStreamUrl(sessionId));
|
||||
const eventSource = new EventSource(getSubtaskStreamUrl(sessionId, projectId));
|
||||
let isClosed = false;
|
||||
|
||||
eventSource.onopen = () => {
|
||||
@@ -1551,8 +1558,9 @@ export function createTasksFromBreakdown(
|
||||
sessionId: string,
|
||||
subtasks: SubtaskItem[],
|
||||
parentTaskId?: string,
|
||||
projectId?: string,
|
||||
): Promise<{ tasks: Task[]; parentTaskClosed?: boolean }> {
|
||||
return api<{ tasks: Task[]; parentTaskClosed?: boolean }>("/subtasks/create-tasks", {
|
||||
return api<{ tasks: Task[]; parentTaskClosed?: boolean }>(withProjectId("/subtasks/create-tasks", projectId), {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
sessionId,
|
||||
@@ -1568,8 +1576,8 @@ export function createTasksFromBreakdown(
|
||||
});
|
||||
}
|
||||
|
||||
export function cancelSubtaskBreakdown(sessionId: string): Promise<void> {
|
||||
return api<void>("/subtasks/cancel", {
|
||||
export function cancelSubtaskBreakdown(sessionId: string, projectId?: string): Promise<void> {
|
||||
return api<void>(withProjectId("/subtasks/cancel", projectId), {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ sessionId }),
|
||||
});
|
||||
@@ -1580,62 +1588,79 @@ export function cancelSubtaskBreakdown(sessionId: string): Promise<void> {
|
||||
import type { Agent, AgentDetail, AgentCapability, AgentState, AgentHeartbeatEvent, AgentHeartbeatRun, AgentCreateInput, AgentUpdateInput } from "@fusion/core";
|
||||
export type { Agent, AgentDetail, AgentCapability, AgentState, AgentHeartbeatEvent, AgentHeartbeatRun, AgentCreateInput, AgentUpdateInput };
|
||||
|
||||
function withProjectId(path: string, projectId?: string): string {
|
||||
if (!projectId) return path;
|
||||
const separator = path.includes("?") ? "&" : "?";
|
||||
return `${path}${separator}projectId=${encodeURIComponent(projectId)}`;
|
||||
}
|
||||
|
||||
/** Fetch all agents, optionally filtered by state or role */
|
||||
export function fetchAgents(filter?: { state?: AgentState; role?: AgentCapability }): Promise<Agent[]> {
|
||||
export function fetchAgents(
|
||||
filter?: { state?: AgentState; role?: AgentCapability },
|
||||
projectId?: string,
|
||||
): Promise<Agent[]> {
|
||||
const params = new URLSearchParams();
|
||||
if (filter?.state) params.set("state", filter.state);
|
||||
if (filter?.role) params.set("role", filter.role);
|
||||
if (projectId) params.set("projectId", projectId);
|
||||
const query = params.size > 0 ? `?${params.toString()}` : "";
|
||||
return api<Agent[]>(`/agents${query}`);
|
||||
}
|
||||
|
||||
/** Fetch a single agent with heartbeat history */
|
||||
export function fetchAgent(agentId: string): Promise<AgentDetail> {
|
||||
return api<AgentDetail>(`/agents/${encodeURIComponent(agentId)}`);
|
||||
export function fetchAgent(agentId: string, projectId?: string): Promise<AgentDetail> {
|
||||
return api<AgentDetail>(withProjectId(`/agents/${encodeURIComponent(agentId)}`, projectId));
|
||||
}
|
||||
|
||||
/** Create a new agent */
|
||||
export function createAgent(input: AgentCreateInput): Promise<Agent> {
|
||||
return api<Agent>("/agents", {
|
||||
export function createAgent(input: AgentCreateInput, projectId?: string): Promise<Agent> {
|
||||
return api<Agent>(withProjectId("/agents", projectId), {
|
||||
method: "POST",
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
}
|
||||
|
||||
/** Update an agent */
|
||||
export function updateAgent(agentId: string, updates: AgentUpdateInput): Promise<Agent> {
|
||||
return api<Agent>(`/agents/${encodeURIComponent(agentId)}`, {
|
||||
export function updateAgent(agentId: string, updates: AgentUpdateInput, projectId?: string): Promise<Agent> {
|
||||
return api<Agent>(withProjectId(`/agents/${encodeURIComponent(agentId)}`, projectId), {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(updates),
|
||||
});
|
||||
}
|
||||
|
||||
/** Update an agent's state */
|
||||
export function updateAgentState(agentId: string, state: AgentState): Promise<Agent> {
|
||||
return api<Agent>(`/agents/${encodeURIComponent(agentId)}/state`, {
|
||||
export function updateAgentState(agentId: string, state: AgentState, projectId?: string): Promise<Agent> {
|
||||
return api<Agent>(withProjectId(`/agents/${encodeURIComponent(agentId)}/state`, projectId), {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ state }),
|
||||
});
|
||||
}
|
||||
|
||||
/** Delete an agent */
|
||||
export function deleteAgent(agentId: string): Promise<void> {
|
||||
return api<void>(`/agents/${encodeURIComponent(agentId)}`, {
|
||||
export function deleteAgent(agentId: string, projectId?: string): Promise<void> {
|
||||
return api<void>(withProjectId(`/agents/${encodeURIComponent(agentId)}`, projectId), {
|
||||
method: "DELETE",
|
||||
});
|
||||
}
|
||||
|
||||
/** Record a heartbeat for an agent */
|
||||
export function recordAgentHeartbeat(agentId: string, status: "ok" | "missed" | "recovered" = "ok"): Promise<AgentHeartbeatEvent> {
|
||||
return api<AgentHeartbeatEvent>(`/agents/${encodeURIComponent(agentId)}/heartbeat`, {
|
||||
export function recordAgentHeartbeat(
|
||||
agentId: string,
|
||||
status: "ok" | "missed" | "recovered" = "ok",
|
||||
projectId?: string,
|
||||
): Promise<AgentHeartbeatEvent> {
|
||||
return api<AgentHeartbeatEvent>(withProjectId(`/agents/${encodeURIComponent(agentId)}/heartbeat`, projectId), {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ status }),
|
||||
});
|
||||
}
|
||||
|
||||
/** Fetch heartbeat history for an agent */
|
||||
export function fetchAgentHeartbeats(agentId: string, limit?: number): Promise<AgentHeartbeatEvent[]> {
|
||||
const query = limit !== undefined ? `?limit=${limit}` : "";
|
||||
export function fetchAgentHeartbeats(agentId: string, limit?: number, projectId?: string): Promise<AgentHeartbeatEvent[]> {
|
||||
const params = new URLSearchParams();
|
||||
if (limit !== undefined) params.set("limit", String(limit));
|
||||
if (projectId) params.set("projectId", projectId);
|
||||
const query = params.size > 0 ? `?${params.toString()}` : "";
|
||||
return api<AgentHeartbeatEvent[]>(`/agents/${encodeURIComponent(agentId)}/heartbeats${query}`);
|
||||
}
|
||||
|
||||
@@ -1666,13 +1691,13 @@ export interface BackupCreateResponse {
|
||||
}
|
||||
|
||||
/** Fetch all database backups */
|
||||
export function fetchBackups(): Promise<BackupListResponse> {
|
||||
return api<BackupListResponse>("/backups");
|
||||
export function fetchBackups(projectId?: string): Promise<BackupListResponse> {
|
||||
return api<BackupListResponse>(withProjectId("/backups", projectId));
|
||||
}
|
||||
|
||||
/** Create a new database backup immediately */
|
||||
export function createBackup(): Promise<BackupCreateResponse> {
|
||||
return api<BackupCreateResponse>("/backups", { method: "POST" });
|
||||
export function createBackup(projectId?: string): Promise<BackupCreateResponse> {
|
||||
return api<BackupCreateResponse>(withProjectId("/backups", projectId), { method: "POST" });
|
||||
}
|
||||
|
||||
// --- Settings Export/Import API ---
|
||||
@@ -1695,17 +1720,19 @@ export interface SettingsImportResponse {
|
||||
}
|
||||
|
||||
/** Export settings as JSON */
|
||||
export function exportSettings(scope?: 'global' | 'project' | 'both'): Promise<SettingsExportData> {
|
||||
const query = scope ? `?scope=${scope}` : "";
|
||||
return api<SettingsExportData>(`/settings/export${query}`);
|
||||
export function exportSettings(scope?: 'global' | 'project' | 'both', projectId?: string): Promise<SettingsExportData> {
|
||||
const path = withProjectId("/settings/export", projectId);
|
||||
const scopedPath = scope ? `${path}${path.includes("?") ? "&" : "?"}scope=${encodeURIComponent(scope)}` : path;
|
||||
return api<SettingsExportData>(scopedPath);
|
||||
}
|
||||
|
||||
/** Import settings from JSON data */
|
||||
export function importSettings(
|
||||
data: SettingsExportData,
|
||||
options?: { scope?: 'global' | 'project' | 'both'; merge?: boolean }
|
||||
options?: { scope?: 'global' | 'project' | 'both'; merge?: boolean },
|
||||
projectId?: string
|
||||
): Promise<SettingsImportResponse> {
|
||||
return api<SettingsImportResponse>("/settings/import", {
|
||||
return api<SettingsImportResponse>(withProjectId("/settings/import", projectId), {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
data,
|
||||
@@ -1956,7 +1983,7 @@ export function fetchProjectHealth(id: string): Promise<ProjectHealth> {
|
||||
* Returns settings-based values and lastActivityAt.
|
||||
* Counts are derived client-side from the tasks array.
|
||||
*/
|
||||
export function fetchExecutorStats(): Promise<{
|
||||
export function fetchExecutorStats(projectId?: string): Promise<{
|
||||
globalPause: boolean;
|
||||
enginePaused: boolean;
|
||||
maxConcurrent: number;
|
||||
@@ -1967,7 +1994,7 @@ export function fetchExecutorStats(): Promise<{
|
||||
enginePaused: boolean;
|
||||
maxConcurrent: number;
|
||||
lastActivityAt?: string;
|
||||
}>("/executor/stats");
|
||||
}>(withProjectId("/executor/stats", projectId));
|
||||
}
|
||||
|
||||
/** Fetch unified activity feed */
|
||||
@@ -2080,9 +2107,10 @@ export interface TaskDiff {
|
||||
}
|
||||
|
||||
/** Fetch diff for a task's changes */
|
||||
export function fetchTaskDiff(taskId: string, worktree?: string): Promise<TaskDiff> {
|
||||
export function fetchTaskDiff(taskId: string, worktree?: string, projectId?: string): Promise<TaskDiff> {
|
||||
const params = new URLSearchParams();
|
||||
if (worktree) params.set("worktree", worktree);
|
||||
if (projectId) params.set("projectId", projectId);
|
||||
const query = params.size > 0 ? `?${params.toString()}` : "";
|
||||
return api<TaskDiff>(`/tasks/${encodeURIComponent(taskId)}/diff${query}`);
|
||||
}
|
||||
@@ -2096,8 +2124,8 @@ export interface TaskFileDiff {
|
||||
}
|
||||
|
||||
/** Fetch file diffs for a task */
|
||||
export function fetchTaskFileDiffs(taskId: string): Promise<TaskFileDiff[]> {
|
||||
return api<TaskFileDiff[]>(`/tasks/${encodeURIComponent(taskId)}/file-diffs`);
|
||||
export function fetchTaskFileDiffs(taskId: string, projectId?: string): Promise<TaskFileDiff[]> {
|
||||
return api<TaskFileDiff[]>(withProjectId(`/tasks/${encodeURIComponent(taskId)}/file-diffs`, projectId));
|
||||
}
|
||||
|
||||
// ── Mission API ───────────────────────────────────────────────────────────
|
||||
@@ -2182,72 +2210,73 @@ export interface MissionWithHierarchy extends Mission {
|
||||
}
|
||||
|
||||
/** Fetch all missions */
|
||||
export function fetchMissions(): Promise<Mission[]> {
|
||||
return api<Mission[]>("/missions");
|
||||
export function fetchMissions(projectId?: string): Promise<Mission[]> {
|
||||
return api<Mission[]>(withProjectId("/missions", projectId));
|
||||
}
|
||||
|
||||
/** Create a new mission */
|
||||
export function createMission(input: { title: string; description?: string }): Promise<Mission> {
|
||||
return api<Mission>("/missions", {
|
||||
export function createMission(input: { title: string; description?: string }, projectId?: string): Promise<Mission> {
|
||||
return api<Mission>(withProjectId("/missions", projectId), {
|
||||
method: "POST",
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
}
|
||||
|
||||
/** Get mission with full hierarchy */
|
||||
export function fetchMission(missionId: string): Promise<MissionWithHierarchy> {
|
||||
return api<MissionWithHierarchy>(`/missions/${encodeURIComponent(missionId)}`);
|
||||
export function fetchMission(missionId: string, projectId?: string): Promise<MissionWithHierarchy> {
|
||||
return api<MissionWithHierarchy>(withProjectId(`/missions/${encodeURIComponent(missionId)}`, projectId));
|
||||
}
|
||||
|
||||
/** Update mission */
|
||||
export function updateMission(missionId: string, updates: Partial<Mission>): Promise<Mission> {
|
||||
return api<Mission>(`/missions/${encodeURIComponent(missionId)}`, {
|
||||
export function updateMission(missionId: string, updates: Partial<Mission>, projectId?: string): Promise<Mission> {
|
||||
return api<Mission>(withProjectId(`/missions/${encodeURIComponent(missionId)}`, projectId), {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(updates),
|
||||
});
|
||||
}
|
||||
|
||||
/** Delete mission */
|
||||
export function deleteMission(missionId: string): Promise<void> {
|
||||
return api<void>(`/missions/${encodeURIComponent(missionId)}`, {
|
||||
export function deleteMission(missionId: string, projectId?: string): Promise<void> {
|
||||
return api<void>(withProjectId(`/missions/${encodeURIComponent(missionId)}`, projectId), {
|
||||
method: "DELETE",
|
||||
});
|
||||
}
|
||||
|
||||
/** Get mission computed status */
|
||||
export function fetchMissionStatus(missionId: string): Promise<{ status: string }> {
|
||||
return api<{ status: string }>(`/missions/${encodeURIComponent(missionId)}/status`);
|
||||
export function fetchMissionStatus(missionId: string, projectId?: string): Promise<{ status: string }> {
|
||||
return api<{ status: string }>(withProjectId(`/missions/${encodeURIComponent(missionId)}/status`, projectId));
|
||||
}
|
||||
|
||||
/** Add milestone to mission */
|
||||
export function createMilestone(
|
||||
missionId: string,
|
||||
input: { title: string; description?: string; dependencies?: string[] }
|
||||
input: { title: string; description?: string; dependencies?: string[] },
|
||||
projectId?: string
|
||||
): Promise<Milestone> {
|
||||
return api<Milestone>(`/missions/${encodeURIComponent(missionId)}/milestones`, {
|
||||
return api<Milestone>(withProjectId(`/missions/${encodeURIComponent(missionId)}/milestones`, projectId), {
|
||||
method: "POST",
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
}
|
||||
|
||||
/** Update milestone */
|
||||
export function updateMilestone(milestoneId: string, updates: Partial<Milestone>): Promise<Milestone> {
|
||||
return api<Milestone>(`/missions/milestones/${encodeURIComponent(milestoneId)}`, {
|
||||
export function updateMilestone(milestoneId: string, updates: Partial<Milestone>, projectId?: string): Promise<Milestone> {
|
||||
return api<Milestone>(withProjectId(`/missions/milestones/${encodeURIComponent(milestoneId)}`, projectId), {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(updates),
|
||||
});
|
||||
}
|
||||
|
||||
/** Delete milestone */
|
||||
export function deleteMilestone(milestoneId: string): Promise<void> {
|
||||
return api<void>(`/missions/milestones/${encodeURIComponent(milestoneId)}`, {
|
||||
export function deleteMilestone(milestoneId: string, projectId?: string): Promise<void> {
|
||||
return api<void>(withProjectId(`/missions/milestones/${encodeURIComponent(milestoneId)}`, projectId), {
|
||||
method: "DELETE",
|
||||
});
|
||||
}
|
||||
|
||||
/** Reorder milestones */
|
||||
export function reorderMilestones(missionId: string, orderedIds: string[]): Promise<void> {
|
||||
return api<void>(`/missions/${encodeURIComponent(missionId)}/milestones/reorder`, {
|
||||
export function reorderMilestones(missionId: string, orderedIds: string[], projectId?: string): Promise<void> {
|
||||
return api<void>(withProjectId(`/missions/${encodeURIComponent(missionId)}/milestones/reorder`, projectId), {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ orderedIds }),
|
||||
});
|
||||
@@ -2256,39 +2285,40 @@ export function reorderMilestones(missionId: string, orderedIds: string[]): Prom
|
||||
/** Add slice to milestone */
|
||||
export function createSlice(
|
||||
milestoneId: string,
|
||||
input: { title: string; description?: string }
|
||||
input: { title: string; description?: string },
|
||||
projectId?: string
|
||||
): Promise<Slice> {
|
||||
return api<Slice>(`/missions/milestones/${encodeURIComponent(milestoneId)}/slices`, {
|
||||
return api<Slice>(withProjectId(`/missions/milestones/${encodeURIComponent(milestoneId)}/slices`, projectId), {
|
||||
method: "POST",
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
}
|
||||
|
||||
/** Update slice */
|
||||
export function updateSlice(sliceId: string, updates: Partial<Slice>): Promise<Slice> {
|
||||
return api<Slice>(`/missions/slices/${encodeURIComponent(sliceId)}`, {
|
||||
export function updateSlice(sliceId: string, updates: Partial<Slice>, projectId?: string): Promise<Slice> {
|
||||
return api<Slice>(withProjectId(`/missions/slices/${encodeURIComponent(sliceId)}`, projectId), {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(updates),
|
||||
});
|
||||
}
|
||||
|
||||
/** Delete slice */
|
||||
export function deleteSlice(sliceId: string): Promise<void> {
|
||||
return api<void>(`/missions/slices/${encodeURIComponent(sliceId)}`, {
|
||||
export function deleteSlice(sliceId: string, projectId?: string): Promise<void> {
|
||||
return api<void>(withProjectId(`/missions/slices/${encodeURIComponent(sliceId)}`, projectId), {
|
||||
method: "DELETE",
|
||||
});
|
||||
}
|
||||
|
||||
/** Activate slice */
|
||||
export function activateSlice(sliceId: string): Promise<Slice> {
|
||||
return api<Slice>(`/missions/slices/${encodeURIComponent(sliceId)}/activate`, {
|
||||
export function activateSlice(sliceId: string, projectId?: string): Promise<Slice> {
|
||||
return api<Slice>(withProjectId(`/missions/slices/${encodeURIComponent(sliceId)}/activate`, projectId), {
|
||||
method: "POST",
|
||||
});
|
||||
}
|
||||
|
||||
/** Reorder slices */
|
||||
export function reorderSlices(milestoneId: string, orderedIds: string[]): Promise<void> {
|
||||
return api<void>(`/missions/milestones/${encodeURIComponent(milestoneId)}/slices/reorder`, {
|
||||
export function reorderSlices(milestoneId: string, orderedIds: string[], projectId?: string): Promise<void> {
|
||||
return api<void>(withProjectId(`/missions/milestones/${encodeURIComponent(milestoneId)}/slices/reorder`, projectId), {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ orderedIds }),
|
||||
});
|
||||
@@ -2297,43 +2327,41 @@ export function reorderSlices(milestoneId: string, orderedIds: string[]): Promis
|
||||
/** Add feature to slice */
|
||||
export function createFeature(
|
||||
sliceId: string,
|
||||
input: { title: string; description?: string; acceptanceCriteria?: string }
|
||||
input: { title: string; description?: string; acceptanceCriteria?: string },
|
||||
projectId?: string
|
||||
): Promise<MissionFeature> {
|
||||
return api<MissionFeature>(`/missions/slices/${encodeURIComponent(sliceId)}/features`, {
|
||||
return api<MissionFeature>(withProjectId(`/missions/slices/${encodeURIComponent(sliceId)}/features`, projectId), {
|
||||
method: "POST",
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
}
|
||||
|
||||
/** Update feature */
|
||||
export function updateFeature(featureId: string, updates: Partial<MissionFeature>): Promise<MissionFeature> {
|
||||
return api<MissionFeature>(`/missions/features/${encodeURIComponent(featureId)}`, {
|
||||
export function updateFeature(featureId: string, updates: Partial<MissionFeature>, projectId?: string): Promise<MissionFeature> {
|
||||
return api<MissionFeature>(withProjectId(`/missions/features/${encodeURIComponent(featureId)}`, projectId), {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(updates),
|
||||
});
|
||||
}
|
||||
|
||||
/** Delete feature */
|
||||
export function deleteFeature(featureId: string): Promise<void> {
|
||||
return api<void>(`/missions/features/${encodeURIComponent(featureId)}`, {
|
||||
export function deleteFeature(featureId: string, projectId?: string): Promise<void> {
|
||||
return api<void>(withProjectId(`/missions/features/${encodeURIComponent(featureId)}`, projectId), {
|
||||
method: "DELETE",
|
||||
});
|
||||
}
|
||||
|
||||
/** Link feature to task */
|
||||
export function linkFeatureToTask(featureId: string, taskId: string): Promise<MissionFeature> {
|
||||
return api<MissionFeature>(`/missions/features/${encodeURIComponent(featureId)}/link-task`, {
|
||||
export function linkFeatureToTask(featureId: string, taskId: string, projectId?: string): Promise<MissionFeature> {
|
||||
return api<MissionFeature>(withProjectId(`/missions/features/${encodeURIComponent(featureId)}/link-task`, projectId), {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ taskId }),
|
||||
});
|
||||
}
|
||||
|
||||
/** Unlink feature from task */
|
||||
export function unlinkFeatureFromTask(featureId: string): Promise<MissionFeature> {
|
||||
return api<MissionFeature>(`/missions/features/${encodeURIComponent(featureId)}/unlink-task`, {
|
||||
export function unlinkFeatureFromTask(featureId: string, projectId?: string): Promise<MissionFeature> {
|
||||
return api<MissionFeature>(withProjectId(`/missions/features/${encodeURIComponent(featureId)}/unlink-task`, projectId), {
|
||||
method: "POST",
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -41,6 +41,7 @@ function relativeTime(iso: string): string {
|
||||
|
||||
interface AgentDetailViewProps {
|
||||
agentId: string;
|
||||
projectId?: string;
|
||||
onClose: () => void;
|
||||
addToast: (message: string, type?: "success" | "error") => void;
|
||||
}
|
||||
@@ -68,7 +69,7 @@ const RUN_STATUS_ICONS: Record<string, { icon: typeof CheckCircle; color: string
|
||||
terminated: { icon: Square, color: "text-gray-500" },
|
||||
};
|
||||
|
||||
export function AgentDetailView({ agentId, onClose, addToast }: AgentDetailViewProps) {
|
||||
export function AgentDetailView({ agentId, projectId, onClose, addToast }: AgentDetailViewProps) {
|
||||
const [agent, setAgent] = useState<AgentDetail | null>(null);
|
||||
const [logs, setLogs] = useState<AgentLogEntry[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
@@ -79,7 +80,7 @@ export function AgentDetailView({ agentId, onClose, addToast }: AgentDetailViewP
|
||||
const loadAgent = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const data = await fetchAgent(agentId);
|
||||
const data = await fetchAgent(agentId, projectId);
|
||||
setAgent(data);
|
||||
} catch (err: any) {
|
||||
addToast(`Failed to load agent: ${err.message}`, "error");
|
||||
@@ -87,7 +88,7 @@ export function AgentDetailView({ agentId, onClose, addToast }: AgentDetailViewP
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [agentId, addToast, onClose]);
|
||||
}, [agentId, addToast, onClose, projectId]);
|
||||
|
||||
const loadLogs = useCallback(async () => {
|
||||
// Agent logs are tied to tasks, not agents directly.
|
||||
@@ -96,13 +97,13 @@ export function AgentDetailView({ agentId, onClose, addToast }: AgentDetailViewP
|
||||
// If the agent is working on a task, we could show task logs.
|
||||
if (agent?.taskId) {
|
||||
try {
|
||||
const data = await fetchAgentLogs(agent.taskId);
|
||||
const data = await fetchAgentLogs(agent.taskId, projectId);
|
||||
setLogs(data);
|
||||
} catch (err: any) {
|
||||
console.error("Failed to load task logs:", err);
|
||||
}
|
||||
}
|
||||
}, [agent?.taskId]);
|
||||
}, [agent?.taskId, projectId]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadAgent();
|
||||
@@ -121,9 +122,10 @@ export function AgentDetailView({ agentId, onClose, addToast }: AgentDetailViewP
|
||||
return;
|
||||
}
|
||||
|
||||
const es = new EventSource(`/api/tasks/${encodeURIComponent(agent.taskId)}/logs/stream`);
|
||||
const query = projectId ? `?projectId=${encodeURIComponent(projectId)}` : "";
|
||||
const es = new EventSource(`/api/tasks/${encodeURIComponent(agent.taskId)}/logs/stream${query}`);
|
||||
|
||||
es.onmessage = (e) => {
|
||||
const handleAgentLog = (e: MessageEvent) => {
|
||||
try {
|
||||
const entry: AgentLogEntry = JSON.parse(e.data);
|
||||
setLogs(prev => [entry, ...prev]);
|
||||
@@ -138,6 +140,8 @@ export function AgentDetailView({ agentId, onClose, addToast }: AgentDetailViewP
|
||||
}
|
||||
};
|
||||
|
||||
es.addEventListener("agent:log", handleAgentLog as EventListener);
|
||||
|
||||
es.onerror = () => {
|
||||
setIsStreaming(false);
|
||||
};
|
||||
@@ -147,14 +151,15 @@ export function AgentDetailView({ agentId, onClose, addToast }: AgentDetailViewP
|
||||
};
|
||||
|
||||
return () => {
|
||||
es.removeEventListener("agent:log", handleAgentLog as EventListener);
|
||||
es.close();
|
||||
setIsStreaming(false);
|
||||
};
|
||||
}, [agent?.taskId, activeTab]);
|
||||
}, [agent?.taskId, activeTab, projectId]);
|
||||
|
||||
const handleStateChange = async (newState: AgentState) => {
|
||||
try {
|
||||
await updateAgentState(agentId, newState);
|
||||
await updateAgentState(agentId, newState, projectId);
|
||||
addToast(`Agent state updated to ${newState}`, "success");
|
||||
void loadAgent();
|
||||
} catch (err: any) {
|
||||
@@ -165,7 +170,7 @@ export function AgentDetailView({ agentId, onClose, addToast }: AgentDetailViewP
|
||||
const handleDelete = async () => {
|
||||
if (!agent || !confirm(`Delete agent "${agent.name}"? This cannot be undone.`)) return;
|
||||
try {
|
||||
await deleteAgent(agentId);
|
||||
await deleteAgent(agentId, projectId);
|
||||
addToast(`Agent "${agent.name}" deleted`, "success");
|
||||
onClose();
|
||||
} catch (err: any) {
|
||||
|
||||
@@ -8,6 +8,7 @@ interface AgentListModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
addToast: (message: string, type?: "success" | "error") => void;
|
||||
projectId?: string;
|
||||
}
|
||||
|
||||
const AGENT_ROLES: { value: AgentCapability; label: string; icon: string }[] = [
|
||||
@@ -26,7 +27,7 @@ const STATE_COLORS: Record<AgentState, { bg: string; text: string; border: strin
|
||||
terminated: { bg: "var(--state-error-bg)", text: "var(--state-error-text)", border: "var(--state-error-border)" },
|
||||
};
|
||||
|
||||
export function AgentListModal({ isOpen, onClose, addToast }: AgentListModalProps) {
|
||||
export function AgentListModal({ isOpen, onClose, addToast, projectId }: AgentListModalProps) {
|
||||
const [agents, setAgents] = useState<Agent[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
@@ -51,14 +52,14 @@ export function AgentListModal({ isOpen, onClose, addToast }: AgentListModalProp
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const filter = filterState !== "all" ? { state: filterState } : undefined;
|
||||
const data = await fetchAgents(filter);
|
||||
const data = await fetchAgents(filter, projectId);
|
||||
setAgents(data);
|
||||
} catch (err: any) {
|
||||
addToast(`Failed to load agents: ${err.message}`, "error");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [filterState, addToast]);
|
||||
}, [filterState, addToast, projectId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
@@ -69,7 +70,7 @@ export function AgentListModal({ isOpen, onClose, addToast }: AgentListModalProp
|
||||
const handleCreate = async () => {
|
||||
if (!newAgentName.trim()) return;
|
||||
try {
|
||||
await createAgent({ name: newAgentName.trim(), role: newAgentRole });
|
||||
await createAgent({ name: newAgentName.trim(), role: newAgentRole }, projectId);
|
||||
addToast(`Agent "${newAgentName}" created`, "success");
|
||||
setNewAgentName("");
|
||||
setIsCreating(false);
|
||||
@@ -81,7 +82,7 @@ export function AgentListModal({ isOpen, onClose, addToast }: AgentListModalProp
|
||||
|
||||
const handleStateChange = async (agentId: string, newState: AgentState) => {
|
||||
try {
|
||||
await updateAgentState(agentId, newState);
|
||||
await updateAgentState(agentId, newState, projectId);
|
||||
addToast(`Agent state updated to ${newState}`, "success");
|
||||
void loadAgents();
|
||||
} catch (err: any) {
|
||||
@@ -92,7 +93,7 @@ export function AgentListModal({ isOpen, onClose, addToast }: AgentListModalProp
|
||||
const handleDelete = async (agentId: string, agentName: string) => {
|
||||
if (!confirm(`Delete agent "${agentName}"? This cannot be undone.`)) return;
|
||||
try {
|
||||
await deleteAgent(agentId);
|
||||
await deleteAgent(agentId, projectId);
|
||||
addToast(`Agent "${agentName}" deleted`, "success");
|
||||
void loadAgents();
|
||||
} catch (err: any) {
|
||||
@@ -111,7 +112,7 @@ export function AgentListModal({ isOpen, onClose, addToast }: AgentListModalProp
|
||||
}
|
||||
|
||||
try {
|
||||
await updateAgent(agentId, { role: newRole });
|
||||
await updateAgent(agentId, { role: newRole }, projectId);
|
||||
addToast(`Agent role updated to ${AGENT_ROLES.find(r => r.value === newRole)?.label ?? newRole}`, "success");
|
||||
setEditingRoleForAgent(null);
|
||||
void loadAgents();
|
||||
@@ -734,4 +735,4 @@ export function AgentListModal({ isOpen, onClose, addToast }: AgentListModalProp
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { AgentDetailView } from "./AgentDetailView";
|
||||
|
||||
export interface AgentsViewProps {
|
||||
addToast: (message: string, type?: "success" | "error") => void;
|
||||
projectId?: string;
|
||||
}
|
||||
|
||||
const AGENT_ROLES: { value: AgentCapability; label: string; icon: string }[] = [
|
||||
@@ -25,7 +26,7 @@ const STATE_COLORS: Record<AgentState, { bg: string; text: string; border: strin
|
||||
terminated: { bg: "var(--state-error-bg)", text: "var(--state-error-text)", border: "var(--state-error-border)" },
|
||||
};
|
||||
|
||||
export function AgentsView({ addToast }: AgentsViewProps) {
|
||||
export function AgentsView({ addToast, projectId }: AgentsViewProps) {
|
||||
const [agents, setAgents] = useState<Agent[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
@@ -51,14 +52,14 @@ export function AgentsView({ addToast }: AgentsViewProps) {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const filter = filterState !== "all" ? { state: filterState } : undefined;
|
||||
const data = await fetchAgents(filter);
|
||||
const data = await fetchAgents(filter, projectId);
|
||||
setAgents(data);
|
||||
} catch (err: any) {
|
||||
addToast(`Failed to load agents: ${err.message}`, "error");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [filterState, addToast]);
|
||||
}, [filterState, addToast, projectId]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadAgents();
|
||||
@@ -67,7 +68,7 @@ export function AgentsView({ addToast }: AgentsViewProps) {
|
||||
const handleCreate = async () => {
|
||||
if (!newAgentName.trim()) return;
|
||||
try {
|
||||
await createAgent({ name: newAgentName.trim(), role: newAgentRole });
|
||||
await createAgent({ name: newAgentName.trim(), role: newAgentRole }, projectId);
|
||||
addToast(`Agent "${newAgentName}" created`, "success");
|
||||
setNewAgentName("");
|
||||
setIsCreating(false);
|
||||
@@ -79,7 +80,7 @@ export function AgentsView({ addToast }: AgentsViewProps) {
|
||||
|
||||
const handleStateChange = async (agentId: string, newState: AgentState) => {
|
||||
try {
|
||||
await updateAgentState(agentId, newState);
|
||||
await updateAgentState(agentId, newState, projectId);
|
||||
addToast(`Agent state updated to ${newState}`, "success");
|
||||
void loadAgents();
|
||||
} catch (err: any) {
|
||||
@@ -90,7 +91,7 @@ export function AgentsView({ addToast }: AgentsViewProps) {
|
||||
const handleDelete = async (agentId: string, agentName: string) => {
|
||||
if (!confirm(`Delete agent "${agentName}"? This cannot be undone.`)) return;
|
||||
try {
|
||||
await deleteAgent(agentId);
|
||||
await deleteAgent(agentId, projectId);
|
||||
addToast(`Agent "${agentName}" deleted`, "success");
|
||||
void loadAgents();
|
||||
} catch (err: any) {
|
||||
@@ -109,7 +110,7 @@ export function AgentsView({ addToast }: AgentsViewProps) {
|
||||
}
|
||||
|
||||
try {
|
||||
await updateAgent(agentId, { role: newRole });
|
||||
await updateAgent(agentId, { role: newRole }, projectId);
|
||||
addToast(`Agent role updated to ${AGENT_ROLES.find(r => r.value === newRole)?.label ?? newRole}`, "success");
|
||||
setEditingRoleForAgent(null);
|
||||
void loadAgents();
|
||||
@@ -499,6 +500,7 @@ export function AgentsView({ addToast }: AgentsViewProps) {
|
||||
{selectedAgentId && (
|
||||
<AgentDetailView
|
||||
agentId={selectedAgentId}
|
||||
projectId={projectId}
|
||||
onClose={() => setSelectedAgentId(null)}
|
||||
addToast={addToast}
|
||||
/>
|
||||
|
||||
@@ -8,6 +8,7 @@ import type { ModelInfo } from "../api";
|
||||
|
||||
interface BoardProps {
|
||||
tasks: Task[];
|
||||
projectId?: string;
|
||||
maxConcurrent: number;
|
||||
onMoveTask: (id: string, column: ColumnType) => Promise<Task>;
|
||||
onOpenDetail: (task: TaskDetail) => void;
|
||||
@@ -53,9 +54,9 @@ function areTaskArraysEqual(previous: Task[], next: Task[]): boolean {
|
||||
return previous.every((task, index) => task === next[index]);
|
||||
}
|
||||
|
||||
export function Board({ tasks, maxConcurrent, onMoveTask, onOpenDetail, addToast, onQuickCreate, onNewTask, autoMerge, onToggleAutoMerge, globalPaused, onUpdateTask, onArchiveTask, onUnarchiveTask, onArchiveAllDone, searchQuery = "", availableModels, onPlanningMode, onSubtaskBreakdown, onOpenFilesForTask }: BoardProps) {
|
||||
export function Board({ tasks, projectId, maxConcurrent, onMoveTask, onOpenDetail, addToast, onQuickCreate, onNewTask, autoMerge, onToggleAutoMerge, globalPaused, onUpdateTask, onArchiveTask, onUnarchiveTask, onArchiveAllDone, searchQuery = "", availableModels, onPlanningMode, onSubtaskBreakdown, onOpenFilesForTask }: BoardProps) {
|
||||
const [archivedCollapsed, setArchivedCollapsed] = useState(true);
|
||||
const { fetchBatch } = useBatchBadgeFetch();
|
||||
const { fetchBatch } = useBatchBadgeFetch(projectId);
|
||||
const debounceTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const tasksByColumnCacheRef = useRef<Record<ColumnType, Task[]>>({
|
||||
triage: [],
|
||||
@@ -151,6 +152,7 @@ export function Board({ tasks, maxConcurrent, onMoveTask, onOpenDetail, addToast
|
||||
key={col}
|
||||
column={col}
|
||||
tasks={tasksByColumn[col]}
|
||||
projectId={projectId}
|
||||
maxConcurrent={maxConcurrent}
|
||||
onMoveTask={onMoveTask}
|
||||
onOpenDetail={onOpenDetail}
|
||||
|
||||
@@ -8,6 +8,7 @@ interface ChangedFilesModalProps {
|
||||
taskId: string;
|
||||
worktree: string | undefined;
|
||||
column: string;
|
||||
projectId?: string;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
@@ -44,8 +45,8 @@ function getDiffStat(diff: string): string {
|
||||
return statLines.join("\n").trim();
|
||||
}
|
||||
|
||||
export function ChangedFilesModal({ taskId, worktree, column, isOpen, onClose }: ChangedFilesModalProps) {
|
||||
const { files, loading, error, selectedFile, setSelectedFile } = useChangedFiles(taskId, worktree, column);
|
||||
export function ChangedFilesModal({ taskId, worktree, column, projectId, isOpen, onClose }: ChangedFilesModalProps) {
|
||||
const { files, loading, error, selectedFile, setSelectedFile } = useChangedFiles(taskId, worktree, column, projectId);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
@@ -17,6 +17,7 @@ const VISIBLE_TASKS_INCREMENT = 25;
|
||||
interface ColumnProps {
|
||||
column: ColumnType;
|
||||
tasks: Task[];
|
||||
projectId?: string;
|
||||
maxConcurrent: number;
|
||||
onMoveTask: (id: string, column: ColumnType) => Promise<Task>;
|
||||
onOpenDetail: (task: TaskDetail) => void;
|
||||
@@ -48,7 +49,7 @@ interface ColumnProps {
|
||||
onOpenFilesForTask?: (taskId: string, worktree: string | undefined, column: string) => void;
|
||||
}
|
||||
|
||||
function ColumnComponent({ column, tasks, maxConcurrent, onMoveTask, onOpenDetail, addToast, onQuickCreate, onNewTask, autoMerge, onToggleAutoMerge, globalPaused, onUpdateTask, onArchiveTask, onUnarchiveTask, onArchiveAllDone, collapsed, onToggleCollapse, allTasks, availableModels, onPlanningMode, onSubtaskBreakdown, onOpenFilesForTask }: ColumnProps) {
|
||||
function ColumnComponent({ column, tasks, projectId, maxConcurrent, onMoveTask, onOpenDetail, addToast, onQuickCreate, onNewTask, autoMerge, onToggleAutoMerge, globalPaused, onUpdateTask, onArchiveTask, onUnarchiveTask, onArchiveAllDone, collapsed, onToggleCollapse, allTasks, availableModels, onPlanningMode, onSubtaskBreakdown, onOpenFilesForTask }: ColumnProps) {
|
||||
const [dragOver, setDragOver] = useState(false);
|
||||
const [visibleTaskCount, setVisibleTaskCount] = useState(VISIBLE_TASKS_INITIAL);
|
||||
const countFlashing = useFlashOnIncrease(tasks.length);
|
||||
@@ -207,6 +208,7 @@ function ColumnComponent({ column, tasks, maxConcurrent, onMoveTask, onOpenDetai
|
||||
label={group.label}
|
||||
activeTasks={group.activeTasks}
|
||||
queuedTasks={group.queuedTasks}
|
||||
projectId={projectId}
|
||||
onOpenDetail={onOpenDetail}
|
||||
addToast={addToast}
|
||||
globalPaused={globalPaused}
|
||||
@@ -223,6 +225,7 @@ function ColumnComponent({ column, tasks, maxConcurrent, onMoveTask, onOpenDetai
|
||||
<TaskCard
|
||||
key={task.id}
|
||||
task={task}
|
||||
projectId={projectId}
|
||||
onOpenDetail={onOpenDetail}
|
||||
addToast={addToast}
|
||||
globalPaused={globalPaused}
|
||||
|
||||
@@ -49,6 +49,7 @@ export interface HeaderProps {
|
||||
currentProject?: ProjectInfo | null;
|
||||
onSelectProject?: (project: ProjectInfo) => void;
|
||||
onViewAllProjects?: () => void;
|
||||
projectId?: string;
|
||||
}
|
||||
|
||||
function useIsMobile() {
|
||||
@@ -96,6 +97,7 @@ export function Header({
|
||||
currentProject,
|
||||
onSelectProject,
|
||||
onViewAllProjects,
|
||||
projectId,
|
||||
}: HeaderProps) {
|
||||
const isMobile = useIsMobile();
|
||||
const [isMobileSearchOpen, setIsMobileSearchOpen] = useState(false);
|
||||
@@ -416,6 +418,7 @@ export function Header({
|
||||
<QuickScriptsDropdown
|
||||
onOpenScripts={onOpenScripts}
|
||||
onRunScript={onRunScript}
|
||||
projectId={projectId}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ interface InlineCreateCardProps {
|
||||
onSubmit: (input: TaskCreateInput) => Promise<Task>;
|
||||
onCancel: () => void;
|
||||
addToast: (msg: string, type?: ToastType) => void;
|
||||
projectId?: string;
|
||||
/**
|
||||
* Optional model list from a parent surface. When omitted, InlineCreateCard
|
||||
* fetches models itself so it can stay reusable in both list and board flows
|
||||
@@ -61,6 +62,7 @@ export function InlineCreateCard({
|
||||
onSubmit,
|
||||
onCancel,
|
||||
addToast,
|
||||
projectId,
|
||||
availableModels,
|
||||
onPlanningMode,
|
||||
onSubtaskBreakdown,
|
||||
@@ -160,7 +162,7 @@ export function InlineCreateCard({
|
||||
}
|
||||
});
|
||||
|
||||
fetchSettings()
|
||||
fetchSettings(projectId)
|
||||
.then((nextSettings) => {
|
||||
if (!cancelled) {
|
||||
setSettings(nextSettings);
|
||||
@@ -175,7 +177,7 @@ export function InlineCreateCard({
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [availableModels]);
|
||||
}, [availableModels, projectId]);
|
||||
|
||||
const executorSelectionValue = getModelSelectionValue(executorProvider, executorModelId);
|
||||
const validatorSelectionValue = getModelSelectionValue(validatorProvider, validatorModelId);
|
||||
@@ -293,7 +295,7 @@ export function InlineCreateCard({
|
||||
const failures: string[] = [];
|
||||
for (const img of pendingImages) {
|
||||
try {
|
||||
await uploadAttachment(task.id, img.file);
|
||||
await uploadAttachment(task.id, img.file, projectId);
|
||||
} catch {
|
||||
failures.push(img.file.name);
|
||||
}
|
||||
|
||||
@@ -474,6 +474,7 @@ export function ListView({
|
||||
payload.modelId,
|
||||
payload.validatorModelProvider,
|
||||
payload.validatorModelId,
|
||||
projectId,
|
||||
);
|
||||
|
||||
// Optimistically update parent with returned tasks
|
||||
@@ -492,18 +493,18 @@ export function ListView({
|
||||
} finally {
|
||||
setIsApplying(false);
|
||||
}
|
||||
}, [selectedTaskIds, tasks, executorModel, validatorModel, addToast, clearSelection, onTasksUpdated]);
|
||||
}, [selectedTaskIds, tasks, executorModel, validatorModel, projectId, addToast, clearSelection, onTasksUpdated]);
|
||||
|
||||
const handleRowClick = useCallback(
|
||||
async (task: Task) => {
|
||||
try {
|
||||
const detail = await fetchTaskDetail(task.id);
|
||||
const detail = await fetchTaskDetail(task.id, projectId);
|
||||
onOpenDetail(detail);
|
||||
} catch (err: any) {
|
||||
addToast("Failed to load task details", "error");
|
||||
}
|
||||
},
|
||||
[onOpenDetail, addToast]
|
||||
[onOpenDetail, addToast, projectId]
|
||||
);
|
||||
|
||||
const handleDragStart = useCallback(
|
||||
|
||||
@@ -55,6 +55,7 @@ interface MissionManagerProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
addToast: (message: string, type?: ToastType) => void;
|
||||
projectId?: string;
|
||||
onSelectTask?: (taskId: string) => void;
|
||||
availableTasks?: Array<{ id: string; title?: string }>;
|
||||
}
|
||||
@@ -120,7 +121,7 @@ const EMPTY_MISSION_FORM: MissionFormData = {
|
||||
title: "",
|
||||
description: "",
|
||||
status: "planning",
|
||||
autoAdvance: true,
|
||||
autoAdvance: false,
|
||||
};
|
||||
|
||||
const EMPTY_MILESTONE_FORM: MilestoneFormData = {
|
||||
@@ -143,7 +144,7 @@ const EMPTY_FEATURE_FORM: FeatureFormData = {
|
||||
status: "defined",
|
||||
};
|
||||
|
||||
export function MissionManager({ isOpen, onClose, addToast, onSelectTask, availableTasks = [] }: MissionManagerProps) {
|
||||
export function MissionManager({ isOpen, onClose, addToast, projectId, onSelectTask, availableTasks = [] }: MissionManagerProps) {
|
||||
const [missions, setMissions] = useState<Mission[]>([]);
|
||||
const [selectedMission, setSelectedMission] = useState<MissionWithHierarchy | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -183,19 +184,19 @@ export function MissionManager({ isOpen, onClose, addToast, onSelectTask, availa
|
||||
const loadMissions = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const data = await fetchMissions();
|
||||
const data = await fetchMissions(projectId);
|
||||
setMissions(data);
|
||||
} catch (err: any) {
|
||||
addToast(err.message || "Failed to load missions", "error");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [addToast]);
|
||||
}, [addToast, projectId]);
|
||||
|
||||
const loadMissionDetail = useCallback(async (missionId: string) => {
|
||||
try {
|
||||
setDetailLoading(true);
|
||||
const data = await fetchMission(missionId);
|
||||
const data = await fetchMission(missionId, projectId);
|
||||
setSelectedMission(data);
|
||||
// Auto-expand first milestone and slice
|
||||
if (data.milestones.length > 0) {
|
||||
@@ -209,7 +210,7 @@ export function MissionManager({ isOpen, onClose, addToast, onSelectTask, availa
|
||||
} finally {
|
||||
setDetailLoading(false);
|
||||
}
|
||||
}, [addToast]);
|
||||
}, [addToast, projectId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
@@ -232,7 +233,7 @@ export function MissionManager({ isOpen, onClose, addToast, onSelectTask, availa
|
||||
title: mission.title,
|
||||
description: mission.description || "",
|
||||
status: mission.status,
|
||||
autoAdvance: mission.autoAdvance ?? true,
|
||||
autoAdvance: mission.autoAdvance ?? false,
|
||||
});
|
||||
}, []);
|
||||
|
||||
@@ -254,7 +255,7 @@ export function MissionManager({ isOpen, onClose, addToast, onSelectTask, availa
|
||||
await createMission({
|
||||
title: missionForm.title.trim(),
|
||||
description: missionForm.description.trim() || undefined,
|
||||
});
|
||||
}, projectId);
|
||||
addToast("Mission created", "success");
|
||||
} else if (editingMissionId) {
|
||||
await updateMission(editingMissionId, {
|
||||
@@ -262,7 +263,7 @@ export function MissionManager({ isOpen, onClose, addToast, onSelectTask, availa
|
||||
description: missionForm.description.trim() || undefined,
|
||||
status: missionForm.status,
|
||||
autoAdvance: missionForm.autoAdvance,
|
||||
});
|
||||
}, projectId);
|
||||
addToast("Mission updated", "success");
|
||||
// Refresh detail view if viewing this mission
|
||||
if (selectedMission?.id === editingMissionId) {
|
||||
@@ -276,11 +277,11 @@ export function MissionManager({ isOpen, onClose, addToast, onSelectTask, availa
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [missionForm, isCreatingMission, editingMissionId, addToast, loadMissions, loadMissionDetail, selectedMission, handleCancelMission]);
|
||||
}, [missionForm, isCreatingMission, editingMissionId, addToast, loadMissions, loadMissionDetail, selectedMission, handleCancelMission, projectId]);
|
||||
|
||||
const handleDeleteMission = useCallback(async (missionId: string) => {
|
||||
try {
|
||||
await deleteMission(missionId);
|
||||
await deleteMission(missionId, projectId);
|
||||
addToast("Mission deleted", "success");
|
||||
if (selectedMission?.id === missionId) {
|
||||
setSelectedMission(null);
|
||||
@@ -290,7 +291,7 @@ export function MissionManager({ isOpen, onClose, addToast, onSelectTask, availa
|
||||
} catch (err: any) {
|
||||
addToast(err.message || "Failed to delete mission", "error");
|
||||
}
|
||||
}, [addToast, loadMissions, selectedMission]);
|
||||
}, [addToast, loadMissions, selectedMission, projectId]);
|
||||
|
||||
// Milestone handlers
|
||||
const handleCreateMilestone = useCallback(() => {
|
||||
@@ -329,7 +330,7 @@ export function MissionManager({ isOpen, onClose, addToast, onSelectTask, availa
|
||||
title: milestoneForm.title.trim(),
|
||||
description: milestoneForm.description.trim() || undefined,
|
||||
dependencies: milestoneForm.dependencies,
|
||||
});
|
||||
}, projectId);
|
||||
addToast("Milestone created", "success");
|
||||
} else if (editingMilestoneId) {
|
||||
await updateMilestone(editingMilestoneId, {
|
||||
@@ -337,7 +338,7 @@ export function MissionManager({ isOpen, onClose, addToast, onSelectTask, availa
|
||||
description: milestoneForm.description.trim() || undefined,
|
||||
status: milestoneForm.status,
|
||||
dependencies: milestoneForm.dependencies,
|
||||
});
|
||||
}, projectId);
|
||||
addToast("Milestone updated", "success");
|
||||
}
|
||||
await loadMissionDetail(selectedMission!.id);
|
||||
@@ -347,18 +348,18 @@ export function MissionManager({ isOpen, onClose, addToast, onSelectTask, availa
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [milestoneForm, isCreatingMilestone, editingMilestoneId, selectedMission, addToast, loadMissionDetail, handleCancelMilestone, missionForm.title]);
|
||||
}, [milestoneForm, isCreatingMilestone, editingMilestoneId, selectedMission, addToast, loadMissionDetail, handleCancelMilestone, missionForm.title, projectId]);
|
||||
|
||||
const handleDeleteMilestone = useCallback(async (milestoneId: string) => {
|
||||
try {
|
||||
await deleteMilestone(milestoneId);
|
||||
await deleteMilestone(milestoneId, projectId);
|
||||
addToast("Milestone deleted", "success");
|
||||
await loadMissionDetail(selectedMission!.id);
|
||||
setDeleteConfirmId(null);
|
||||
} catch (err: any) {
|
||||
addToast(err.message || "Failed to delete milestone", "error");
|
||||
}
|
||||
}, [addToast, loadMissionDetail, selectedMission]);
|
||||
}, [addToast, loadMissionDetail, selectedMission, projectId]);
|
||||
|
||||
const toggleMilestoneExpanded = useCallback((milestoneId: string) => {
|
||||
setExpandedMilestones((prev) => {
|
||||
@@ -409,14 +410,14 @@ export function MissionManager({ isOpen, onClose, addToast, onSelectTask, availa
|
||||
await createSlice(selectedMilestoneIdForNewSlice, {
|
||||
title: sliceForm.title.trim(),
|
||||
description: sliceForm.description.trim() || undefined,
|
||||
});
|
||||
}, projectId);
|
||||
addToast("Slice created", "success");
|
||||
} else if (editingSliceId) {
|
||||
await updateSlice(editingSliceId, {
|
||||
title: sliceForm.title.trim(),
|
||||
description: sliceForm.description.trim() || undefined,
|
||||
status: sliceForm.status,
|
||||
});
|
||||
}, projectId);
|
||||
addToast("Slice updated", "success");
|
||||
}
|
||||
await loadMissionDetail(selectedMission!.id);
|
||||
@@ -426,28 +427,28 @@ export function MissionManager({ isOpen, onClose, addToast, onSelectTask, availa
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [sliceForm, isCreatingSlice, editingSliceId, selectedMilestoneIdForNewSlice, selectedMission, addToast, loadMissionDetail, handleCancelSlice]);
|
||||
}, [sliceForm, isCreatingSlice, editingSliceId, selectedMilestoneIdForNewSlice, selectedMission, addToast, loadMissionDetail, handleCancelSlice, projectId]);
|
||||
|
||||
const handleDeleteSlice = useCallback(async (sliceId: string) => {
|
||||
try {
|
||||
await deleteSlice(sliceId);
|
||||
await deleteSlice(sliceId, projectId);
|
||||
addToast("Slice deleted", "success");
|
||||
await loadMissionDetail(selectedMission!.id);
|
||||
setDeleteConfirmId(null);
|
||||
} catch (err: any) {
|
||||
addToast(err.message || "Failed to delete slice", "error");
|
||||
}
|
||||
}, [addToast, loadMissionDetail, selectedMission]);
|
||||
}, [addToast, loadMissionDetail, selectedMission, projectId]);
|
||||
|
||||
const handleActivateSlice = useCallback(async (sliceId: string) => {
|
||||
try {
|
||||
await activateSlice(sliceId);
|
||||
await activateSlice(sliceId, projectId);
|
||||
addToast("Slice activated", "success");
|
||||
await loadMissionDetail(selectedMission!.id);
|
||||
} catch (err: any) {
|
||||
addToast(err.message || "Failed to activate slice", "error");
|
||||
}
|
||||
}, [addToast, loadMissionDetail, selectedMission]);
|
||||
}, [addToast, loadMissionDetail, selectedMission, projectId]);
|
||||
|
||||
const toggleSliceExpanded = useCallback((sliceId: string) => {
|
||||
setExpandedSlices((prev) => {
|
||||
@@ -500,7 +501,7 @@ export function MissionManager({ isOpen, onClose, addToast, onSelectTask, availa
|
||||
title: featureForm.title.trim(),
|
||||
description: featureForm.description.trim() || undefined,
|
||||
acceptanceCriteria: featureForm.acceptanceCriteria.trim() || undefined,
|
||||
});
|
||||
}, projectId);
|
||||
addToast("Feature created", "success");
|
||||
} else if (editingFeatureId) {
|
||||
await updateFeature(editingFeatureId, {
|
||||
@@ -508,7 +509,7 @@ export function MissionManager({ isOpen, onClose, addToast, onSelectTask, availa
|
||||
description: featureForm.description.trim() || undefined,
|
||||
acceptanceCriteria: featureForm.acceptanceCriteria.trim() || undefined,
|
||||
status: featureForm.status,
|
||||
});
|
||||
}, projectId);
|
||||
addToast("Feature updated", "success");
|
||||
}
|
||||
await loadMissionDetail(selectedMission!.id);
|
||||
@@ -518,18 +519,18 @@ export function MissionManager({ isOpen, onClose, addToast, onSelectTask, availa
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [featureForm, isCreatingFeature, editingFeatureId, selectedSliceIdForNewFeature, selectedMission, addToast, loadMissionDetail, handleCancelFeature]);
|
||||
}, [featureForm, isCreatingFeature, editingFeatureId, selectedSliceIdForNewFeature, selectedMission, addToast, loadMissionDetail, handleCancelFeature, projectId]);
|
||||
|
||||
const handleDeleteFeature = useCallback(async (featureId: string) => {
|
||||
try {
|
||||
await deleteFeature(featureId);
|
||||
await deleteFeature(featureId, projectId);
|
||||
addToast("Feature deleted", "success");
|
||||
await loadMissionDetail(selectedMission!.id);
|
||||
setDeleteConfirmId(null);
|
||||
} catch (err: any) {
|
||||
addToast(err.message || "Failed to delete feature", "error");
|
||||
}
|
||||
}, [addToast, loadMissionDetail, selectedMission]);
|
||||
}, [addToast, loadMissionDetail, selectedMission, projectId]);
|
||||
|
||||
const handleLinkTask = useCallback(async () => {
|
||||
if (!linkTaskFeatureId || !selectedTaskId.trim()) {
|
||||
@@ -538,7 +539,7 @@ export function MissionManager({ isOpen, onClose, addToast, onSelectTask, availa
|
||||
}
|
||||
|
||||
try {
|
||||
await linkFeatureToTask(linkTaskFeatureId, selectedTaskId.trim());
|
||||
await linkFeatureToTask(linkTaskFeatureId, selectedTaskId.trim(), projectId);
|
||||
addToast("Feature linked to task", "success");
|
||||
await loadMissionDetail(selectedMission!.id);
|
||||
setLinkTaskFeatureId(null);
|
||||
@@ -546,17 +547,17 @@ export function MissionManager({ isOpen, onClose, addToast, onSelectTask, availa
|
||||
} catch (err: any) {
|
||||
addToast(err.message || "Failed to link feature to task", "error");
|
||||
}
|
||||
}, [linkTaskFeatureId, selectedTaskId, addToast, loadMissionDetail, selectedMission]);
|
||||
}, [linkTaskFeatureId, selectedTaskId, addToast, loadMissionDetail, selectedMission, projectId]);
|
||||
|
||||
const handleUnlinkTask = useCallback(async (featureId: string) => {
|
||||
try {
|
||||
await unlinkFeatureFromTask(featureId);
|
||||
await unlinkFeatureFromTask(featureId, projectId);
|
||||
addToast("Feature unlinked from task", "success");
|
||||
await loadMissionDetail(selectedMission!.id);
|
||||
} catch (err: any) {
|
||||
addToast(err.message || "Failed to unlink feature", "error");
|
||||
}
|
||||
}, [addToast, loadMissionDetail, selectedMission]);
|
||||
}, [addToast, loadMissionDetail, selectedMission, projectId]);
|
||||
|
||||
const handleSelectMission = useCallback((mission: Mission) => {
|
||||
loadMissionDetail(mission.id);
|
||||
|
||||
@@ -275,7 +275,6 @@ export function ModelSelectorTab({ task, addToast }: ModelSelectorTabProps) {
|
||||
.catch((err) => setModelsError(err.message))
|
||||
.finally(() => setModelsLoading(false));
|
||||
}}
|
||||
className="btn btn-sm"
|
||||
style={{ marginLeft: "8px" }}
|
||||
>
|
||||
Retry
|
||||
|
||||
@@ -17,6 +17,7 @@ interface PendingImage {
|
||||
interface NewTaskModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
projectId?: string;
|
||||
tasks: Task[]; // for dependency selection
|
||||
onCreateTask: (input: TaskCreateInput) => Promise<Task>;
|
||||
addToast: (message: string, type?: ToastType) => void;
|
||||
@@ -24,7 +25,7 @@ interface NewTaskModalProps {
|
||||
onSubtaskBreakdown?: (description: string) => void;
|
||||
}
|
||||
|
||||
export function NewTaskModal({ isOpen, onClose, tasks, onCreateTask, addToast, onPlanningMode, onSubtaskBreakdown }: NewTaskModalProps) {
|
||||
export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask, addToast, onPlanningMode, onSubtaskBreakdown }: NewTaskModalProps) {
|
||||
const [description, setDescription] = useState("");
|
||||
const [dependencies, setDependencies] = useState<string[]>([]);
|
||||
const [showDepDropdown, setShowDepDropdown] = useState(false);
|
||||
@@ -63,14 +64,14 @@ export function NewTaskModal({ isOpen, onClose, tasks, onCreateTask, addToast, o
|
||||
})
|
||||
.catch(() => {/* silently fail - models just won't be available */})
|
||||
.finally(() => setModelsLoading(false));
|
||||
fetchSettings()
|
||||
fetchSettings(projectId)
|
||||
.then((nextSettings) => setSettings(nextSettings))
|
||||
.catch(() => setSettings(null));
|
||||
fetchWorkflowSteps()
|
||||
fetchWorkflowSteps(projectId)
|
||||
.then((steps) => setWorkflowSteps(steps.filter((s) => s.enabled)))
|
||||
.catch(() => setWorkflowSteps([]));
|
||||
}
|
||||
}, [isOpen]);
|
||||
}, [isOpen, projectId]);
|
||||
|
||||
// Track dirty state
|
||||
useEffect(() => {
|
||||
@@ -237,7 +238,7 @@ export function NewTaskModal({ isOpen, onClose, tasks, onCreateTask, addToast, o
|
||||
const failures: string[] = [];
|
||||
for (const img of pendingImages) {
|
||||
try {
|
||||
await uploadAttachment(task.id, img.file);
|
||||
await uploadAttachment(task.id, img.file, projectId);
|
||||
} catch {
|
||||
failures.push(img.file.name);
|
||||
}
|
||||
|
||||
@@ -103,7 +103,7 @@ describe("PlanningModeModal", () => {
|
||||
mockStartPlanningStreaming.mockResolvedValue({ sessionId: "session-123" });
|
||||
|
||||
// Default: simulate receiving a question after a brief delay
|
||||
mockConnectPlanningStream.mockImplementation((sessionId: string, handlers: any) => {
|
||||
mockConnectPlanningStream.mockImplementation((_sessionId: string, _projectId: string | undefined, handlers: any) => {
|
||||
setTimeout(() => {
|
||||
handlers.onQuestion?.(mockQuestion);
|
||||
}, 10);
|
||||
@@ -191,7 +191,7 @@ describe("PlanningModeModal", () => {
|
||||
|
||||
// Wait for startPlanningStreaming to be called (allow time for setTimeout in useEffect)
|
||||
await waitFor(() => {
|
||||
expect(mockStartPlanningStreaming).toHaveBeenCalledWith("Build a login system from new task dialog");
|
||||
expect(mockStartPlanningStreaming).toHaveBeenCalledWith("Build a login system from new task dialog", undefined);
|
||||
}, { timeout: 2000 });
|
||||
|
||||
// Should transition to question view
|
||||
@@ -213,7 +213,7 @@ describe("PlanningModeModal", () => {
|
||||
|
||||
// The auto-start should happen with the initial plan (allow time for setTimeout in useEffect)
|
||||
await waitFor(() => {
|
||||
expect(mockStartPlanningStreaming).toHaveBeenCalledWith("Pre-filled plan from new task");
|
||||
expect(mockStartPlanningStreaming).toHaveBeenCalledWith("Pre-filled plan from new task", undefined);
|
||||
}, { timeout: 2000 });
|
||||
});
|
||||
});
|
||||
@@ -236,7 +236,7 @@ describe("PlanningModeModal", () => {
|
||||
|
||||
// Wait for streaming to be called
|
||||
await waitFor(() => {
|
||||
expect(mockStartPlanningStreaming).toHaveBeenCalledWith("Build auth system");
|
||||
expect(mockStartPlanningStreaming).toHaveBeenCalledWith("Build auth system", undefined);
|
||||
});
|
||||
|
||||
// Should transition to question view via streaming
|
||||
@@ -247,7 +247,7 @@ describe("PlanningModeModal", () => {
|
||||
|
||||
it("shows error message when planning fails", async () => {
|
||||
// Override the default mock to simulate an error
|
||||
mockConnectPlanningStream.mockImplementationOnce((sessionId: string, handlers: any) => {
|
||||
mockConnectPlanningStream.mockImplementationOnce((_sessionId: string, _projectId: string | undefined, handlers: any) => {
|
||||
setTimeout(() => {
|
||||
handlers.onError?.("Rate limit exceeded");
|
||||
}, 10);
|
||||
@@ -315,7 +315,7 @@ describe("PlanningModeModal", () => {
|
||||
let streamConnectionCount = 0;
|
||||
let streamHandlers: any = null;
|
||||
|
||||
mockConnectPlanningStream.mockImplementation((sessionId: string, handlers: any) => {
|
||||
mockConnectPlanningStream.mockImplementation((_sessionId: string, _projectId: string | undefined, handlers: any) => {
|
||||
streamConnectionCount++;
|
||||
streamHandlers = handlers;
|
||||
|
||||
@@ -379,7 +379,7 @@ describe("PlanningModeModal", () => {
|
||||
describe("Summary view", () => {
|
||||
it("shows summary when planning is complete", async () => {
|
||||
// Override mock to return summary instead of question
|
||||
mockConnectPlanningStream.mockImplementationOnce((sessionId: string, handlers: any) => {
|
||||
mockConnectPlanningStream.mockImplementationOnce((_sessionId: string, _projectId: string | undefined, handlers: any) => {
|
||||
setTimeout(() => {
|
||||
handlers.onSummary?.(mockSummary);
|
||||
}, 10);
|
||||
@@ -427,7 +427,7 @@ describe("PlanningModeModal", () => {
|
||||
};
|
||||
|
||||
// Override mock to return summary
|
||||
mockConnectPlanningStream.mockImplementationOnce((sessionId: string, handlers: any) => {
|
||||
mockConnectPlanningStream.mockImplementationOnce((_sessionId: string, _projectId: string | undefined, handlers: any) => {
|
||||
setTimeout(() => {
|
||||
handlers.onSummary?.(mockSummary);
|
||||
}, 10);
|
||||
@@ -460,7 +460,7 @@ describe("PlanningModeModal", () => {
|
||||
fireEvent.click(screen.getByText("Create Task"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockCreateTaskFromPlanning).toHaveBeenCalledWith("session-123");
|
||||
expect(mockCreateTaskFromPlanning).toHaveBeenCalledWith("session-123", undefined);
|
||||
expect(mockOnTaskCreated).toHaveBeenCalledWith(createdTask);
|
||||
});
|
||||
});
|
||||
@@ -495,7 +495,7 @@ describe("PlanningModeModal", () => {
|
||||
describe("Loading state", () => {
|
||||
it("shows 'Generating next question...' text when loading without streaming content", async () => {
|
||||
// Mock to delay the question response so we stay in loading state
|
||||
mockConnectPlanningStream.mockImplementationOnce((sessionId: string, handlers: any) => {
|
||||
mockConnectPlanningStream.mockImplementationOnce((_sessionId: string, _projectId: string | undefined, handlers: any) => {
|
||||
// Don't call any handlers - stay in loading state
|
||||
return {
|
||||
close: vi.fn(),
|
||||
@@ -528,7 +528,7 @@ describe("PlanningModeModal", () => {
|
||||
|
||||
it("shows thinking container even when streaming output is initially empty", async () => {
|
||||
// Mock to delay the question response so we stay in loading state
|
||||
mockConnectPlanningStream.mockImplementationOnce((sessionId: string, handlers: any) => {
|
||||
mockConnectPlanningStream.mockImplementationOnce((_sessionId: string, _projectId: string | undefined, handlers: any) => {
|
||||
// Don't call any handlers - stay in loading state
|
||||
return {
|
||||
close: vi.fn(),
|
||||
@@ -563,7 +563,7 @@ describe("PlanningModeModal", () => {
|
||||
it("shows 'AI is thinking...' text and renders streaming content when it arrives", async () => {
|
||||
let streamHandlers: any = null;
|
||||
|
||||
mockConnectPlanningStream.mockImplementationOnce((sessionId: string, handlers: any) => {
|
||||
mockConnectPlanningStream.mockImplementationOnce((_sessionId: string, _projectId: string | undefined, handlers: any) => {
|
||||
streamHandlers = handlers;
|
||||
return {
|
||||
close: vi.fn(),
|
||||
@@ -624,7 +624,7 @@ describe("PlanningModeModal", () => {
|
||||
|
||||
let streamHandlers: any = null;
|
||||
|
||||
mockConnectPlanningStream.mockImplementation((sessionId: string, handlers: any) => {
|
||||
mockConnectPlanningStream.mockImplementation((_sessionId: string, _projectId: string | undefined, handlers: any) => {
|
||||
streamHandlers = handlers;
|
||||
|
||||
setTimeout(() => {
|
||||
@@ -815,7 +815,7 @@ describe("PlanningModeModal", () => {
|
||||
|
||||
it("shows confirmation in summary view", async () => {
|
||||
// Override mock to return summary
|
||||
mockConnectPlanningStream.mockImplementationOnce((sessionId: string, handlers: any) => {
|
||||
mockConnectPlanningStream.mockImplementationOnce((_sessionId: string, _projectId: string | undefined, handlers: any) => {
|
||||
setTimeout(() => {
|
||||
handlers.onSummary?.(mockSummary);
|
||||
}, 10);
|
||||
|
||||
@@ -17,6 +17,7 @@ interface PlanningModeModalProps {
|
||||
onTaskCreated: (task: Task) => void;
|
||||
tasks: Task[];
|
||||
initialPlan?: string;
|
||||
projectId?: string;
|
||||
}
|
||||
|
||||
interface QuestionResponse {
|
||||
@@ -36,7 +37,7 @@ const EXAMPLE_PLANS = [
|
||||
"Refactor the task card component for better performance",
|
||||
];
|
||||
|
||||
export function PlanningModeModal({ isOpen, onClose, onTaskCreated, tasks, initialPlan: initialPlanProp }: PlanningModeModalProps) {
|
||||
export function PlanningModeModal({ isOpen, onClose, onTaskCreated, tasks, initialPlan: initialPlanProp, projectId }: PlanningModeModalProps) {
|
||||
const [initialPlan, setInitialPlan] = useState("");
|
||||
const [view, setView] = useState<ViewState>({ type: "initial" });
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -64,11 +65,11 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, tasks, initi
|
||||
|
||||
try {
|
||||
// Use streaming mode for real-time AI thinking display
|
||||
const { sessionId } = await startPlanningStreaming(plan.trim());
|
||||
const { sessionId } = await startPlanningStreaming(plan.trim(), projectId);
|
||||
currentSessionIdRef.current = sessionId;
|
||||
|
||||
// Connect to SSE stream
|
||||
const connection = connectPlanningStream(sessionId, {
|
||||
const connection = connectPlanningStream(sessionId, projectId, {
|
||||
onThinking: (data) => {
|
||||
setStreamingOutput((prev) => prev + data);
|
||||
},
|
||||
@@ -108,7 +109,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, tasks, initi
|
||||
setView({ type: "initial" });
|
||||
currentSessionIdRef.current = null;
|
||||
}
|
||||
}, [initialPlan]);
|
||||
}, [initialPlan, projectId]);
|
||||
|
||||
// Focus textarea when opening
|
||||
useEffect(() => {
|
||||
@@ -177,7 +178,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, tasks, initi
|
||||
|
||||
if (view.type === "question" || view.type === "summary") {
|
||||
try {
|
||||
await cancelPlanning(view.session.sessionId);
|
||||
await cancelPlanning(view.session.sessionId, projectId);
|
||||
} catch {
|
||||
// Ignore errors on cancel
|
||||
}
|
||||
@@ -232,7 +233,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, tasks, initi
|
||||
|
||||
try {
|
||||
// Submit response - AI will broadcast events via the already-connected stream
|
||||
await respondToPlanning(sessionId, responses);
|
||||
await respondToPlanning(sessionId, responses, projectId);
|
||||
setResponseHistory((prev) => [...prev, responses]);
|
||||
setHasProgress(true);
|
||||
// Events (question/summary) will arrive via the existing SSE stream
|
||||
@@ -251,7 +252,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, tasks, initi
|
||||
setView({ type: "loading" });
|
||||
|
||||
try {
|
||||
const task = await createTaskFromPlanning(view.session.sessionId);
|
||||
const task = await createTaskFromPlanning(view.session.sessionId, projectId);
|
||||
onTaskCreated(task);
|
||||
handleCancel();
|
||||
} catch (err: any) {
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { ToastType } from "../hooks/useToast";
|
||||
|
||||
interface PrSectionProps {
|
||||
taskId: string;
|
||||
projectId?: string;
|
||||
prInfo?: PrInfo;
|
||||
automationStatus?: string | null;
|
||||
hasGitHubToken: boolean;
|
||||
@@ -22,6 +23,7 @@ const STATUS_COLORS = {
|
||||
|
||||
export function PrSection({
|
||||
taskId,
|
||||
projectId,
|
||||
prInfo,
|
||||
automationStatus,
|
||||
hasGitHubToken,
|
||||
@@ -44,7 +46,7 @@ export function PrSection({
|
||||
const newPr = await createPr(taskId, {
|
||||
title: prTitle.trim(),
|
||||
body: prBody.trim() || undefined,
|
||||
});
|
||||
}, projectId);
|
||||
onPrCreated(newPr);
|
||||
setShowCreateForm(false);
|
||||
setPrTitle("");
|
||||
@@ -55,14 +57,14 @@ export function PrSection({
|
||||
} finally {
|
||||
setIsCreating(false);
|
||||
}
|
||||
}, [taskId, prTitle, prBody, onPrCreated, addToast]);
|
||||
}, [taskId, prTitle, prBody, projectId, onPrCreated, addToast]);
|
||||
|
||||
const handleRefresh = useCallback(async () => {
|
||||
if (!prInfo) return;
|
||||
|
||||
setIsRefreshing(true);
|
||||
try {
|
||||
const updated = await refreshPrStatus(taskId);
|
||||
const updated = await refreshPrStatus(taskId, projectId);
|
||||
setRefreshState(updated);
|
||||
onPrUpdated(updated.prInfo);
|
||||
addToast("PR status refreshed", "success");
|
||||
@@ -71,7 +73,7 @@ export function PrSection({
|
||||
} finally {
|
||||
setIsRefreshing(false);
|
||||
}
|
||||
}, [taskId, prInfo, onPrUpdated, addToast]);
|
||||
}, [taskId, prInfo, projectId, onPrUpdated, addToast]);
|
||||
|
||||
// No PR yet - show create button or automation state
|
||||
if (!prInfo) {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { fetchScripts } from "../api";
|
||||
export interface QuickScriptsDropdownProps {
|
||||
onOpenScripts: () => void;
|
||||
onRunScript: (name: string, command: string) => void;
|
||||
projectId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -23,6 +24,7 @@ export interface QuickScriptsDropdownProps {
|
||||
export function QuickScriptsDropdown({
|
||||
onOpenScripts,
|
||||
onRunScript,
|
||||
projectId,
|
||||
}: QuickScriptsDropdownProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [scripts, setScripts] = useState<Record<string, string>>({});
|
||||
@@ -49,7 +51,7 @@ export function QuickScriptsDropdown({
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
|
||||
fetchScripts()
|
||||
fetchScripts(projectId)
|
||||
.then((data) => {
|
||||
if (!cancelled) {
|
||||
setScripts(data);
|
||||
@@ -69,7 +71,7 @@ export function QuickScriptsDropdown({
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [isOpen]);
|
||||
}, [isOpen, projectId]);
|
||||
|
||||
// Close dropdown on outside click
|
||||
useEffect(() => {
|
||||
|
||||
@@ -14,6 +14,7 @@ interface ScriptsModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
addToast: (message: string, type?: ToastType) => void;
|
||||
projectId?: string;
|
||||
/** Callback when user wants to run a script - opens terminal modal */
|
||||
onRunScript?: (name: string, command: string) => void;
|
||||
}
|
||||
@@ -39,7 +40,7 @@ function truncateCommand(command: string, maxLength: number = 60): string {
|
||||
return command.slice(0, maxLength - 3) + "...";
|
||||
}
|
||||
|
||||
export function ScriptsModal({ isOpen, onClose, addToast, onRunScript }: ScriptsModalProps) {
|
||||
export function ScriptsModal({ isOpen, onClose, addToast, projectId, onRunScript }: ScriptsModalProps) {
|
||||
const [scripts, setScripts] = useState<Record<string, string>>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
@@ -52,14 +53,14 @@ export function ScriptsModal({ isOpen, onClose, addToast, onRunScript }: Scripts
|
||||
const loadScripts = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const data = await fetchScripts();
|
||||
const data = await fetchScripts(projectId);
|
||||
setScripts(data);
|
||||
} catch (err: any) {
|
||||
addToast(err.message || "Failed to load scripts", "error");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [addToast]);
|
||||
}, [addToast, projectId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
@@ -118,7 +119,7 @@ export function ScriptsModal({ isOpen, onClose, addToast, onRunScript }: Scripts
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
await addScript(trimmedName, trimmedCommand);
|
||||
await addScript(trimmedName, trimmedCommand, projectId);
|
||||
addToast(isEditing ? "Script updated" : "Script created", "success");
|
||||
setIsEditing(null);
|
||||
setIsCreating(false);
|
||||
@@ -134,11 +135,11 @@ export function ScriptsModal({ isOpen, onClose, addToast, onRunScript }: Scripts
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [form, isEditing, addToast, loadScripts]);
|
||||
}, [form, isEditing, addToast, loadScripts, projectId]);
|
||||
|
||||
const handleDelete = useCallback(async (name: string) => {
|
||||
try {
|
||||
await removeScript(name);
|
||||
await removeScript(name, projectId);
|
||||
addToast("Script deleted", "success");
|
||||
setDeleteConfirmName(null);
|
||||
if (isEditing === name) {
|
||||
@@ -149,7 +150,7 @@ export function ScriptsModal({ isOpen, onClose, addToast, onRunScript }: Scripts
|
||||
} catch (err: any) {
|
||||
addToast(err.message || "Failed to delete script", "error");
|
||||
}
|
||||
}, [isEditing, addToast, loadScripts]);
|
||||
}, [isEditing, addToast, loadScripts, projectId]);
|
||||
|
||||
const handleRun = useCallback((name: string, command: string) => {
|
||||
if (onRunScript) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { THINKING_LEVELS, GLOBAL_SETTINGS_KEYS, PROJECT_SETTINGS_KEYS } from "@fusion/core";
|
||||
import type { Settings, GlobalSettings, ThemeMode, ColorTheme, ModelPreset } from "@fusion/core";
|
||||
import type { Settings, GlobalSettings, ThemeMode, ColorTheme, ModelPreset, NtfyNotificationEvent } from "@fusion/core";
|
||||
import { fetchSettings, updateSettings, updateGlobalSettings, fetchAuthStatus, loginProvider, logoutProvider, fetchModels, testNtfyNotification, fetchBackups, createBackup, exportSettings, importSettings } from "../api";
|
||||
import type { AuthProvider, ModelInfo, BackupListResponse, SettingsExportData } from "../api";
|
||||
import type { ToastType } from "../hooks/useToast";
|
||||
@@ -56,6 +56,7 @@ export type SectionId = (typeof SETTINGS_SECTIONS)[number]["id"];
|
||||
interface SettingsModalProps {
|
||||
onClose: () => void;
|
||||
addToast: (message: string, type?: ToastType) => void;
|
||||
projectId?: string;
|
||||
/** Optional section to show when the modal first opens. Defaults to "general". */
|
||||
initialSection?: SectionId;
|
||||
/** Current theme mode */
|
||||
@@ -71,6 +72,7 @@ interface SettingsModalProps {
|
||||
export function SettingsModal({
|
||||
onClose,
|
||||
addToast,
|
||||
projectId,
|
||||
initialSection,
|
||||
themeMode = "dark",
|
||||
colorTheme = "default",
|
||||
@@ -115,7 +117,7 @@ export function SettingsModal({
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchSettings()
|
||||
fetchSettings(projectId)
|
||||
.then((s) => {
|
||||
setForm(s);
|
||||
setLoading(false);
|
||||
@@ -124,7 +126,7 @@ export function SettingsModal({
|
||||
addToast(err.message, "error");
|
||||
setLoading(false);
|
||||
});
|
||||
}, [addToast]);
|
||||
}, [addToast, projectId]);
|
||||
|
||||
// Load auth status when the authentication section is active
|
||||
const loadAuthStatus = useCallback(async () => {
|
||||
@@ -149,12 +151,12 @@ export function SettingsModal({
|
||||
useEffect(() => {
|
||||
if (activeSection === "backups") {
|
||||
setBackupLoading(true);
|
||||
fetchBackups()
|
||||
fetchBackups(projectId)
|
||||
.then((info) => setBackupInfo(info))
|
||||
.catch(() => setBackupInfo(null))
|
||||
.finally(() => setBackupLoading(false));
|
||||
}
|
||||
}, [activeSection]);
|
||||
}, [activeSection, projectId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeSection === "authentication") {
|
||||
@@ -224,7 +226,7 @@ export function SettingsModal({
|
||||
const result = await testNtfyNotification({
|
||||
ntfyEnabled: form.ntfyEnabled,
|
||||
ntfyTopic: form.ntfyTopic,
|
||||
});
|
||||
}, projectId);
|
||||
if (result.success) {
|
||||
addToast("Test notification sent — check your ntfy app!", "success");
|
||||
} else {
|
||||
@@ -235,16 +237,16 @@ export function SettingsModal({
|
||||
} finally {
|
||||
setTestNotificationLoading(false);
|
||||
}
|
||||
}, [addToast, form.ntfyEnabled, form.ntfyTopic]);
|
||||
}, [addToast, form.ntfyEnabled, form.ntfyTopic, projectId]);
|
||||
|
||||
const handleBackupNow = useCallback(async () => {
|
||||
setBackupLoading(true);
|
||||
try {
|
||||
const result = await createBackup();
|
||||
const result = await createBackup(projectId);
|
||||
if (result.success) {
|
||||
addToast("Backup created successfully", "success");
|
||||
// Refresh backup list
|
||||
const info = await fetchBackups();
|
||||
const info = await fetchBackups(projectId);
|
||||
setBackupInfo(info);
|
||||
} else {
|
||||
addToast(result.error || "Failed to create backup", "error");
|
||||
@@ -254,7 +256,7 @@ export function SettingsModal({
|
||||
} finally {
|
||||
setBackupLoading(false);
|
||||
}
|
||||
}, [addToast]);
|
||||
}, [addToast, projectId]);
|
||||
|
||||
// Export/Import handlers
|
||||
const handleExport = useCallback(async () => {
|
||||
@@ -262,7 +264,7 @@ export function SettingsModal({
|
||||
// Default scope based on active section
|
||||
const scope = activeSectionScope === "global" ? "global" :
|
||||
activeSectionScope === "project" ? "project" : "both";
|
||||
const data = await exportSettings(scope);
|
||||
const data = await exportSettings(scope, projectId);
|
||||
|
||||
// Create and download the JSON file
|
||||
const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" });
|
||||
@@ -281,7 +283,7 @@ export function SettingsModal({
|
||||
} catch (err: any) {
|
||||
addToast(err.message || "Failed to export settings", "error");
|
||||
}
|
||||
}, [addToast, activeSectionScope]);
|
||||
}, [addToast, activeSectionScope, projectId]);
|
||||
|
||||
const handleFileSelect = useCallback(async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
@@ -308,9 +310,9 @@ export function SettingsModal({
|
||||
|
||||
setImportLoading(true);
|
||||
try {
|
||||
const result = await importSettings(importPreview, { scope: importScope, merge: importMerge });
|
||||
const result = await importSettings(importPreview, { scope: importScope, merge: importMerge }, projectId);
|
||||
if (result.success) {
|
||||
const parts = [];
|
||||
const parts: string[] = [];
|
||||
if (result.globalCount > 0) parts.push(`${result.globalCount} global`);
|
||||
if (result.projectCount > 0) parts.push(`${result.projectCount} project`);
|
||||
addToast(`Imported ${parts.join(", ")} setting(s)`, "success");
|
||||
@@ -318,7 +320,7 @@ export function SettingsModal({
|
||||
setImportPreview(null);
|
||||
setImportFile(null);
|
||||
// Refresh settings to show imported values
|
||||
const refreshed = await fetchSettings();
|
||||
const refreshed = await fetchSettings(projectId);
|
||||
setForm(refreshed);
|
||||
} else {
|
||||
addToast(result.error || "Import failed", "error");
|
||||
@@ -328,7 +330,7 @@ export function SettingsModal({
|
||||
} finally {
|
||||
setImportLoading(false);
|
||||
}
|
||||
}, [addToast, importPreview, importScope, importMerge]);
|
||||
}, [addToast, importPreview, importScope, importMerge, projectId]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKey = (e: KeyboardEvent) => {
|
||||
@@ -379,7 +381,7 @@ export function SettingsModal({
|
||||
// Save both scopes in parallel if they have changes
|
||||
await Promise.all([
|
||||
Object.keys(globalPatch).length > 0 ? updateGlobalSettings(globalPatch) : Promise.resolve(),
|
||||
Object.keys(projectPatch).length > 0 ? updateSettings(projectPatch) : Promise.resolve(),
|
||||
Object.keys(projectPatch).length > 0 ? updateSettings(projectPatch, projectId) : Promise.resolve(),
|
||||
]);
|
||||
|
||||
addToast("Settings saved", "success");
|
||||
@@ -387,7 +389,7 @@ export function SettingsModal({
|
||||
} catch (err: any) {
|
||||
addToast(err.message, "error");
|
||||
}
|
||||
}, [form, prefixError, presetDraft, onClose, addToast]);
|
||||
}, [form, prefixError, presetDraft, onClose, addToast, projectId]);
|
||||
|
||||
const savePresetDraft = () => {
|
||||
if (!presetDraft) return;
|
||||
@@ -1408,10 +1410,10 @@ export function SettingsModal({
|
||||
type="checkbox"
|
||||
checked={form.ntfyEvents?.includes("in-review") ?? true}
|
||||
onChange={(e) => {
|
||||
const current = form.ntfyEvents ?? ["in-review", "merged", "failed"];
|
||||
const current = form.ntfyEvents ?? (["in-review", "merged", "failed"] as NtfyNotificationEvent[]);
|
||||
const newEvents = e.target.checked
|
||||
? [...new Set([...current, "in-review"])]
|
||||
: current.filter((ev) => ev !== "in-review");
|
||||
? (current.includes("in-review") ? current : [...current, "in-review" as NtfyNotificationEvent])
|
||||
: current.filter((ev): ev is NtfyNotificationEvent => ev !== "in-review");
|
||||
setForm((f) => ({ ...f, ntfyEvents: newEvents.length > 0 ? newEvents : undefined }));
|
||||
}}
|
||||
/>
|
||||
@@ -1424,10 +1426,10 @@ export function SettingsModal({
|
||||
type="checkbox"
|
||||
checked={form.ntfyEvents?.includes("merged") ?? true}
|
||||
onChange={(e) => {
|
||||
const current = form.ntfyEvents ?? ["in-review", "merged", "failed"];
|
||||
const current = form.ntfyEvents ?? (["in-review", "merged", "failed"] as NtfyNotificationEvent[]);
|
||||
const newEvents = e.target.checked
|
||||
? [...new Set([...current, "merged"])]
|
||||
: current.filter((ev) => ev !== "merged");
|
||||
? (current.includes("merged") ? current : [...current, "merged" as NtfyNotificationEvent])
|
||||
: current.filter((ev): ev is NtfyNotificationEvent => ev !== "merged");
|
||||
setForm((f) => ({ ...f, ntfyEvents: newEvents.length > 0 ? newEvents : undefined }));
|
||||
}}
|
||||
/>
|
||||
@@ -1440,10 +1442,10 @@ export function SettingsModal({
|
||||
type="checkbox"
|
||||
checked={form.ntfyEvents?.includes("failed") ?? true}
|
||||
onChange={(e) => {
|
||||
const current = form.ntfyEvents ?? ["in-review", "merged", "failed"];
|
||||
const current = form.ntfyEvents ?? (["in-review", "merged", "failed"] as NtfyNotificationEvent[]);
|
||||
const newEvents = e.target.checked
|
||||
? [...new Set([...current, "failed"])]
|
||||
: current.filter((ev) => ev !== "failed");
|
||||
? (current.includes("failed") ? current : [...current, "failed" as NtfyNotificationEvent])
|
||||
: current.filter((ev): ev is NtfyNotificationEvent => ev !== "failed");
|
||||
setForm((f) => ({ ...f, ntfyEvents: newEvents.length > 0 ? newEvents : undefined }));
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -34,7 +34,7 @@ describe("SubtaskBreakdownModal", () => {
|
||||
vi.clearAllMocks();
|
||||
streamHandlers = undefined;
|
||||
mockStartSubtaskBreakdown.mockResolvedValue({ sessionId: "session-123" });
|
||||
mockConnectSubtaskStream.mockImplementation((_sessionId, handlers) => {
|
||||
mockConnectSubtaskStream.mockImplementation((_sessionId, _projectId, handlers) => {
|
||||
streamHandlers = handlers;
|
||||
return { close: vi.fn(), isConnected: () => true };
|
||||
});
|
||||
@@ -60,7 +60,7 @@ describe("SubtaskBreakdownModal", () => {
|
||||
|
||||
it("shows generating state after auto-start", async () => {
|
||||
renderModal();
|
||||
await waitFor(() => expect(mockStartSubtaskBreakdown).toHaveBeenCalledWith("Build a complex feature"));
|
||||
await waitFor(() => expect(mockStartSubtaskBreakdown).toHaveBeenCalledWith("Build a complex feature", undefined));
|
||||
expect(await screen.findByText("AI is generating subtasks...")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ interface SubtaskBreakdownModalProps {
|
||||
initialDescription: string;
|
||||
onTasksCreated: (tasks: Task[]) => void;
|
||||
parentTaskId?: string;
|
||||
projectId?: string;
|
||||
}
|
||||
|
||||
type ViewState =
|
||||
@@ -53,7 +54,7 @@ function hasDependencyCycle(subtasks: SubtaskItem[]): boolean {
|
||||
return subtasks.some((item) => visit(item.id));
|
||||
}
|
||||
|
||||
export function SubtaskBreakdownModal({ isOpen, onClose, initialDescription, onTasksCreated, parentTaskId }: SubtaskBreakdownModalProps) {
|
||||
export function SubtaskBreakdownModal({ isOpen, onClose, initialDescription, onTasksCreated, parentTaskId, projectId }: SubtaskBreakdownModalProps) {
|
||||
const [view, setView] = useState<ViewState>({ type: "initial" });
|
||||
const [subtasks, setSubtasks] = useState<SubtaskItem[]>([]);
|
||||
const [thinkingOutput, setThinkingOutput] = useState("");
|
||||
@@ -98,14 +99,14 @@ export function SubtaskBreakdownModal({ isOpen, onClose, initialDescription, onT
|
||||
}
|
||||
if (sessionId) {
|
||||
try {
|
||||
await cancelSubtaskBreakdown(sessionId);
|
||||
await cancelSubtaskBreakdown(sessionId, projectId);
|
||||
} catch {
|
||||
// ignore cancel errors
|
||||
}
|
||||
}
|
||||
resetState();
|
||||
onClose();
|
||||
}, [dirty, onClose, resetState, sessionId, view.type]);
|
||||
}, [dirty, onClose, resetState, sessionId, view.type, projectId]);
|
||||
|
||||
const beginBreakdown = useCallback(async () => {
|
||||
if (!initialDescription.trim()) return;
|
||||
@@ -113,10 +114,10 @@ export function SubtaskBreakdownModal({ isOpen, onClose, initialDescription, onT
|
||||
setThinkingOutput("");
|
||||
|
||||
try {
|
||||
const { sessionId } = await startSubtaskBreakdown(initialDescription.trim());
|
||||
const { sessionId } = await startSubtaskBreakdown(initialDescription.trim(), projectId);
|
||||
setView({ type: "generating", sessionId });
|
||||
streamRef.current?.close();
|
||||
streamRef.current = connectSubtaskStream(sessionId, {
|
||||
streamRef.current = connectSubtaskStream(sessionId, projectId, {
|
||||
onThinking: (data) => setThinkingOutput((prev) => prev + data),
|
||||
onSubtasks: (items) => {
|
||||
setSubtasks(items);
|
||||
@@ -274,7 +275,7 @@ export function SubtaskBreakdownModal({ isOpen, onClose, initialDescription, onT
|
||||
setError(null);
|
||||
setView({ type: "creating", sessionId });
|
||||
try {
|
||||
const result = await createTasksFromBreakdown(sessionId, subtasks, parentTaskId);
|
||||
const result = await createTasksFromBreakdown(sessionId, subtasks, parentTaskId, projectId);
|
||||
onTasksCreated(result.tasks);
|
||||
resetState();
|
||||
onClose();
|
||||
|
||||
@@ -133,7 +133,7 @@ describe("TaskCard", () => {
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpload).toHaveBeenCalledWith("FN-001", file);
|
||||
expect(mockUpload).toHaveBeenCalledWith("FN-001", file, undefined);
|
||||
expect(addToast).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Attached test.png"),
|
||||
"success",
|
||||
|
||||
@@ -33,6 +33,7 @@ const ACTIVE_STATUSES = new Set(["planning", "researching", "executing", "finali
|
||||
|
||||
interface TaskCardProps {
|
||||
task: Task;
|
||||
projectId?: string;
|
||||
queued?: boolean;
|
||||
onOpenDetail: (task: TaskDetail) => void;
|
||||
addToast: (message: string, type?: ToastType) => void;
|
||||
@@ -79,6 +80,7 @@ function areTaskCardPropsEqual(previous: TaskCardProps, next: TaskCardProps): bo
|
||||
|
||||
return (
|
||||
previous.queued === next.queued &&
|
||||
previous.projectId === next.projectId &&
|
||||
previous.globalPaused === next.globalPaused &&
|
||||
previous.onOpenDetail === next.onOpenDetail &&
|
||||
previous.addToast === next.addToast &&
|
||||
@@ -119,6 +121,7 @@ function areTaskCardPropsEqual(previous: TaskCardProps, next: TaskCardProps): bo
|
||||
|
||||
function TaskCardComponent({
|
||||
task,
|
||||
projectId,
|
||||
queued,
|
||||
onOpenDetail,
|
||||
addToast,
|
||||
@@ -227,7 +230,7 @@ function TaskCardComponent({
|
||||
const files = Array.from(e.dataTransfer.files);
|
||||
for (const file of files) {
|
||||
try {
|
||||
await uploadAttachment(task.id, file);
|
||||
await uploadAttachment(task.id, file, projectId);
|
||||
addToast(`Attached ${file.name} to ${task.id}`, "success");
|
||||
} catch (err: any) {
|
||||
addToast(`Failed to attach ${file.name}: ${err.message}`, "error");
|
||||
@@ -238,7 +241,7 @@ function TaskCardComponent({
|
||||
const handleClick = useCallback(async () => {
|
||||
if (isEditing) return; // Don't open detail when editing
|
||||
try {
|
||||
const detail = await fetchTaskDetail(task.id);
|
||||
const detail = await fetchTaskDetail(task.id, projectId);
|
||||
onOpenDetail(detail);
|
||||
} catch {
|
||||
addToast("Failed to load task details", "error");
|
||||
@@ -301,7 +304,7 @@ function TaskCardComponent({
|
||||
const handleDepClick = useCallback(async (e: React.MouseEvent, depId: string) => {
|
||||
e.stopPropagation(); // Prevent card click
|
||||
try {
|
||||
const detail = await fetchTaskDetail(depId);
|
||||
const detail = await fetchTaskDetail(depId, projectId);
|
||||
onOpenDetail(detail);
|
||||
} catch (err: any) {
|
||||
addToast(`Failed to load dependency ${depId}`, "error");
|
||||
@@ -331,10 +334,10 @@ function TaskCardComponent({
|
||||
}, [hasGitHubBadge, isInViewport, subscribeToBadge, task.id, unsubscribeFromBadge]);
|
||||
|
||||
const liveBadgeData = badgeUpdates.get(task.id);
|
||||
const { files: sessionFiles, loading: sessionFilesLoading } = useSessionFiles(task.id, task.worktree, task.column);
|
||||
const { files: sessionFiles, loading: sessionFilesLoading } = useSessionFiles(task.id, task.worktree, task.column, projectId);
|
||||
|
||||
// Get fresh batch data if available
|
||||
const batchData = useMemo(() => getFreshBatchData(task.id), [task.id]);
|
||||
const batchData = useMemo(() => getFreshBatchData(task.id, projectId), [task.id, projectId]);
|
||||
|
||||
// Pick the freshest data among WebSocket, batch, and task data
|
||||
const livePrInfo = useMemo(() => {
|
||||
|
||||
@@ -6,6 +6,7 @@ import { highlightDiff } from "../utils/highlightDiff";
|
||||
interface TaskChangesTabProps {
|
||||
taskId: string;
|
||||
worktree?: string;
|
||||
projectId?: string;
|
||||
}
|
||||
|
||||
function getFileStatus(file: string, patch: string): "added" | "modified" | "deleted" | "unknown" {
|
||||
@@ -36,7 +37,7 @@ function formatFileSize(bytes: number): string {
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
export function TaskChangesTab({ taskId, worktree }: TaskChangesTabProps) {
|
||||
export function TaskChangesTab({ taskId, worktree, projectId }: TaskChangesTabProps) {
|
||||
const [diffData, setDiffData] = useState<TaskDiff | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -51,7 +52,7 @@ export function TaskChangesTab({ taskId, worktree }: TaskChangesTabProps) {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const data = await fetchTaskDiff(taskId);
|
||||
const data = await fetchTaskDiff(taskId, undefined, projectId);
|
||||
setDiffData(data);
|
||||
// Auto-expand first file if there are files
|
||||
if (data.files.length > 0) {
|
||||
@@ -62,7 +63,7 @@ export function TaskChangesTab({ taskId, worktree }: TaskChangesTabProps) {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [taskId, worktree]);
|
||||
}, [taskId, worktree, projectId]);
|
||||
|
||||
useEffect(() => {
|
||||
loadDiff();
|
||||
|
||||
@@ -10,6 +10,7 @@ interface TaskCommentsProps {
|
||||
onTaskUpdated?: (task: Task) => void;
|
||||
addToast: (message: string, type?: ToastType) => void;
|
||||
currentAuthor?: string;
|
||||
projectId?: string;
|
||||
}
|
||||
|
||||
type CommentType = "comment" | "guidance";
|
||||
@@ -24,7 +25,7 @@ function isAIGuidanceComment(author: string): boolean {
|
||||
return author === "agent" || author === "system";
|
||||
}
|
||||
|
||||
export function TaskComments({ task, onTaskUpdated, addToast, currentAuthor = "user" }: TaskCommentsProps) {
|
||||
export function TaskComments({ task, onTaskUpdated, addToast, currentAuthor = "user", projectId }: TaskCommentsProps) {
|
||||
const [draft, setDraft] = useState("");
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [editingText, setEditingText] = useState("");
|
||||
@@ -47,12 +48,12 @@ export function TaskComments({ task, onTaskUpdated, addToast, currentAuthor = "u
|
||||
setSubmitting(true);
|
||||
try {
|
||||
if (commentType === "guidance") {
|
||||
const updated = await addSteeringComment(task.id, text);
|
||||
const updated = await addSteeringComment(task.id, text, projectId);
|
||||
setDraft("");
|
||||
onTaskUpdated?.(updated);
|
||||
addToast("AI Guidance added", "success");
|
||||
} else {
|
||||
const updated = await addTaskComment(task.id, text, currentAuthor);
|
||||
const updated = await addTaskComment(task.id, text, currentAuthor, projectId);
|
||||
setDraft("");
|
||||
onTaskUpdated?.(updated);
|
||||
addToast("Comment added", "success");
|
||||
@@ -69,7 +70,7 @@ export function TaskComments({ task, onTaskUpdated, addToast, currentAuthor = "u
|
||||
if (!text) return;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const updated = await updateTaskComment(task.id, commentId, text);
|
||||
const updated = await updateTaskComment(task.id, commentId, text, projectId);
|
||||
setEditingId(null);
|
||||
setEditingText("");
|
||||
onTaskUpdated?.(updated);
|
||||
@@ -84,7 +85,7 @@ export function TaskComments({ task, onTaskUpdated, addToast, currentAuthor = "u
|
||||
async function handleDelete(commentId: string) {
|
||||
setDeletingId(commentId);
|
||||
try {
|
||||
const updated = await deleteTaskComment(task.id, commentId);
|
||||
const updated = await deleteTaskComment(task.id, commentId, projectId);
|
||||
onTaskUpdated?.(updated);
|
||||
addToast("Comment deleted", "success");
|
||||
} catch (error: any) {
|
||||
|
||||
@@ -74,6 +74,7 @@ function formatBytes(bytes: number): string {
|
||||
|
||||
interface TaskDetailModalProps {
|
||||
task: TaskDetail;
|
||||
projectId?: string;
|
||||
tasks?: Task[];
|
||||
onClose: () => void;
|
||||
onOpenDetail: (task: TaskDetail) => void; // For clicking dependencies
|
||||
@@ -94,6 +95,7 @@ const EDITABLE_COLUMNS: Set<Column> = new Set(["triage", "todo"]);
|
||||
|
||||
export function TaskDetailModal({
|
||||
task,
|
||||
projectId,
|
||||
tasks = [],
|
||||
onClose,
|
||||
onOpenDetail,
|
||||
@@ -184,7 +186,7 @@ export function TaskDetailModal({
|
||||
await updateTask(task.id, {
|
||||
title: editTitle.trim() || undefined,
|
||||
description: editDescription.trim() || undefined,
|
||||
});
|
||||
}, projectId);
|
||||
addToast(`Updated ${task.id}`, "success");
|
||||
setIsEditing(false);
|
||||
} catch (err: any) {
|
||||
@@ -229,6 +231,7 @@ export function TaskDetailModal({
|
||||
const { entries: agentLogEntries, loading: agentLogLoading } = useAgentLogs(
|
||||
task.id,
|
||||
activeTab === "agent-log",
|
||||
projectId,
|
||||
);
|
||||
useEffect(() => {
|
||||
const handleKey = (e: KeyboardEvent) => {
|
||||
@@ -311,10 +314,10 @@ export function TaskDetailModal({
|
||||
const handleTogglePause = useCallback(async () => {
|
||||
try {
|
||||
if (task.paused) {
|
||||
await unpauseTask(task.id);
|
||||
await unpauseTask(task.id, projectId);
|
||||
addToast(`Unpaused ${task.id}`, "success");
|
||||
} else {
|
||||
await pauseTask(task.id);
|
||||
await pauseTask(task.id, projectId);
|
||||
addToast(`Paused ${task.id}`, "success");
|
||||
}
|
||||
onClose();
|
||||
@@ -325,7 +328,7 @@ export function TaskDetailModal({
|
||||
|
||||
const handleApprovePlan = useCallback(async () => {
|
||||
try {
|
||||
await approvePlan(task.id);
|
||||
await approvePlan(task.id, projectId);
|
||||
addToast(`Plan approved — ${task.id} moved to Todo`, "success");
|
||||
onClose();
|
||||
} catch (err: any) {
|
||||
@@ -336,7 +339,7 @@ export function TaskDetailModal({
|
||||
const handleRejectPlan = useCallback(async () => {
|
||||
if (!confirm("Reject this plan? The specification will be discarded and regenerated.")) return;
|
||||
try {
|
||||
await rejectPlan(task.id);
|
||||
await rejectPlan(task.id, projectId);
|
||||
addToast(`Plan rejected — ${task.id} returned to Triage for re-specification`, "info");
|
||||
onClose();
|
||||
} catch (err: any) {
|
||||
@@ -366,7 +369,7 @@ export function TaskDetailModal({
|
||||
}
|
||||
setIsRefining(true);
|
||||
try {
|
||||
const newTask = await refineTask(task.id, refineFeedback.trim());
|
||||
const newTask = await refineTask(task.id, refineFeedback.trim(), projectId);
|
||||
addToast(`Refinement task created: ${newTask.id}`, "success");
|
||||
onClose();
|
||||
} catch (err: any) {
|
||||
@@ -379,7 +382,7 @@ export function TaskDetailModal({
|
||||
const uploadFile = useCallback(async (file: File) => {
|
||||
setUploading(true);
|
||||
try {
|
||||
const attachment = await uploadAttachment(task.id, file);
|
||||
const attachment = await uploadAttachment(task.id, file, projectId);
|
||||
setAttachments((prev) => [...prev, attachment]);
|
||||
addToast("Screenshot attached", "success");
|
||||
} catch (err: any) {
|
||||
@@ -434,7 +437,7 @@ export function TaskDetailModal({
|
||||
|
||||
const handleDeleteAttachment = useCallback(async (filename: string) => {
|
||||
try {
|
||||
await deleteAttachment(task.id, filename);
|
||||
await deleteAttachment(task.id, filename, projectId);
|
||||
setAttachments((prev) => prev.filter((a) => a.filename !== filename));
|
||||
addToast("Attachment deleted", "info");
|
||||
} catch (err: any) {
|
||||
@@ -446,7 +449,7 @@ export function TaskDetailModal({
|
||||
const newDeps = [...dependencies, depId];
|
||||
setDependencies(newDeps);
|
||||
try {
|
||||
await updateTask(task.id, { dependencies: newDeps });
|
||||
await updateTask(task.id, { dependencies: newDeps }, projectId);
|
||||
} catch (err: any) {
|
||||
setDependencies(dependencies);
|
||||
addToast(err.message, "error");
|
||||
@@ -458,7 +461,7 @@ export function TaskDetailModal({
|
||||
const newDeps = dependencies.filter((d) => d !== depId);
|
||||
setDependencies(newDeps);
|
||||
try {
|
||||
await updateTask(task.id, { dependencies: newDeps });
|
||||
await updateTask(task.id, { dependencies: newDeps }, projectId);
|
||||
} catch (err: any) {
|
||||
setDependencies(dependencies);
|
||||
addToast(err.message, "error");
|
||||
@@ -467,7 +470,7 @@ export function TaskDetailModal({
|
||||
|
||||
const handleDepClick = useCallback(async (depId: string) => {
|
||||
try {
|
||||
const detail = await fetchTaskDetail(depId);
|
||||
const detail = await fetchTaskDetail(depId, projectId);
|
||||
onOpenDetail(detail);
|
||||
} catch (err: any) {
|
||||
addToast(`Failed to load dependency ${depId}`, "error");
|
||||
@@ -478,7 +481,7 @@ export function TaskDetailModal({
|
||||
const handleSaveSpec = useCallback(async (newContent: string) => {
|
||||
setIsSavingSpec(true);
|
||||
try {
|
||||
await updateTask(task.id, { prompt: newContent });
|
||||
await updateTask(task.id, { prompt: newContent }, projectId);
|
||||
addToast("Spec updated", "success");
|
||||
// Update local task data
|
||||
task.prompt = newContent;
|
||||
@@ -493,7 +496,7 @@ export function TaskDetailModal({
|
||||
const handleRequestSpecRevision = useCallback(async (feedback: string) => {
|
||||
setIsRequestingRevision(true);
|
||||
try {
|
||||
await requestSpecRevision(task.id, feedback);
|
||||
await requestSpecRevision(task.id, feedback, projectId);
|
||||
addToast("AI revision requested. Task moved to triage.", "success");
|
||||
// Task has been moved to triage, close modal
|
||||
onClose();
|
||||
@@ -706,9 +709,9 @@ export function TaskDetailModal({
|
||||
/>
|
||||
</div>
|
||||
) : activeTab === "changes" ? (
|
||||
<TaskChangesTab taskId={task.id} worktree={task.worktree} />
|
||||
<TaskChangesTab taskId={task.id} worktree={task.worktree} projectId={projectId} />
|
||||
) : activeTab === "comments" ? (
|
||||
<TaskComments task={task} addToast={addToast} />
|
||||
<TaskComments task={task} addToast={addToast} projectId={projectId} />
|
||||
) : activeTab === "activity" ? (
|
||||
<div className="detail-section detail-activity">
|
||||
<h4>Activity</h4>
|
||||
@@ -984,6 +987,7 @@ export function TaskDetailModal({
|
||||
{task.column === "in-review" && (
|
||||
<PrSection
|
||||
taskId={task.id}
|
||||
projectId={projectId}
|
||||
prInfo={task.prInfo}
|
||||
automationStatus={task.status ?? null}
|
||||
hasGitHubToken={githubTokenConfigured ?? false}
|
||||
|
||||
@@ -37,7 +37,7 @@ export function TerminalModal({ isOpen, onClose, initialCommand }: TerminalModal
|
||||
const xtermRef = useRef<XTerm | null>(null);
|
||||
const fitAddonRef = useRef<ITerminalAddon | null>(null);
|
||||
const hasInitialCommandRun = useRef(false);
|
||||
const xtermInitializedRef = useRef(false);
|
||||
const xtermInitializedRef = useRef<string | false>(false);
|
||||
|
||||
// Use the session management hook
|
||||
const {
|
||||
|
||||
@@ -32,6 +32,7 @@ interface WorkflowStepManagerProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
addToast: (message: string, type?: ToastType) => void;
|
||||
projectId?: string;
|
||||
}
|
||||
|
||||
interface StepFormData {
|
||||
@@ -80,7 +81,7 @@ function getCategoryColors(category: string): { bg: string; text: string } {
|
||||
}
|
||||
}
|
||||
|
||||
export function WorkflowStepManager({ isOpen, onClose, addToast }: WorkflowStepManagerProps) {
|
||||
export function WorkflowStepManager({ isOpen, onClose, addToast, projectId }: WorkflowStepManagerProps) {
|
||||
const [steps, setSteps] = useState<WorkflowStep[]>([]);
|
||||
const [templates, setTemplates] = useState<WorkflowStepTemplate[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -97,14 +98,14 @@ export function WorkflowStepManager({ isOpen, onClose, addToast }: WorkflowStepM
|
||||
const loadSteps = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const data = await fetchWorkflowSteps();
|
||||
const data = await fetchWorkflowSteps(projectId);
|
||||
setSteps(data);
|
||||
} catch (err: any) {
|
||||
addToast(err.message || "Failed to load workflow steps", "error");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [addToast]);
|
||||
}, [addToast, projectId]);
|
||||
|
||||
const loadTemplates = useCallback(async () => {
|
||||
try {
|
||||
@@ -163,7 +164,7 @@ export function WorkflowStepManager({ isOpen, onClose, addToast }: WorkflowStepM
|
||||
prompt: form.prompt.trim() || undefined,
|
||||
enabled: form.enabled,
|
||||
};
|
||||
await createWorkflowStep(input);
|
||||
await createWorkflowStep(input, projectId);
|
||||
addToast("Workflow step created", "success");
|
||||
} else if (editingId) {
|
||||
await updateWorkflowStep(editingId, {
|
||||
@@ -171,7 +172,7 @@ export function WorkflowStepManager({ isOpen, onClose, addToast }: WorkflowStepM
|
||||
description: form.description.trim(),
|
||||
prompt: form.prompt,
|
||||
enabled: form.enabled,
|
||||
});
|
||||
}, projectId);
|
||||
addToast("Workflow step updated", "success");
|
||||
}
|
||||
|
||||
@@ -188,7 +189,7 @@ export function WorkflowStepManager({ isOpen, onClose, addToast }: WorkflowStepM
|
||||
|
||||
const handleDelete = useCallback(async (id: string) => {
|
||||
try {
|
||||
await deleteWorkflowStep(id);
|
||||
await deleteWorkflowStep(id, projectId);
|
||||
addToast("Workflow step deleted", "success");
|
||||
setDeleteConfirmId(null);
|
||||
if (editingId === id) {
|
||||
@@ -219,13 +220,13 @@ export function WorkflowStepManager({ isOpen, onClose, addToast }: WorkflowStepM
|
||||
prompt: form.prompt.trim() || undefined,
|
||||
enabled: form.enabled,
|
||||
};
|
||||
const created = await createWorkflowStep(input);
|
||||
const created = await createWorkflowStep(input, projectId);
|
||||
setIsCreating(false);
|
||||
setEditingId(created.id);
|
||||
|
||||
// Now refine
|
||||
setRefining(true);
|
||||
const result = await refineWorkflowStepPrompt(created.id);
|
||||
const result = await refineWorkflowStepPrompt(created.id, projectId);
|
||||
setForm((prev) => ({ ...prev, prompt: result.prompt }));
|
||||
addToast("Prompt refined with AI", "success");
|
||||
await loadSteps();
|
||||
@@ -242,7 +243,7 @@ export function WorkflowStepManager({ isOpen, onClose, addToast }: WorkflowStepM
|
||||
|
||||
setRefining(true);
|
||||
try {
|
||||
const result = await refineWorkflowStepPrompt(editingId);
|
||||
const result = await refineWorkflowStepPrompt(editingId, projectId);
|
||||
setForm((prev) => ({ ...prev, prompt: result.prompt }));
|
||||
addToast("Prompt refined with AI", "success");
|
||||
await loadSteps();
|
||||
@@ -256,7 +257,7 @@ export function WorkflowStepManager({ isOpen, onClose, addToast }: WorkflowStepM
|
||||
const handleAddTemplate = useCallback(async (template: WorkflowStepTemplate) => {
|
||||
setAddingTemplateId(template.id);
|
||||
try {
|
||||
await createWorkflowStepFromTemplate(template.id);
|
||||
await createWorkflowStepFromTemplate(template.id, projectId);
|
||||
addToast(`Added ${template.name} workflow step`, "success");
|
||||
await loadSteps();
|
||||
// Switch to "My Workflow Steps" tab to show the newly added step
|
||||
|
||||
@@ -8,6 +8,7 @@ interface WorktreeGroupProps {
|
||||
label: string;
|
||||
activeTasks: Task[];
|
||||
queuedTasks: Task[];
|
||||
projectId?: string;
|
||||
onOpenDetail: (task: TaskDetail) => void;
|
||||
addToast: (message: string, type?: ToastType) => void;
|
||||
globalPaused?: boolean;
|
||||
@@ -22,6 +23,7 @@ function WorktreeGroupComponent({
|
||||
label,
|
||||
activeTasks,
|
||||
queuedTasks,
|
||||
projectId,
|
||||
onOpenDetail,
|
||||
addToast,
|
||||
globalPaused,
|
||||
@@ -37,12 +39,13 @@ function WorktreeGroupComponent({
|
||||
<span className="worktree-label">{label}</span>
|
||||
</div>
|
||||
{activeTasks.map((task) => (
|
||||
<TaskCard key={task.id} task={task} onOpenDetail={onOpenDetail} addToast={addToast} globalPaused={globalPaused} onUpdateTask={onUpdateTask} onOpenFilesForTask={onOpenFilesForTask} />
|
||||
<TaskCard key={task.id} task={task} projectId={projectId} onOpenDetail={onOpenDetail} addToast={addToast} globalPaused={globalPaused} onUpdateTask={onUpdateTask} onOpenFilesForTask={onOpenFilesForTask} />
|
||||
))}
|
||||
{queuedTasks.map((task) => (
|
||||
<TaskCard
|
||||
key={task.id}
|
||||
task={task}
|
||||
projectId={projectId}
|
||||
queued
|
||||
onOpenDetail={onOpenDetail}
|
||||
addToast={addToast}
|
||||
|
||||
@@ -89,6 +89,21 @@ describe("AgentDetailView", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("fetches the agent using the active project context", async () => {
|
||||
render(
|
||||
<AgentDetailView
|
||||
agentId="agent-001"
|
||||
projectId="proj_123"
|
||||
onClose={vi.fn()}
|
||||
addToast={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchAgent).toHaveBeenCalledWith("agent-001", "proj_123");
|
||||
});
|
||||
});
|
||||
|
||||
it("displays role badge", async () => {
|
||||
render(
|
||||
<AgentDetailView
|
||||
@@ -219,7 +234,7 @@ describe("AgentDetailView", () => {
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchAgent).toHaveBeenCalledWith("agent-001");
|
||||
expect(mockFetchAgent).toHaveBeenCalledWith("agent-001", undefined);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -296,10 +296,13 @@ describe("AgentListModal", () => {
|
||||
fireEvent.click(createButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockCreateAgent).toHaveBeenCalledWith({
|
||||
name: "My New Agent",
|
||||
role: "executor",
|
||||
});
|
||||
expect(mockCreateAgent).toHaveBeenCalledWith(
|
||||
{
|
||||
name: "My New Agent",
|
||||
role: "executor",
|
||||
},
|
||||
undefined
|
||||
);
|
||||
});
|
||||
|
||||
expect(mockAddToast).toHaveBeenCalledWith(
|
||||
@@ -393,7 +396,7 @@ describe("AgentListModal", () => {
|
||||
fireEvent.click(startButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateAgentState).toHaveBeenCalledWith("agent-001", "active");
|
||||
expect(mockUpdateAgentState).toHaveBeenCalledWith("agent-001", "active", undefined);
|
||||
});
|
||||
|
||||
expect(mockAddToast).toHaveBeenCalledWith(
|
||||
@@ -463,7 +466,7 @@ describe("AgentListModal", () => {
|
||||
fireEvent.click(pauseButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateAgentState).toHaveBeenCalledWith("agent-002", "paused");
|
||||
expect(mockUpdateAgentState).toHaveBeenCalledWith("agent-002", "paused", undefined);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -496,7 +499,7 @@ describe("AgentListModal", () => {
|
||||
fireEvent.click(stopButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateAgentState).toHaveBeenCalledWith("agent-002", "terminated");
|
||||
expect(mockUpdateAgentState).toHaveBeenCalledWith("agent-002", "terminated", undefined);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -530,7 +533,7 @@ describe("AgentListModal", () => {
|
||||
fireEvent.click(screen.getByTitle("Resume"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateAgentState).toHaveBeenCalledWith("agent-003", "active");
|
||||
expect(mockUpdateAgentState).toHaveBeenCalledWith("agent-003", "active", undefined);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -618,7 +621,7 @@ describe("AgentListModal", () => {
|
||||
fireEvent.click(screen.getByTitle("Delete"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockDeleteAgent).toHaveBeenCalledWith("agent-004");
|
||||
expect(mockDeleteAgent).toHaveBeenCalledWith("agent-004", undefined);
|
||||
});
|
||||
|
||||
expect(mockAddToast).toHaveBeenCalledWith(
|
||||
@@ -672,7 +675,7 @@ describe("AgentListModal", () => {
|
||||
fireEvent.change(filterSelect, { target: { value: "active" } });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchAgents).toHaveBeenCalledWith({ state: "active" });
|
||||
expect(mockFetchAgents).toHaveBeenCalledWith({ state: "active" }, undefined);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -693,13 +696,13 @@ describe("AgentListModal", () => {
|
||||
fireEvent.change(filterSelect, { target: { value: "idle" } });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchAgents).toHaveBeenLastCalledWith({ state: "idle" });
|
||||
expect(mockFetchAgents).toHaveBeenLastCalledWith({ state: "idle" }, undefined);
|
||||
});
|
||||
|
||||
fireEvent.change(filterSelect, { target: { value: "all" } });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchAgents).toHaveBeenLastCalledWith(undefined);
|
||||
expect(mockFetchAgents).toHaveBeenLastCalledWith(undefined, undefined);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -94,6 +94,13 @@ describe("AgentsView", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("passes projectId to agent fetches", async () => {
|
||||
render(<AgentsView addToast={mockAddToast} projectId="proj_123" />);
|
||||
await waitFor(() => {
|
||||
expect(mockFetchAgents).toHaveBeenCalledWith(undefined, "proj_123");
|
||||
});
|
||||
});
|
||||
|
||||
it("renders empty state when no agents", async () => {
|
||||
mockFetchAgents.mockResolvedValue([]);
|
||||
render(<AgentsView addToast={mockAddToast} />);
|
||||
@@ -220,7 +227,7 @@ describe("AgentsView", () => {
|
||||
fireEvent.change(filterSelect, { target: { value: "active" } });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchAgents).toHaveBeenCalledWith({ state: "active" });
|
||||
expect(mockFetchAgents).toHaveBeenCalledWith({ state: "active" }, undefined);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -235,13 +242,13 @@ describe("AgentsView", () => {
|
||||
fireEvent.change(filterSelect, { target: { value: "idle" } });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchAgents).toHaveBeenLastCalledWith({ state: "idle" });
|
||||
expect(mockFetchAgents).toHaveBeenLastCalledWith({ state: "idle" }, undefined);
|
||||
});
|
||||
|
||||
fireEvent.change(filterSelect, { target: { value: "all" } });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchAgents).toHaveBeenLastCalledWith(undefined);
|
||||
expect(mockFetchAgents).toHaveBeenLastCalledWith(undefined, undefined);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -268,7 +275,7 @@ describe("AgentsView", () => {
|
||||
expect(mockCreateAgent).toHaveBeenCalledWith({
|
||||
name: "My Agent",
|
||||
role: "custom",
|
||||
});
|
||||
}, undefined);
|
||||
});
|
||||
|
||||
expect(mockAddToast).toHaveBeenCalledWith(
|
||||
@@ -338,7 +345,7 @@ describe("AgentsView", () => {
|
||||
fireEvent.click(screen.getByTitle("Activate"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateAgentState).toHaveBeenCalledWith("agent-001", "active");
|
||||
expect(mockUpdateAgentState).toHaveBeenCalledWith("agent-001", "active", undefined);
|
||||
});
|
||||
|
||||
expect(mockAddToast).toHaveBeenCalledWith(
|
||||
@@ -370,7 +377,7 @@ describe("AgentsView", () => {
|
||||
fireEvent.click(pauseButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateAgentState).toHaveBeenCalledWith("agent-002", "paused");
|
||||
expect(mockUpdateAgentState).toHaveBeenCalledWith("agent-002", "paused", undefined);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -384,7 +391,7 @@ describe("AgentsView", () => {
|
||||
fireEvent.click(screen.getByTitle("Resume"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateAgentState).toHaveBeenCalledWith("agent-003", "active");
|
||||
expect(mockUpdateAgentState).toHaveBeenCalledWith("agent-003", "active", undefined);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -448,7 +455,7 @@ describe("AgentsView", () => {
|
||||
fireEvent.click(screen.getByTitle("Delete"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockDeleteAgent).toHaveBeenCalledWith("agent-004");
|
||||
expect(mockDeleteAgent).toHaveBeenCalledWith("agent-004", undefined);
|
||||
});
|
||||
|
||||
expect(mockAddToast).toHaveBeenCalledWith(
|
||||
|
||||
@@ -140,7 +140,7 @@ describe("App deep link handling", () => {
|
||||
render(<App />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchTaskDetail).toHaveBeenCalledWith("FN-123");
|
||||
expect(fetchTaskDetail).toHaveBeenCalledWith("FN-123", "proj_123");
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -160,7 +160,7 @@ describe("App deep link handling", () => {
|
||||
render(<App />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchTaskDetail).toHaveBeenCalledWith("FN-404");
|
||||
expect(fetchTaskDetail).toHaveBeenCalledWith("FN-404", "proj_123");
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -197,7 +197,7 @@ describe("App deep link handling", () => {
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchTaskDetail).toHaveBeenCalledWith("FN-789");
|
||||
expect(fetchTaskDetail).toHaveBeenCalledWith("FN-789", "proj_456");
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -242,7 +242,7 @@ describe("App deep link handling", () => {
|
||||
render(<App />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchTaskDetail).toHaveBeenCalledWith("FN-123");
|
||||
expect(fetchTaskDetail).toHaveBeenCalledWith("FN-123", "proj_123");
|
||||
});
|
||||
|
||||
// setCurrentProject should NOT be called since we're already on this project
|
||||
@@ -262,7 +262,7 @@ describe("App deep link handling", () => {
|
||||
render(<App />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchTaskDetail).toHaveBeenCalledWith("FN-123");
|
||||
expect(fetchTaskDetail).toHaveBeenCalledWith("FN-123", "proj_123");
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -274,6 +274,44 @@ describe("App deep link handling", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("App mission wiring", () => {
|
||||
afterEach(() => {
|
||||
localStorage.removeItem("kb-dashboard-view-mode");
|
||||
});
|
||||
|
||||
it("hides mission controls when no project is selected", async () => {
|
||||
mockCurrentProjectState.currentProject = null;
|
||||
mockProjectsState.projects = [];
|
||||
|
||||
render(<App />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchSettings).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
expect(screen.queryByTestId("missions-btn")).toBeNull();
|
||||
});
|
||||
|
||||
it("shows mission controls in project view when a project is selected", async () => {
|
||||
localStorage.setItem("kb-dashboard-view-mode", "project");
|
||||
mockCurrentProjectState.currentProject = {
|
||||
id: "proj_123",
|
||||
name: "Test Project",
|
||||
path: "/test",
|
||||
status: "active",
|
||||
isolationMode: "in-process",
|
||||
createdAt: "",
|
||||
updatedAt: "",
|
||||
};
|
||||
|
||||
render(<App />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("missions-btn")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("App auto-open Settings on unauthenticated", () => {
|
||||
it("auto-opens Settings to Authentication tab when all providers are unauthenticated", async () => {
|
||||
render(<App />);
|
||||
@@ -410,7 +448,7 @@ describe("App global pause (hard stop)", () => {
|
||||
});
|
||||
|
||||
// Should call updateSettings with globalPause: true
|
||||
expect(updateSettings).toHaveBeenCalledWith({ globalPause: true });
|
||||
expect(updateSettings).toHaveBeenCalledWith({ globalPause: true }, "proj_123");
|
||||
});
|
||||
|
||||
it("reverts global pause state on updateSettings failure", async () => {
|
||||
@@ -485,7 +523,7 @@ describe("App engine pause (soft pause)", () => {
|
||||
});
|
||||
|
||||
// Should call updateSettings with enginePaused: true
|
||||
expect(updateSettings).toHaveBeenCalledWith({ enginePaused: true });
|
||||
expect(updateSettings).toHaveBeenCalledWith({ enginePaused: true }, "proj_123");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -638,6 +676,19 @@ describe("App view switching", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("hides agent view controls when no project is active", async () => {
|
||||
mockCurrentProjectState.currentProject = null;
|
||||
localStorage.setItem("kb-dashboard-view-mode", "overview");
|
||||
|
||||
render(<App />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTitle("Agents view")).toBeNull();
|
||||
});
|
||||
|
||||
localStorage.removeItem("kb-dashboard-view-mode");
|
||||
});
|
||||
|
||||
it("renders AgentsView when agents view is selected", async () => {
|
||||
render(<App />);
|
||||
|
||||
|
||||
@@ -55,6 +55,17 @@ describe("Header", () => {
|
||||
expect(btn).toBeDefined();
|
||||
});
|
||||
|
||||
it("renders the missions button when mission management is available", () => {
|
||||
const onOpen = vi.fn();
|
||||
render(<Header onOpenMissions={onOpen} />);
|
||||
expect(screen.getByTestId("missions-btn")).toBeDefined();
|
||||
});
|
||||
|
||||
it("does not render the missions button when mission management is unavailable", () => {
|
||||
render(<Header />);
|
||||
expect(screen.queryByTestId("missions-btn")).toBeNull();
|
||||
});
|
||||
|
||||
it("calls onOpenGitHubImport when import button is clicked", () => {
|
||||
const onOpen = vi.fn();
|
||||
render(<Header onOpenGitHubImport={onOpen} />);
|
||||
|
||||
@@ -167,7 +167,7 @@ describe("ListView", () => {
|
||||
fireEvent.click(row!);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchTaskDetail).toHaveBeenCalledWith("FN-001");
|
||||
expect(fetchTaskDetail).toHaveBeenCalledWith("FN-001", undefined);
|
||||
});
|
||||
|
||||
expect(mockOnOpenDetail).toHaveBeenCalledWith(mockDetail);
|
||||
|
||||
@@ -119,7 +119,7 @@ describe("PrSection", () => {
|
||||
expect(createPr).toHaveBeenCalledWith("FN-001", {
|
||||
title: "My PR Title",
|
||||
body: undefined,
|
||||
});
|
||||
}, undefined);
|
||||
});
|
||||
|
||||
expect(mockOnPrCreated).toHaveBeenCalledWith(mockPrInfo);
|
||||
@@ -243,7 +243,7 @@ describe("PrSection", () => {
|
||||
fireEvent.click(refreshButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(refreshPrStatus).toHaveBeenCalledWith("FN-001");
|
||||
expect(refreshPrStatus).toHaveBeenCalledWith("FN-001", undefined);
|
||||
});
|
||||
|
||||
expect(mockOnPrUpdated).toHaveBeenCalledWith(updatedPr);
|
||||
|
||||
@@ -106,7 +106,7 @@ describe("ScriptsModal", () => {
|
||||
fireEvent.click(screen.getByTestId("script-save-btn"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(addScript).toHaveBeenCalledWith("new-script", "echo hello");
|
||||
expect(addScript).toHaveBeenCalledWith("new-script", "echo hello", undefined);
|
||||
expect(addToast).toHaveBeenCalledWith("Script created", "success");
|
||||
});
|
||||
});
|
||||
@@ -158,7 +158,7 @@ describe("ScriptsModal", () => {
|
||||
fireEvent.click(screen.getByTestId("script-save-btn"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(addScript).toHaveBeenCalledWith("my-script_v2", "echo test");
|
||||
expect(addScript).toHaveBeenCalledWith("my-script_v2", "echo test", undefined);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -222,7 +222,7 @@ describe("ScriptsModal", () => {
|
||||
fireEvent.click(screen.getByTestId("confirm-delete-script-build"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(removeScript).toHaveBeenCalledWith("build");
|
||||
expect(removeScript).toHaveBeenCalledWith("build", undefined);
|
||||
expect(addToast).toHaveBeenCalledWith("Script deleted", "success");
|
||||
});
|
||||
});
|
||||
@@ -317,7 +317,7 @@ describe("ScriptsModal", () => {
|
||||
fireEvent.click(screen.getByTestId("script-save-btn"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(addScript).toHaveBeenCalledWith("build", "npm run build:prod");
|
||||
expect(addScript).toHaveBeenCalledWith("build", "npm run build:prod", undefined);
|
||||
expect(addToast).toHaveBeenCalledWith("Script updated", "success");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -33,10 +33,14 @@ vi.mock("../../api", () => ({
|
||||
fetchAuthStatus: vi.fn(() => Promise.resolve({ providers: [{ id: "anthropic", name: "Anthropic", authenticated: false }] })),
|
||||
loginProvider: vi.fn(() => Promise.resolve({ url: "https://auth.example.com/login" })),
|
||||
logoutProvider: vi.fn(() => Promise.resolve({ success: true })),
|
||||
fetchModels: vi.fn(() => Promise.resolve([
|
||||
{ provider: "anthropic", id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5", reasoning: true, contextWindow: 200000 },
|
||||
{ provider: "openai", id: "gpt-4o", name: "GPT-4o", reasoning: false, contextWindow: 128000 },
|
||||
])),
|
||||
fetchModels: vi.fn(() => Promise.resolve({
|
||||
models: [
|
||||
{ provider: "anthropic", id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5", reasoning: true, contextWindow: 200000 },
|
||||
{ provider: "openai", id: "gpt-4o", name: "GPT-4o", reasoning: false, contextWindow: 128000 },
|
||||
],
|
||||
favoriteProviders: [],
|
||||
favoriteModels: [],
|
||||
})),
|
||||
testNtfyNotification: vi.fn(() => Promise.resolve({ success: true })),
|
||||
}));
|
||||
|
||||
@@ -579,7 +583,7 @@ describe("SettingsModal", () => {
|
||||
});
|
||||
|
||||
it("shows empty state when no models available", async () => {
|
||||
(fetchModels as ReturnType<typeof vi.fn>).mockResolvedValueOnce([]);
|
||||
(fetchModels as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ models: [], favoriteProviders: [], favoriteModels: [] });
|
||||
|
||||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||||
@@ -649,7 +653,7 @@ describe("SettingsModal", () => {
|
||||
});
|
||||
|
||||
it("shows empty state in Execution Model section when no models available", async () => {
|
||||
(fetchModels as ReturnType<typeof vi.fn>).mockResolvedValueOnce([]);
|
||||
(fetchModels as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ models: [], favoriteProviders: [], favoriteModels: [] });
|
||||
|
||||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||||
@@ -1493,7 +1497,7 @@ describe("SettingsModal", () => {
|
||||
fireEvent.click(testButton);
|
||||
|
||||
await waitFor(() => expect(testNtfyNotification).toHaveBeenCalledTimes(1));
|
||||
expect(testNtfyNotification).toHaveBeenCalledWith({ ntfyEnabled: true, ntfyTopic: "my-valid-topic" });
|
||||
expect(testNtfyNotification).toHaveBeenCalledWith({ ntfyEnabled: true, ntfyTopic: "my-valid-topic" }, undefined);
|
||||
});
|
||||
|
||||
it("Success toast is shown when test notification succeeds", async () => {
|
||||
|
||||
@@ -621,7 +621,7 @@ describe("TaskCard clickable dependencies", () => {
|
||||
fireEvent.click(depBadge);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetch).toHaveBeenCalledWith("FN-001");
|
||||
expect(mockFetch).toHaveBeenCalledWith("FN-001", undefined);
|
||||
expect(onOpenDetail).toHaveBeenCalledWith(mockDetail);
|
||||
});
|
||||
});
|
||||
@@ -2384,7 +2384,7 @@ describe("TaskCard detail opening", () => {
|
||||
fireEvent.click(cardTitle);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetch).toHaveBeenCalledWith("FN-099");
|
||||
expect(mockFetch).toHaveBeenCalledWith("FN-099", undefined);
|
||||
expect(onOpenDetail).toHaveBeenCalledWith(mockDetail);
|
||||
});
|
||||
});
|
||||
@@ -2419,7 +2419,7 @@ describe("TaskCard detail opening", () => {
|
||||
fireEvent.click(screen.getByText("Test task"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetch).toHaveBeenCalledWith("FN-099");
|
||||
expect(mockFetch).toHaveBeenCalledWith("FN-099", undefined);
|
||||
expect(onOpenDetail).toHaveBeenCalledWith(mockDetail);
|
||||
expect(onOpenDetail).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
@@ -42,7 +42,7 @@ describe("TaskComments", () => {
|
||||
fireEvent.change(screen.getByPlaceholderText(/Add a comment/), { target: { value: "Hello" } });
|
||||
fireEvent.click(screen.getByText("Add Comment"));
|
||||
|
||||
await waitFor(() => expect(addTaskComment).toHaveBeenCalledWith("FN-001", "Hello", "user"));
|
||||
await waitFor(() => expect(addTaskComment).toHaveBeenCalledWith("FN-001", "Hello", "user", undefined));
|
||||
expect(onTaskUpdated).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -55,7 +55,7 @@ describe("TaskComments", () => {
|
||||
fireEvent.change(screen.getByDisplayValue("Original"), { target: { value: "Updated" } });
|
||||
fireEvent.click(screen.getByText("Save"));
|
||||
|
||||
await waitFor(() => expect(updateTaskComment).toHaveBeenCalledWith("FN-001", "c1", "Updated"));
|
||||
await waitFor(() => expect(updateTaskComment).toHaveBeenCalledWith("FN-001", "c1", "Updated", undefined));
|
||||
expect(onTaskUpdated).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -66,7 +66,7 @@ describe("TaskComments", () => {
|
||||
render(<TaskComments task={makeTask({ comments: [{ id: "c1", text: "Original", author: "user", createdAt: "2026-01-01T00:00:00.000Z" }] })} addToast={vi.fn()} onTaskUpdated={onTaskUpdated} />);
|
||||
fireEvent.click(screen.getByText("Delete"));
|
||||
|
||||
await waitFor(() => expect(deleteTaskComment).toHaveBeenCalledWith("FN-001", "c1"));
|
||||
await waitFor(() => expect(deleteTaskComment).toHaveBeenCalledWith("FN-001", "c1", undefined));
|
||||
expect(onTaskUpdated).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -170,7 +170,7 @@ describe("TaskComments", () => {
|
||||
fireEvent.click(screen.getByText("Add Guidance"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(addSteeringComment).toHaveBeenCalledWith("FN-001", "Guidance text");
|
||||
expect(addSteeringComment).toHaveBeenCalledWith("FN-001", "Guidance text", undefined);
|
||||
expect(addTaskComment).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -187,7 +187,7 @@ describe("TaskComments", () => {
|
||||
fireEvent.click(screen.getByText("Add Comment"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(addTaskComment).toHaveBeenCalledWith("FN-001", "User text", "user");
|
||||
expect(addTaskComment).toHaveBeenCalledWith("FN-001", "User text", "user", undefined);
|
||||
expect(addSteeringComment).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -252,7 +252,7 @@ describe("TaskComments", () => {
|
||||
fireEvent.keyDown(textarea, { key: "Enter", ctrlKey: true });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(addTaskComment).toHaveBeenCalledWith("FN-001", "Keyboard", "user");
|
||||
expect(addTaskComment).toHaveBeenCalledWith("FN-001", "Keyboard", "user", undefined);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -268,7 +268,7 @@ describe("TaskComments", () => {
|
||||
fireEvent.keyDown(textarea, { key: "Enter", metaKey: true });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(addTaskComment).toHaveBeenCalledWith("FN-001", "Mac", "user");
|
||||
expect(addTaskComment).toHaveBeenCalledWith("FN-001", "Mac", "user", undefined);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -302,7 +302,7 @@ describe("TaskDetailModal", () => {
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpload).toHaveBeenCalledWith("FN-099", imageFile);
|
||||
expect(mockUpload).toHaveBeenCalledWith("FN-099", imageFile, undefined);
|
||||
expect(addToast).toHaveBeenCalledWith("Screenshot attached", "success");
|
||||
});
|
||||
});
|
||||
@@ -432,7 +432,7 @@ describe("TaskDetailModal", () => {
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpload).toHaveBeenCalledWith("FN-099", imageFile);
|
||||
expect(mockUpload).toHaveBeenCalledWith("FN-099", imageFile, undefined);
|
||||
expect(addToast).toHaveBeenCalledWith("Screenshot attached", "success");
|
||||
});
|
||||
});
|
||||
@@ -502,7 +502,7 @@ describe("TaskDetailModal", () => {
|
||||
fireEvent.click(screen.getByText("FN-001"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(updateTask).toHaveBeenCalledWith("FN-099", { dependencies: ["FN-001"] });
|
||||
expect(updateTask).toHaveBeenCalledWith("FN-099", { dependencies: ["FN-001"] }, undefined);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -525,7 +525,7 @@ describe("TaskDetailModal", () => {
|
||||
fireEvent.click(removeButtons[0]); // Remove KB-001
|
||||
|
||||
await waitFor(() => {
|
||||
expect(updateTask).toHaveBeenCalledWith("FN-099", { dependencies: ["FN-002"] });
|
||||
expect(updateTask).toHaveBeenCalledWith("FN-099", { dependencies: ["FN-002"] }, undefined);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1438,7 +1438,7 @@ describe("TaskDetailModal", () => {
|
||||
fireEvent.click(depLink);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetch).toHaveBeenCalledWith("FN-001");
|
||||
expect(mockFetch).toHaveBeenCalledWith("FN-001", undefined);
|
||||
expect(onOpenDetail).toHaveBeenCalledWith(mockDetail);
|
||||
});
|
||||
});
|
||||
@@ -1497,7 +1497,7 @@ describe("TaskDetailModal", () => {
|
||||
expect(onOpenDetail).not.toHaveBeenCalled();
|
||||
// updateTask should be called to remove the dependency
|
||||
await waitFor(() => {
|
||||
expect(updateTask).toHaveBeenCalledWith("FN-099", { dependencies: [] });
|
||||
expect(updateTask).toHaveBeenCalledWith("FN-099", { dependencies: [] }, undefined);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1593,7 +1593,7 @@ describe("TaskDetailModal", () => {
|
||||
fireEvent.click(screen.getByText("Save"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdate).toHaveBeenCalledWith("FN-099", { prompt: "# Updated" });
|
||||
expect(mockUpdate).toHaveBeenCalledWith("FN-099", { prompt: "# Updated" }, undefined);
|
||||
});
|
||||
|
||||
// Should return to view mode
|
||||
@@ -1646,7 +1646,7 @@ describe("TaskDetailModal", () => {
|
||||
fireEvent.click(screen.getByText("Request AI Revision"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(requestSpecRevision).toHaveBeenCalledWith("FN-099", "Please add more error handling details");
|
||||
expect(requestSpecRevision).toHaveBeenCalledWith("FN-099", "Please add more error handling details", undefined);
|
||||
expect(addToast).toHaveBeenCalledWith("AI revision requested. Task moved to triage.", "success");
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
@@ -1804,7 +1804,7 @@ describe("TaskDetailModal", () => {
|
||||
fireEvent.click(screen.getByText("Approve Plan"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockApprovePlan).toHaveBeenCalledWith("FN-001");
|
||||
expect(mockApprovePlan).toHaveBeenCalledWith("FN-001", undefined);
|
||||
});
|
||||
expect(addToast).toHaveBeenCalledWith("Plan approved — FN-001 moved to Todo", "success");
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
@@ -1844,7 +1844,7 @@ describe("TaskDetailModal", () => {
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockRejectPlan).toHaveBeenCalledWith("FN-001");
|
||||
expect(mockRejectPlan).toHaveBeenCalledWith("FN-001", undefined);
|
||||
});
|
||||
expect(addToast).toHaveBeenCalledWith(
|
||||
"Plan rejected — FN-001 returned to Triage for re-specification",
|
||||
@@ -2393,7 +2393,7 @@ describe("TaskDetailModal", () => {
|
||||
fireEvent.click(screen.getByText("Create Refinement Task"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(refineTask).toHaveBeenCalledWith("FN-001", "Need to add more tests");
|
||||
expect(refineTask).toHaveBeenCalledWith("FN-001", "Need to add more tests", undefined);
|
||||
expect(addToast).toHaveBeenCalledWith("Refinement task created: FN-002", "success");
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
@@ -2597,7 +2597,7 @@ describe("TaskDetailModal", () => {
|
||||
expect(mockUpdate).toHaveBeenCalledWith("FN-001", {
|
||||
title: "New title",
|
||||
description: "New description",
|
||||
});
|
||||
}, undefined);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -135,7 +135,7 @@ describe("WorkflowStepManager", () => {
|
||||
description: "New description",
|
||||
prompt: undefined,
|
||||
enabled: true,
|
||||
});
|
||||
}, undefined);
|
||||
expect(addToast).toHaveBeenCalledWith("Workflow step created", "success");
|
||||
});
|
||||
});
|
||||
@@ -167,7 +167,7 @@ describe("WorkflowStepManager", () => {
|
||||
await waitFor(() => {
|
||||
expect(updateWorkflowStep).toHaveBeenCalledWith("WS-001", expect.objectContaining({
|
||||
name: "Updated Name",
|
||||
}));
|
||||
}), undefined);
|
||||
expect(addToast).toHaveBeenCalledWith("Workflow step updated", "success");
|
||||
});
|
||||
});
|
||||
@@ -192,7 +192,7 @@ describe("WorkflowStepManager", () => {
|
||||
fireEvent.click(confirmBtn);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(deleteWorkflowStep).toHaveBeenCalledWith("WS-001");
|
||||
expect(deleteWorkflowStep).toHaveBeenCalledWith("WS-001", undefined);
|
||||
expect(addToast).toHaveBeenCalledWith("Workflow step deleted", "success");
|
||||
});
|
||||
});
|
||||
@@ -216,7 +216,7 @@ describe("WorkflowStepManager", () => {
|
||||
fireEvent.click(refineBtn);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(refineWorkflowStepPrompt).toHaveBeenCalledWith("WS-001");
|
||||
expect(refineWorkflowStepPrompt).toHaveBeenCalledWith("WS-001", undefined);
|
||||
expect(addToast).toHaveBeenCalledWith("Prompt refined with AI", "success");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -72,7 +72,7 @@ describe("useAgentLogs", () => {
|
||||
expect(result.current.entries).toEqual(historicalLogs);
|
||||
});
|
||||
|
||||
expect(mockFetchAgentLogs).toHaveBeenCalledWith("FN-001");
|
||||
expect(mockFetchAgentLogs).toHaveBeenCalledWith("FN-001", undefined);
|
||||
expect(MockEventSource.instances).toHaveLength(1);
|
||||
expect(MockEventSource.instances[0].url).toBe("/api/tasks/FN-001/logs/stream");
|
||||
});
|
||||
|
||||
@@ -41,7 +41,7 @@ describe("useBatchBadgeFetch", () => {
|
||||
await result.current.fetchBatch(["FN-001"]);
|
||||
});
|
||||
|
||||
expect(mockFetchBatchStatus).toHaveBeenCalledWith(["FN-001"]);
|
||||
expect(mockFetchBatchStatus).toHaveBeenCalledWith(["FN-001"], undefined);
|
||||
expect(mockFetchBatchStatus).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ describe("useChangedFiles", () => {
|
||||
expect(result.current.error).toBeNull();
|
||||
expect(result.current.files).toHaveLength(2);
|
||||
expect(result.current.selectedFile?.path).toBe("src/a.ts");
|
||||
expect(mockFetchTaskFileDiffs).toHaveBeenCalledWith("KB-651");
|
||||
expect(mockFetchTaskFileDiffs).toHaveBeenCalledWith("KB-651", undefined);
|
||||
});
|
||||
|
||||
it("does not fetch for tasks without worktrees or inactive columns", async () => {
|
||||
|
||||
@@ -26,7 +26,7 @@ describe("useSessionFiles", () => {
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
|
||||
expect(result.current.files).toEqual(["src/a.ts", "src/b.ts"]);
|
||||
expect(mockFetchSessionFiles).toHaveBeenCalledWith("FN-123");
|
||||
expect(mockFetchSessionFiles).toHaveBeenCalledWith("FN-123", undefined);
|
||||
});
|
||||
|
||||
it("does not fetch for tasks without worktrees or inactive columns", async () => {
|
||||
|
||||
@@ -676,7 +676,7 @@ describe("useTasks", () => {
|
||||
expect(mockUpdateTask).toHaveBeenCalledWith("FN-001", {
|
||||
title: "New Title",
|
||||
description: "New Description",
|
||||
});
|
||||
}, undefined);
|
||||
expect(returnedTask).toEqual(updatedTask);
|
||||
expect(result.current.tasks[0].title).toBe("New Title");
|
||||
expect(result.current.tasks[0].description).toBe("New Description");
|
||||
|
||||
@@ -21,7 +21,7 @@ function capLogEntries(entries: AgentLogEntry[]): AgentLogEntry[] {
|
||||
* When `enabled` becomes false or the component unmounts, the EventSource
|
||||
* is closed to avoid unnecessary SSE connections.
|
||||
*/
|
||||
export function useAgentLogs(taskId: string | null, enabled: boolean) {
|
||||
export function useAgentLogs(taskId: string | null, enabled: boolean, projectId?: string) {
|
||||
const [entries, setEntries] = useState<AgentLogEntry[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const eventSourceRef = useRef<EventSource | null>(null);
|
||||
@@ -45,7 +45,7 @@ export function useAgentLogs(taskId: string | null, enabled: boolean) {
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const historical = await fetchAgentLogs(currentTaskId);
|
||||
const historical = await fetchAgentLogs(currentTaskId, projectId);
|
||||
if (cancelled) return;
|
||||
setEntries(capLogEntries(historical));
|
||||
} catch {
|
||||
@@ -56,7 +56,8 @@ export function useAgentLogs(taskId: string | null, enabled: boolean) {
|
||||
}
|
||||
|
||||
// Open SSE connection for live updates
|
||||
const es = new EventSource(`/api/tasks/${currentTaskId}/logs/stream`);
|
||||
const query = projectId ? `?projectId=${encodeURIComponent(projectId)}` : "";
|
||||
const es = new EventSource(`/api/tasks/${currentTaskId}/logs/stream${query}`);
|
||||
eventSourceRef.current = es;
|
||||
|
||||
es.addEventListener("agent:log", (e) => {
|
||||
@@ -79,7 +80,7 @@ export function useAgentLogs(taskId: string | null, enabled: boolean) {
|
||||
eventSourceRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [taskId, enabled]);
|
||||
}, [taskId, enabled, projectId]);
|
||||
|
||||
const clear = useCallback(() => setEntries([]), []);
|
||||
|
||||
|
||||
@@ -12,13 +12,17 @@ const batchBadgeStore = {
|
||||
/** Maximum age of cached batch data in milliseconds (5 seconds) */
|
||||
const CACHE_MAX_AGE_MS = 5000;
|
||||
|
||||
function getScopedTaskKey(taskId: string, projectId?: string): string {
|
||||
return projectId ? `${projectId}::${taskId}` : taskId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if fresh batch data exists for a task ID.
|
||||
* @param taskId - The task ID to check
|
||||
* @returns The cached data if fresh, undefined otherwise
|
||||
*/
|
||||
export function getFreshBatchData(taskId: string): { result: BatchStatusResult[string]; timestamp: number } | undefined {
|
||||
const cached = batchBadgeStore.data.get(taskId);
|
||||
export function getFreshBatchData(taskId: string, projectId?: string): { result: BatchStatusResult[string]; timestamp: number } | undefined {
|
||||
const cached = batchBadgeStore.data.get(getScopedTaskKey(taskId, projectId));
|
||||
if (!cached) return undefined;
|
||||
|
||||
const now = Date.now();
|
||||
@@ -49,7 +53,7 @@ interface UseBatchBadgeFetchResult {
|
||||
* - Exponential backoff retry: handles 429 rate limit errors with up to 3 retries
|
||||
* - Shared store: data is available across all hook instances
|
||||
*/
|
||||
export function useBatchBadgeFetch(): UseBatchBadgeFetchResult {
|
||||
export function useBatchBadgeFetch(projectId?: string): UseBatchBadgeFetchResult {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const fetchTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
@@ -62,7 +66,7 @@ export function useBatchBadgeFetch(): UseBatchBadgeFetchResult {
|
||||
|
||||
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
||||
try {
|
||||
const results = await fetchBatchStatus(taskIds);
|
||||
const results = await fetchBatchStatus(taskIds, projectId);
|
||||
return results;
|
||||
} catch (err: any) {
|
||||
lastError = err instanceof Error ? err : new Error(String(err));
|
||||
@@ -85,7 +89,7 @@ export function useBatchBadgeFetch(): UseBatchBadgeFetchResult {
|
||||
}
|
||||
|
||||
return {};
|
||||
}, []);
|
||||
}, [projectId]);
|
||||
|
||||
/**
|
||||
* Fetch batch badge statuses for the given task IDs.
|
||||
@@ -98,7 +102,7 @@ export function useBatchBadgeFetch(): UseBatchBadgeFetchResult {
|
||||
const now = Date.now();
|
||||
const fiveSecondsAgo = now - 5000;
|
||||
const hasFreshCache = taskIds.every((id) => {
|
||||
const cached = batchBadgeStore.data.get(id);
|
||||
const cached = batchBadgeStore.data.get(getScopedTaskKey(id, projectId));
|
||||
return cached && cached.timestamp > fiveSecondsAgo;
|
||||
});
|
||||
|
||||
@@ -136,7 +140,7 @@ export function useBatchBadgeFetch(): UseBatchBadgeFetchResult {
|
||||
// Update the store with new data
|
||||
const timestamp = Date.now();
|
||||
for (const [taskId, result] of Object.entries(results)) {
|
||||
batchBadgeStore.data.set(taskId, { result, timestamp });
|
||||
batchBadgeStore.data.set(getScopedTaskKey(taskId, projectId), { result, timestamp });
|
||||
}
|
||||
batchBadgeStore.lastFetchTime = timestamp;
|
||||
} catch (err) {
|
||||
@@ -146,14 +150,14 @@ export function useBatchBadgeFetch(): UseBatchBadgeFetchResult {
|
||||
batchBadgeStore.pendingPromise = null;
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [fetchWithRetry]);
|
||||
}, [fetchWithRetry, projectId]);
|
||||
|
||||
/**
|
||||
* Get cached batch data for a specific task ID.
|
||||
*/
|
||||
const getBatchData = useCallback((taskId: string) => {
|
||||
return batchBadgeStore.data.get(taskId);
|
||||
}, []);
|
||||
return batchBadgeStore.data.get(getScopedTaskKey(taskId, projectId));
|
||||
}, [projectId]);
|
||||
|
||||
return {
|
||||
fetchBatch,
|
||||
|
||||
@@ -11,7 +11,7 @@ interface UseChangedFilesResult {
|
||||
setSelectedFile: (file: TaskFileDiff) => void;
|
||||
}
|
||||
|
||||
export function useChangedFiles(taskId: string, worktree: string | undefined, column: string): UseChangedFilesResult {
|
||||
export function useChangedFiles(taskId: string, worktree: string | undefined, column: string, projectId?: string): UseChangedFilesResult {
|
||||
const [files, setFiles] = useState<TaskFileDiff[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -32,7 +32,7 @@ export function useChangedFiles(taskId: string, worktree: string | undefined, co
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await fetchTaskFileDiffs(taskId);
|
||||
const result = await fetchTaskFileDiffs(taskId, projectId);
|
||||
if (cancelled) return;
|
||||
setFiles(result);
|
||||
setSelectedFile((current) => {
|
||||
@@ -60,7 +60,7 @@ export function useChangedFiles(taskId: string, worktree: string | undefined, co
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [taskId, worktree, column]);
|
||||
}, [taskId, worktree, column, projectId]);
|
||||
|
||||
return { files, loading, error, selectedFile, setSelectedFile };
|
||||
}
|
||||
|
||||
@@ -140,7 +140,7 @@ export function useExecutorStats(projectId?: string): UseExecutorStatsResult {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const data = await fetchExecutorStats();
|
||||
const data = await fetchExecutorStats(projectId);
|
||||
setApiData(data);
|
||||
} catch (err) {
|
||||
if (err instanceof Error && err.name === "AbortError") {
|
||||
@@ -151,7 +151,7 @@ export function useExecutorStats(projectId?: string): UseExecutorStatsResult {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
}, [projectId]);
|
||||
|
||||
// Initial fetch
|
||||
useEffect(() => {
|
||||
|
||||
@@ -8,7 +8,7 @@ interface UseSessionFilesResult {
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
export function useSessionFiles(taskId: string, worktree: string | undefined, column: string): UseSessionFilesResult {
|
||||
export function useSessionFiles(taskId: string, worktree: string | undefined, column: string, projectId?: string): UseSessionFilesResult {
|
||||
const [files, setFiles] = useState<string[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
@@ -24,7 +24,7 @@ export function useSessionFiles(taskId: string, worktree: string | undefined, co
|
||||
async function load() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await fetchSessionFiles(taskId);
|
||||
const result = await fetchSessionFiles(taskId, projectId);
|
||||
if (!cancelled) {
|
||||
setFiles(result);
|
||||
}
|
||||
@@ -40,7 +40,7 @@ export function useSessionFiles(taskId: string, worktree: string | undefined, co
|
||||
}
|
||||
|
||||
void load();
|
||||
}, [taskId, worktree, column]);
|
||||
}, [taskId, worktree, column, projectId]);
|
||||
|
||||
return { files, loading };
|
||||
}
|
||||
|
||||
@@ -49,9 +49,7 @@ export function useTasks(options?: UseTasksOptions) {
|
||||
const requestVersion = ++fetchVersionRef.current;
|
||||
|
||||
try {
|
||||
const fetchedTasks = projectId
|
||||
? await api.fetchProjectTasks(projectId)
|
||||
: await api.fetchTasks();
|
||||
const fetchedTasks = await api.fetchTasks(undefined, undefined, projectId);
|
||||
if (fetchVersionRef.current !== requestVersion) {
|
||||
return;
|
||||
}
|
||||
@@ -93,20 +91,6 @@ export function useTasks(options?: UseTasksOptions) {
|
||||
};
|
||||
}, [refreshTasks]);
|
||||
|
||||
// Fetch initial tasks and recover when the tab becomes visible again.
|
||||
useEffect(() => {
|
||||
void refreshTasks();
|
||||
|
||||
const handleVisibilityChange = () => {
|
||||
void refreshTasks();
|
||||
};
|
||||
|
||||
document.addEventListener("visibilitychange", handleVisibilityChange);
|
||||
return () => {
|
||||
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
||||
};
|
||||
}, [refreshTasks]);
|
||||
|
||||
// SSE live updates
|
||||
// Note: In multi-project mode, SSE receives all task events.
|
||||
// Tasks are filtered by ID match, so cross-project updates won't affect
|
||||
@@ -117,7 +101,8 @@ export function useTasks(options?: UseTasksOptions) {
|
||||
if (connectionNonce > 0) {
|
||||
void refreshTasks();
|
||||
}
|
||||
const es = new EventSource("/api/events");
|
||||
const query = projectId ? `?projectId=${encodeURIComponent(projectId)}` : "";
|
||||
const es = new EventSource(`/api/events${query}`);
|
||||
|
||||
const handleCreated = (e: MessageEvent) => {
|
||||
const task = normalizeTask(JSON.parse(e.data) as Task);
|
||||
@@ -220,31 +205,31 @@ export function useTasks(options?: UseTasksOptions) {
|
||||
closedByCleanup = true;
|
||||
cleanup();
|
||||
};
|
||||
}, [connectionNonce, refreshTasks]);
|
||||
}, [connectionNonce, projectId, refreshTasks]);
|
||||
|
||||
const createTask = useCallback(async (input: TaskCreateInput): Promise<Task> => {
|
||||
return normalizeTask(await api.createTask(input));
|
||||
}, []);
|
||||
return normalizeTask(await api.createTask(input, projectId));
|
||||
}, [projectId]);
|
||||
|
||||
const moveTask = useCallback(async (id: string, column: Column): Promise<Task> => {
|
||||
return normalizeTask(await api.moveTask(id, column));
|
||||
}, []);
|
||||
return normalizeTask(await api.moveTask(id, column, projectId));
|
||||
}, [projectId]);
|
||||
|
||||
const deleteTask = useCallback(async (id: string): Promise<Task> => {
|
||||
return normalizeTask(await api.deleteTask(id));
|
||||
}, []);
|
||||
return normalizeTask(await api.deleteTask(id, projectId));
|
||||
}, [projectId]);
|
||||
|
||||
const mergeTask = useCallback(async (id: string): Promise<MergeResult> => {
|
||||
return api.mergeTask(id);
|
||||
}, []);
|
||||
return api.mergeTask(id, projectId);
|
||||
}, [projectId]);
|
||||
|
||||
const retryTask = useCallback(async (id: string): Promise<Task> => {
|
||||
return normalizeTask(await api.retryTask(id));
|
||||
}, []);
|
||||
return normalizeTask(await api.retryTask(id, projectId));
|
||||
}, [projectId]);
|
||||
|
||||
const duplicateTask = useCallback(async (id: string): Promise<Task> => {
|
||||
return normalizeTask(await api.duplicateTask(id));
|
||||
}, []);
|
||||
return normalizeTask(await api.duplicateTask(id, projectId));
|
||||
}, [projectId]);
|
||||
|
||||
const updateTask = useCallback(async (
|
||||
id: string,
|
||||
@@ -262,7 +247,7 @@ export function useTasks(options?: UseTasksOptions) {
|
||||
}
|
||||
|
||||
try {
|
||||
const updatedTask = normalizeTask(await api.updateTask(id, updates));
|
||||
const updatedTask = normalizeTask(await api.updateTask(id, updates, projectId));
|
||||
setTasks((prev) =>
|
||||
prev.map((t) => (t.id === id ? updatedTask : t))
|
||||
);
|
||||
@@ -275,26 +260,26 @@ export function useTasks(options?: UseTasksOptions) {
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}, []);
|
||||
}, [projectId]);
|
||||
|
||||
const archiveTask = useCallback(async (id: string): Promise<Task> => {
|
||||
const task = normalizeTask(await api.archiveTask(id));
|
||||
const task = normalizeTask(await api.archiveTask(id, projectId));
|
||||
setTasks((prev) =>
|
||||
prev.map((t) => (t.id === id ? task : t))
|
||||
);
|
||||
return task;
|
||||
}, []);
|
||||
}, [projectId]);
|
||||
|
||||
const unarchiveTask = useCallback(async (id: string): Promise<Task> => {
|
||||
const task = normalizeTask(await api.unarchiveTask(id));
|
||||
const task = normalizeTask(await api.unarchiveTask(id, projectId));
|
||||
setTasks((prev) =>
|
||||
prev.map((t) => (t.id === id ? task : t))
|
||||
);
|
||||
return task;
|
||||
}, []);
|
||||
}, [projectId]);
|
||||
|
||||
const archiveAllDone = useCallback(async (): Promise<Task[]> => {
|
||||
const archived = await api.archiveAllDone();
|
||||
const archived = await api.archiveAllDone(projectId);
|
||||
const normalized = archived.map(normalizeTask);
|
||||
setTasks((prev) =>
|
||||
prev.map((t) => {
|
||||
@@ -303,7 +288,7 @@ export function useTasks(options?: UseTasksOptions) {
|
||||
})
|
||||
);
|
||||
return normalized;
|
||||
}, []);
|
||||
}, [projectId]);
|
||||
|
||||
return { tasks, createTask, moveTask, deleteTask, mergeTask, retryTask, duplicateTask, updateTask, archiveTask, unarchiveTask, archiveAllDone };
|
||||
}
|
||||
|
||||
@@ -50,6 +50,12 @@ function generateTabId(): string {
|
||||
return `tab-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
|
||||
}
|
||||
|
||||
function isRelativeUrlFetchError(error: unknown): boolean {
|
||||
const message =
|
||||
error instanceof Error ? error.message : typeof error === "string" ? error : "";
|
||||
return message.includes("Failed to parse URL") || message.includes("Invalid URL");
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for managing multiple terminal sessions with localStorage persistence.
|
||||
*
|
||||
@@ -82,6 +88,7 @@ export function useTerminalSessions(): UseTerminalSessionsReturn {
|
||||
|
||||
// Track whether validation has completed
|
||||
const [isReady, setIsReady] = useState(false);
|
||||
const [serverAvailable, setServerAvailable] = useState(true);
|
||||
|
||||
// Persist tabs to localStorage whenever they change
|
||||
useEffect(() => {
|
||||
@@ -105,6 +112,7 @@ export function useTerminalSessions(): UseTerminalSessionsReturn {
|
||||
if (cancelled) return;
|
||||
|
||||
const validSessionIds = new Set(serverSessions.map((s) => s.id));
|
||||
setServerAvailable(true);
|
||||
|
||||
setTabs((currentTabs) => {
|
||||
if (cancelled) return currentTabs;
|
||||
@@ -143,7 +151,11 @@ export function useTerminalSessions(): UseTerminalSessionsReturn {
|
||||
} catch (err) {
|
||||
// Server listing failed - keep local tabs but mark as unverified
|
||||
// The WebSocket will fail to connect, which is acceptable
|
||||
console.warn("Failed to validate terminal sessions with server:", err);
|
||||
const relativeUrlError = isRelativeUrlFetchError(err);
|
||||
if (!relativeUrlError) {
|
||||
console.warn("Failed to validate terminal sessions with server:", err);
|
||||
}
|
||||
setServerAvailable(!relativeUrlError);
|
||||
// Still mark as ready so the UI can proceed
|
||||
setIsReady(true);
|
||||
}
|
||||
@@ -158,14 +170,18 @@ export function useTerminalSessions(): UseTerminalSessionsReturn {
|
||||
|
||||
// Auto-create first tab if no tabs exist after validation
|
||||
useEffect(() => {
|
||||
if (tabs.length === 0 && isReady) {
|
||||
if (tabs.length === 0 && isReady && serverAvailable) {
|
||||
// Small delay to avoid race condition with the validation effect
|
||||
const timeout = setTimeout(() => {
|
||||
createTabInternal().catch(console.error);
|
||||
createTabInternal().catch((err) => {
|
||||
if (!isRelativeUrlFetchError(err)) {
|
||||
console.error(err);
|
||||
}
|
||||
});
|
||||
}, 0);
|
||||
return () => clearTimeout(timeout);
|
||||
}
|
||||
}, [isReady, tabs.length]); // Run when ready or when tabs become empty
|
||||
}, [isReady, serverAvailable, tabs.length]); // Run when ready or when tabs become empty
|
||||
|
||||
/**
|
||||
* Internal create tab function (used for auto-creation and user-initiated creation)
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { EventEmitter, once } from "node:events";
|
||||
import http from "node:http";
|
||||
import { EventEmitter } from "node:events";
|
||||
import type { Task } from "@fusion/core";
|
||||
import { createServer } from "../server.js";
|
||||
import * as childProcess from "node:child_process";
|
||||
import * as fs from "node:fs";
|
||||
|
||||
vi.mock("node:child_process", async () => {
|
||||
const actual = await vi.importActual<typeof import("node:child_process")>("node:child_process");
|
||||
@@ -22,6 +18,8 @@ vi.mock("node:fs", async () => {
|
||||
};
|
||||
});
|
||||
|
||||
const childProcess = await import("node:child_process");
|
||||
const fs = await import("node:fs");
|
||||
const mockExecSync = vi.mocked(childProcess.execSync);
|
||||
const mockExistsSync = vi.mocked(fs.existsSync);
|
||||
|
||||
@@ -44,6 +42,14 @@ class MockStore extends EventEmitter {
|
||||
addTask(task: Task): void {
|
||||
this.tasks.set(task.id, task);
|
||||
}
|
||||
|
||||
getMissionStore() {
|
||||
return new EventEmitter();
|
||||
}
|
||||
|
||||
async listTasks(): Promise<Task[]> {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function createTask(overrides: Partial<Task> = {}): Task {
|
||||
@@ -64,31 +70,59 @@ function createTask(overrides: Partial<Task> = {}): Task {
|
||||
};
|
||||
}
|
||||
|
||||
async function requestSessionFiles(port: number, taskId = "FN-675"): Promise<{ status: number; body: any }> {
|
||||
return await new Promise((resolve, reject) => {
|
||||
const req = http.request(
|
||||
{
|
||||
hostname: "127.0.0.1",
|
||||
port,
|
||||
path: `/api/tasks/${taskId}/session-files`,
|
||||
method: "GET",
|
||||
},
|
||||
(res) => {
|
||||
let data = "";
|
||||
res.on("data", (chunk) => (data += chunk));
|
||||
res.on("end", () => resolve({ status: res.statusCode!, body: JSON.parse(data) }));
|
||||
},
|
||||
);
|
||||
req.on("error", reject);
|
||||
req.end();
|
||||
});
|
||||
async function getSessionFilesHandler(store: MockStore) {
|
||||
vi.resetModules();
|
||||
const { createApiRoutes } = await import("../routes.js");
|
||||
const router = createApiRoutes(store as any);
|
||||
const layer = (router as any).stack.find(
|
||||
(candidate: any) =>
|
||||
candidate.route?.path === "/tasks/:id/session-files" &&
|
||||
candidate.route?.methods?.get,
|
||||
);
|
||||
|
||||
if (!layer) {
|
||||
throw new Error("GET /tasks/:id/session-files route not found");
|
||||
}
|
||||
|
||||
return layer.route.stack[layer.route.stack.length - 1].handle as (req: any, res: any) => Promise<void>;
|
||||
}
|
||||
|
||||
function createMockResponse() {
|
||||
return {
|
||||
statusCode: 200,
|
||||
body: undefined as any,
|
||||
status(code: number) {
|
||||
this.statusCode = code;
|
||||
return this;
|
||||
},
|
||||
json(payload: any) {
|
||||
this.body = payload;
|
||||
return this;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function requestSessionFiles(store: MockStore, taskId = "FN-675"): Promise<{ status: number; body: any }> {
|
||||
const handler = await getSessionFilesHandler(store);
|
||||
return requestSessionFilesWithHandler(handler, taskId);
|
||||
}
|
||||
|
||||
async function requestSessionFilesWithHandler(
|
||||
handler: (req: any, res: any) => Promise<void>,
|
||||
taskId = "FN-675",
|
||||
): Promise<{ status: number; body: any }> {
|
||||
const req = { params: { id: taskId } };
|
||||
const res = createMockResponse();
|
||||
await handler(req, res);
|
||||
return { status: res.statusCode, body: res.body };
|
||||
}
|
||||
|
||||
describe("GET /api/tasks/:id/session-files", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.clearAllMocks();
|
||||
mockExistsSync.mockReturnValue(true);
|
||||
vi.useFakeTimers();
|
||||
vi.useFakeTimers({ toFake: ["Date", "setTimeout", "clearTimeout", "setInterval", "clearInterval"] });
|
||||
vi.setSystemTime(new Date("2026-04-01T12:00:00.000Z"));
|
||||
});
|
||||
|
||||
@@ -99,7 +133,7 @@ describe("GET /api/tasks/:id/session-files", () => {
|
||||
|
||||
it("uses baseCommitSha with double-dot syntax when available", async () => {
|
||||
const store = new MockStore();
|
||||
store.addTask(createTask({ baseCommitSha: "abc123" }));
|
||||
store.addTask(createTask({ id: "FN-675-base", baseCommitSha: "abc123" }));
|
||||
mockExecSync.mockImplementation((command) => {
|
||||
if (String(command) === "git diff --name-only abc123..HEAD") {
|
||||
return "src/a.ts\nsrc/b.ts\n" as any;
|
||||
@@ -107,25 +141,17 @@ describe("GET /api/tasks/:id/session-files", () => {
|
||||
throw new Error(`Unexpected command: ${String(command)}`);
|
||||
});
|
||||
|
||||
const app = createServer(store as any);
|
||||
const server = app.listen(0);
|
||||
await once(server, "listening");
|
||||
const port = (server.address() as { port: number }).port;
|
||||
|
||||
const response = await requestSessionFiles(port);
|
||||
const response = await requestSessionFiles(store, "FN-675-base");
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual(["src/a.ts", "src/b.ts"]);
|
||||
expect(mockExecSync).toHaveBeenCalledWith("git diff --name-only abc123..HEAD", expect.objectContaining({ cwd: "/tmp/fn-675" }));
|
||||
expect(mockExecSync).not.toHaveBeenCalledWith(expect.stringContaining("...HEAD"), expect.anything());
|
||||
|
||||
server.close();
|
||||
await once(server, "close");
|
||||
});
|
||||
|
||||
it("computes fallback base ref with merge-base and returns matching file list", async () => {
|
||||
const store = new MockStore();
|
||||
store.addTask(createTask({ baseCommitSha: undefined }));
|
||||
store.addTask(createTask({ id: "FN-675-merge-base", baseCommitSha: undefined }));
|
||||
mockExecSync.mockImplementation((command) => {
|
||||
if (String(command) === "git merge-base HEAD origin/main 2>/dev/null || git merge-base HEAD main") {
|
||||
return "mergebase123\n" as any;
|
||||
@@ -136,12 +162,7 @@ describe("GET /api/tasks/:id/session-files", () => {
|
||||
throw new Error(`Unexpected command: ${String(command)}`);
|
||||
});
|
||||
|
||||
const app = createServer(store as any);
|
||||
const server = app.listen(0);
|
||||
await once(server, "listening");
|
||||
const port = (server.address() as { port: number }).port;
|
||||
|
||||
const response = await requestSessionFiles(port);
|
||||
const response = await requestSessionFiles(store, "FN-675-merge-base");
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual([
|
||||
@@ -158,14 +179,11 @@ describe("GET /api/tasks/:id/session-files", () => {
|
||||
"git diff --name-only mergebase123..HEAD",
|
||||
expect.objectContaining({ cwd: "/tmp/fn-675" }),
|
||||
);
|
||||
|
||||
server.close();
|
||||
await once(server, "close");
|
||||
});
|
||||
|
||||
it("falls back to HEAD~1 when merge-base fails", async () => {
|
||||
const store = new MockStore();
|
||||
store.addTask(createTask({ baseCommitSha: undefined }));
|
||||
store.addTask(createTask({ id: "FN-675-head-parent", baseCommitSha: undefined }));
|
||||
mockExecSync.mockImplementation((command) => {
|
||||
if (String(command) === "git merge-base HEAD origin/main 2>/dev/null || git merge-base HEAD main") {
|
||||
throw new Error("merge-base failed");
|
||||
@@ -179,63 +197,40 @@ describe("GET /api/tasks/:id/session-files", () => {
|
||||
throw new Error(`Unexpected command: ${String(command)}`);
|
||||
});
|
||||
|
||||
const app = createServer(store as any);
|
||||
const server = app.listen(0);
|
||||
await once(server, "listening");
|
||||
const port = (server.address() as { port: number }).port;
|
||||
|
||||
const response = await requestSessionFiles(port);
|
||||
const response = await requestSessionFiles(store, "FN-675-head-parent");
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual(["src/only.ts"]);
|
||||
|
||||
server.close();
|
||||
await once(server, "close");
|
||||
});
|
||||
|
||||
it("returns empty array when worktree is missing", async () => {
|
||||
const store = new MockStore();
|
||||
store.addTask(createTask({ worktree: undefined }));
|
||||
store.addTask(createTask({ id: "FN-675-missing", worktree: undefined }));
|
||||
|
||||
const app = createServer(store as any);
|
||||
const server = app.listen(0);
|
||||
await once(server, "listening");
|
||||
const port = (server.address() as { port: number }).port;
|
||||
|
||||
const response = await requestSessionFiles(port);
|
||||
const response = await requestSessionFiles(store, "FN-675-missing");
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual([]);
|
||||
expect(mockExecSync).not.toHaveBeenCalled();
|
||||
|
||||
server.close();
|
||||
await once(server, "close");
|
||||
});
|
||||
|
||||
it("uses the 10-second cache before recomputing", async () => {
|
||||
const store = new MockStore();
|
||||
store.addTask(createTask({ baseCommitSha: "cachebase" }));
|
||||
store.addTask(createTask({ id: "FN-675-cache", baseCommitSha: "cachebase" }));
|
||||
mockExecSync.mockReturnValue("cached/file.ts\n" as any);
|
||||
const handler = await getSessionFilesHandler(store);
|
||||
|
||||
const app = createServer(store as any);
|
||||
const server = app.listen(0);
|
||||
await once(server, "listening");
|
||||
const port = (server.address() as { port: number }).port;
|
||||
|
||||
const first = await requestSessionFiles(port);
|
||||
const second = await requestSessionFiles(port);
|
||||
const first = await requestSessionFilesWithHandler(handler, "FN-675-cache");
|
||||
const second = await requestSessionFilesWithHandler(handler, "FN-675-cache");
|
||||
|
||||
expect(first.body).toEqual(["cached/file.ts"]);
|
||||
expect(second.body).toEqual(["cached/file.ts"]);
|
||||
expect(mockExecSync).toHaveBeenCalledTimes(1);
|
||||
|
||||
vi.advanceTimersByTime(10001);
|
||||
const third = await requestSessionFiles(port);
|
||||
const third = await requestSessionFilesWithHandler(handler, "FN-675-cache");
|
||||
|
||||
expect(third.body).toEqual(["cached/file.ts"]);
|
||||
expect(mockExecSync).toHaveBeenCalledTimes(2);
|
||||
|
||||
server.close();
|
||||
await once(server, "close");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -45,6 +45,7 @@ function createMockMissionStore() {
|
||||
description: input.description,
|
||||
status: "planning",
|
||||
interviewState: "not_started",
|
||||
autoAdvance: false,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
@@ -228,6 +229,40 @@ function buildApp() {
|
||||
}
|
||||
|
||||
describe("Mission API", () => {
|
||||
describe("POST /api/missions", () => {
|
||||
it("should create a mission with the default auto-advance state", async () => {
|
||||
const { app } = buildApp();
|
||||
|
||||
const res = await request(
|
||||
app,
|
||||
"POST",
|
||||
"/api/missions",
|
||||
JSON.stringify({ title: "New Mission", description: "Ship it" }),
|
||||
{ "content-type": "application/json" }
|
||||
);
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.title).toBe("New Mission");
|
||||
expect(res.body.autoAdvance).toBe(false);
|
||||
});
|
||||
|
||||
it("should persist auto-advance when provided during creation", async () => {
|
||||
const { app, missionStore } = buildApp();
|
||||
|
||||
const res = await request(
|
||||
app,
|
||||
"POST",
|
||||
"/api/missions",
|
||||
JSON.stringify({ title: "Mission", autoAdvance: true }),
|
||||
{ "content-type": "application/json" }
|
||||
);
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.autoAdvance).toBe(true);
|
||||
expect(missionStore.updateMission).toHaveBeenCalledWith(res.body.id, { autoAdvance: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /api/missions", () => {
|
||||
it("should list all missions", async () => {
|
||||
const { app, missionStore } = buildApp();
|
||||
@@ -268,6 +303,50 @@ describe("Mission API", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("PATCH /api/missions/:missionId", () => {
|
||||
it("should update mission status and auto-advance", async () => {
|
||||
const { app, missionStore } = buildApp();
|
||||
const mission = missionStore.createMission({ title: "Test Mission" });
|
||||
|
||||
const res = await request(
|
||||
app,
|
||||
"PATCH",
|
||||
`/api/missions/${mission.id}`,
|
||||
JSON.stringify({ status: "active", autoAdvance: true }),
|
||||
{ "content-type": "application/json" }
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.status).toBe("active");
|
||||
expect(res.body.autoAdvance).toBe(true);
|
||||
expect(missionStore.updateMission).toHaveBeenCalledWith(mission.id, {
|
||||
status: "active",
|
||||
autoAdvance: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("should reject non-boolean auto-advance values", async () => {
|
||||
const { app, missionStore } = buildApp();
|
||||
const mission = missionStore.createMission({ title: "Test Mission" });
|
||||
|
||||
app.use((err: Error, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
|
||||
res.status(500).json({ error: err.message });
|
||||
});
|
||||
|
||||
const res = await request(
|
||||
app,
|
||||
"PATCH",
|
||||
`/api/missions/${mission.id}`,
|
||||
JSON.stringify({ autoAdvance: "yes" }),
|
||||
{ "content-type": "application/json" }
|
||||
);
|
||||
|
||||
expect(res.status).toBe(500);
|
||||
expect(res.body.error).toContain("autoAdvance must be a boolean");
|
||||
expect(missionStore.updateMission).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("DELETE /api/missions/:missionId", () => {
|
||||
it("should delete mission", async () => {
|
||||
const { app, missionStore } = buildApp();
|
||||
|
||||
@@ -13,7 +13,8 @@
|
||||
*/
|
||||
|
||||
import { Router, type Request, type Response, type NextFunction } from "express";
|
||||
import type { TaskStore } from "@fusion/core";
|
||||
import { AsyncLocalStorage } from "node:async_hooks";
|
||||
import { TaskStore } from "@fusion/core";
|
||||
import type {
|
||||
Mission,
|
||||
Milestone,
|
||||
@@ -103,6 +104,13 @@ function validateStringArray(arr: unknown, fieldName: string): string[] {
|
||||
return arr;
|
||||
}
|
||||
|
||||
function validateBoolean(value: unknown, fieldName: string): boolean {
|
||||
if (typeof value !== "boolean") {
|
||||
throw new Error(`${fieldName} must be a boolean`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function validateOrderedIds(body: unknown): string[] {
|
||||
if (!body || typeof body !== "object") {
|
||||
throw new Error("Request body must contain orderedIds array");
|
||||
@@ -131,7 +139,43 @@ function asyncHandler(fn: (req: TypedRequest, res: Response, next: NextFunction)
|
||||
|
||||
export function createMissionRouter(store: TaskStore): Router {
|
||||
const router = Router();
|
||||
const missionStore = store.getMissionStore();
|
||||
const requestContext = new AsyncLocalStorage<ReturnType<TaskStore["getMissionStore"]>>();
|
||||
|
||||
function getProjectIdFromRequest(req: Request): string | undefined {
|
||||
if (typeof req.query.projectId === "string" && req.query.projectId.trim()) {
|
||||
return req.query.projectId;
|
||||
}
|
||||
if (req.body && typeof req.body === "object" && typeof req.body.projectId === "string" && req.body.projectId.trim()) {
|
||||
return req.body.projectId;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function getScopedMissionStore() {
|
||||
const missionStore = requestContext.getStore();
|
||||
if (!missionStore) {
|
||||
return store.getMissionStore();
|
||||
}
|
||||
return missionStore;
|
||||
}
|
||||
|
||||
const missionStore = new Proxy({} as ReturnType<TaskStore["getMissionStore"]>, {
|
||||
get(_target, property) {
|
||||
const target = getScopedMissionStore();
|
||||
const value = (target as unknown as Record<PropertyKey, unknown>)[property];
|
||||
return typeof value === "function" ? value.bind(target) : value;
|
||||
},
|
||||
});
|
||||
|
||||
router.use(async (req, _res, next) => {
|
||||
try {
|
||||
const projectId = getProjectIdFromRequest(req);
|
||||
const scopedStore = projectId ? await TaskStore.getOrCreateForProject(projectId) : store;
|
||||
requestContext.run(scopedStore.getMissionStore(), next);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// ── Mission Endpoints ─────────────────────────────────────────────────────
|
||||
|
||||
@@ -156,7 +200,7 @@ export function createMissionRouter(store: TaskStore): Router {
|
||||
router.post(
|
||||
"/",
|
||||
asyncHandler(async (req, res) => {
|
||||
const { title, description } = req.body;
|
||||
const { title, description, autoAdvance } = req.body;
|
||||
|
||||
const validatedTitle = validateTitle(title);
|
||||
const validatedDescription = validateDescription(description);
|
||||
@@ -167,6 +211,15 @@ export function createMissionRouter(store: TaskStore): Router {
|
||||
};
|
||||
|
||||
const mission = missionStore.createMission(input);
|
||||
|
||||
if (autoAdvance !== undefined) {
|
||||
const updatedMission = missionStore.updateMission(mission.id, {
|
||||
autoAdvance: validateBoolean(autoAdvance, "autoAdvance"),
|
||||
});
|
||||
res.status(201).json(updatedMission);
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(201).json(mission);
|
||||
})
|
||||
);
|
||||
@@ -203,7 +256,7 @@ export function createMissionRouter(store: TaskStore): Router {
|
||||
"/:missionId",
|
||||
asyncHandler(async (req, res) => {
|
||||
const { missionId } = req.params;
|
||||
const { title, description, status } = req.body;
|
||||
const { title, description, status, autoAdvance } = req.body;
|
||||
|
||||
if (!validateMissionId(missionId)) {
|
||||
res.status(400).json({ error: "Invalid mission ID format" });
|
||||
@@ -221,6 +274,9 @@ export function createMissionRouter(store: TaskStore): Router {
|
||||
if (status !== undefined) {
|
||||
updates.status = validateStatus(status, MISSION_STATUSES) as MissionStatus;
|
||||
}
|
||||
if (autoAdvance !== undefined) {
|
||||
updates.autoAdvance = validateBoolean(autoAdvance, "autoAdvance");
|
||||
}
|
||||
|
||||
if (Object.keys(updates).length === 0) {
|
||||
res.status(400).json({ error: "No valid fields to update" });
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -78,30 +78,12 @@ async function REQUEST(
|
||||
return { status: res.status, body: res.body };
|
||||
}
|
||||
|
||||
// Script store mock - module-level
|
||||
const mockScriptStore = {
|
||||
getScripts: vi.fn(() => ({})),
|
||||
setScript: vi.fn(),
|
||||
removeScript: vi.fn(),
|
||||
save: vi.fn().mockResolvedValue(undefined),
|
||||
hasScript: vi.fn(() => false),
|
||||
};
|
||||
|
||||
vi.mock("./script-store.js", () => ({
|
||||
loadScriptStore: vi.fn(() => Promise.resolve(mockScriptStore)),
|
||||
resetScriptStore: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("Scripts routes", () => {
|
||||
let store: TaskStore;
|
||||
|
||||
beforeEach(() => {
|
||||
store = createMockStore();
|
||||
vi.clearAllMocks();
|
||||
mockScriptStore.getScripts.mockReturnValue({});
|
||||
mockScriptStore.hasScript.mockReturnValue(false);
|
||||
mockScriptStore.setScript.mockImplementation(() => {});
|
||||
mockScriptStore.removeScript.mockImplementation(() => {});
|
||||
});
|
||||
|
||||
function buildApp() {
|
||||
@@ -112,7 +94,9 @@ describe("Scripts routes", () => {
|
||||
}
|
||||
|
||||
it("GET /api/scripts returns all scripts from script store", async () => {
|
||||
mockScriptStore.getScripts.mockReturnValueOnce({ build: "pnpm build", test: "pnpm test" });
|
||||
vi.mocked(store.getSettings).mockResolvedValueOnce({
|
||||
scripts: { build: "pnpm build", test: "pnpm test" },
|
||||
} as any);
|
||||
|
||||
const res = await GET(buildApp(), "/api/scripts");
|
||||
|
||||
@@ -121,7 +105,7 @@ describe("Scripts routes", () => {
|
||||
});
|
||||
|
||||
it("GET /api/scripts returns empty object when no scripts", async () => {
|
||||
mockScriptStore.getScripts.mockReturnValueOnce({});
|
||||
vi.mocked(store.getSettings).mockResolvedValueOnce({ scripts: {} } as any);
|
||||
|
||||
const res = await GET(buildApp(), "/api/scripts");
|
||||
|
||||
@@ -130,8 +114,8 @@ describe("Scripts routes", () => {
|
||||
});
|
||||
|
||||
it("POST /api/scripts creates a new script and returns updated scripts", async () => {
|
||||
mockScriptStore.hasScript.mockReturnValueOnce(false);
|
||||
mockScriptStore.getScripts.mockReturnValueOnce({ test: "pnpm test" });
|
||||
vi.mocked(store.getSettings).mockResolvedValueOnce({ scripts: { test: "pnpm test" } } as any);
|
||||
vi.mocked(store.updateSettings).mockResolvedValueOnce({} as any);
|
||||
|
||||
const res = await REQUEST(
|
||||
buildApp(),
|
||||
@@ -142,8 +126,10 @@ describe("Scripts routes", () => {
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(mockScriptStore.setScript).toHaveBeenCalledWith("build", "pnpm build");
|
||||
expect(mockScriptStore.save).toHaveBeenCalled();
|
||||
expect(store.updateSettings).toHaveBeenCalledWith({
|
||||
scripts: { test: "pnpm test", build: "pnpm build" },
|
||||
});
|
||||
expect(res.body).toEqual({ test: "pnpm test", build: "pnpm build" });
|
||||
});
|
||||
|
||||
it("POST /api/scripts returns 400 for missing name", async () => {
|
||||
@@ -173,8 +159,8 @@ describe("Scripts routes", () => {
|
||||
});
|
||||
|
||||
it("POST /api/scripts creates script with any name", async () => {
|
||||
mockScriptStore.hasScript.mockReturnValueOnce(false);
|
||||
mockScriptStore.getScripts.mockReturnValueOnce({});
|
||||
vi.mocked(store.getSettings).mockResolvedValueOnce({ scripts: {} } as any);
|
||||
vi.mocked(store.updateSettings).mockResolvedValueOnce({} as any);
|
||||
|
||||
// The actual implementation accepts any name
|
||||
const res = await REQUEST(
|
||||
@@ -186,28 +172,34 @@ describe("Scripts routes", () => {
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(mockScriptStore.setScript).toHaveBeenCalledWith("my-script", "echo hi");
|
||||
expect(store.updateSettings).toHaveBeenCalledWith({
|
||||
scripts: { "my-script": "echo hi" },
|
||||
});
|
||||
});
|
||||
|
||||
it("DELETE /api/scripts/:name removes script and returns updated scripts", async () => {
|
||||
mockScriptStore.hasScript.mockReturnValueOnce(true);
|
||||
mockScriptStore.getScripts.mockReturnValueOnce({ test: "pnpm test" });
|
||||
vi.mocked(store.getSettings).mockResolvedValueOnce({
|
||||
scripts: { build: "pnpm build", test: "pnpm test" },
|
||||
} as any);
|
||||
vi.mocked(store.updateSettings).mockResolvedValueOnce({} as any);
|
||||
|
||||
const res = await REQUEST(buildApp(), "DELETE", "/api/scripts/build");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(mockScriptStore.removeScript).toHaveBeenCalledWith("build");
|
||||
expect(mockScriptStore.save).toHaveBeenCalled();
|
||||
expect(store.updateSettings).toHaveBeenCalledWith({
|
||||
scripts: { test: "pnpm test" },
|
||||
});
|
||||
expect(res.body).toEqual({ test: "pnpm test" });
|
||||
});
|
||||
|
||||
it("DELETE /api/scripts/:name removes script regardless of name format", async () => {
|
||||
mockScriptStore.hasScript.mockReturnValueOnce(true);
|
||||
mockScriptStore.getScripts.mockReturnValueOnce({});
|
||||
vi.mocked(store.getSettings).mockResolvedValueOnce({ scripts: { build: "pnpm build" } } as any);
|
||||
vi.mocked(store.updateSettings).mockResolvedValueOnce({} as any);
|
||||
|
||||
// The actual implementation doesn't validate names, it just removes
|
||||
const res = await REQUEST(buildApp(), "DELETE", "/api/scripts/build");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(mockScriptStore.removeScript).toHaveBeenCalledWith("build");
|
||||
expect(store.updateSettings).toHaveBeenCalledWith({ scripts: {} });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -106,11 +106,26 @@ export function createServer(store: TaskStore, options?: ServerOptions): ReturnT
|
||||
app.use(express.static(clientDir));
|
||||
|
||||
// Rate limiting — stricter limit on SSE connections
|
||||
app.get("/api/events", rateLimit(RATE_LIMITS.sse), createSSE(store));
|
||||
app.get("/api/events", rateLimit(RATE_LIMITS.sse), async (req, res) => {
|
||||
const projectId = typeof req.query.projectId === "string" ? req.query.projectId : undefined;
|
||||
if (!projectId) {
|
||||
createSSE(store, store.getMissionStore())(req, res);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const { TaskStore: TaskStoreClass } = await import("@fusion/core");
|
||||
const scopedStore = await TaskStoreClass.getOrCreateForProject(projectId);
|
||||
createSSE(scopedStore, scopedStore.getMissionStore())(req, res);
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ error: err.message ?? "Failed to open project event stream" });
|
||||
}
|
||||
});
|
||||
|
||||
// Per-task SSE endpoint for live agent log streaming
|
||||
app.get("/api/tasks/:id/logs/stream", (req, res) => {
|
||||
const taskId = req.params.id;
|
||||
const projectId = typeof req.query.projectId === "string" ? req.query.projectId : undefined;
|
||||
|
||||
res.setHeader("Content-Type", "text/event-stream");
|
||||
res.setHeader("Cache-Control", "no-cache");
|
||||
@@ -120,12 +135,28 @@ export function createServer(store: TaskStore, options?: ServerOptions): ReturnT
|
||||
|
||||
res.write(": connected\n\n");
|
||||
|
||||
const onAgentLog = (entry: { taskId: string; text: string; type: string; timestamp: string }) => {
|
||||
if (entry.taskId !== taskId) return;
|
||||
res.write(`event: agent:log\ndata: ${JSON.stringify(entry)}\n\n`);
|
||||
};
|
||||
let activeStore = store;
|
||||
let detachListener: (() => void) | null = null;
|
||||
|
||||
store.on("agent:log", onAgentLog);
|
||||
void (async () => {
|
||||
if (projectId) {
|
||||
const { TaskStore: TaskStoreClass } = await import("@fusion/core");
|
||||
activeStore = await TaskStoreClass.getOrCreateForProject(projectId);
|
||||
}
|
||||
|
||||
const onAgentLog = (entry: { taskId: string; text: string; type: string; timestamp: string }) => {
|
||||
if (entry.taskId !== taskId) return;
|
||||
res.write(`event: agent:log\ndata: ${JSON.stringify(entry)}\n\n`);
|
||||
};
|
||||
|
||||
activeStore.on("agent:log", onAgentLog);
|
||||
detachListener = () => {
|
||||
activeStore.off("agent:log", onAgentLog);
|
||||
};
|
||||
})().catch(() => {
|
||||
res.write("event: error\ndata: \"Failed to attach log stream\"\n\n");
|
||||
res.end();
|
||||
});
|
||||
|
||||
const heartbeat = setInterval(() => {
|
||||
res.write(": heartbeat\n\n");
|
||||
@@ -133,7 +164,7 @@ export function createServer(store: TaskStore, options?: ServerOptions): ReturnT
|
||||
|
||||
req.on("close", () => {
|
||||
clearInterval(heartbeat);
|
||||
store.off("agent:log", onAgentLog);
|
||||
detachListener?.();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Request, Response } from "express";
|
||||
import type { TaskStore } from "@fusion/core";
|
||||
import type { TaskStore, MissionStore } from "@fusion/core";
|
||||
|
||||
let activeConnections = 0;
|
||||
|
||||
@@ -8,7 +8,7 @@ export function getActiveSSEConnections(): number {
|
||||
return activeConnections;
|
||||
}
|
||||
|
||||
export function createSSE(store: TaskStore) {
|
||||
export function createSSE(store: TaskStore, missionStore?: MissionStore) {
|
||||
return (_req: Request, res: Response) => {
|
||||
res.setHeader("Content-Type", "text/event-stream");
|
||||
res.setHeader("Cache-Control", "no-cache");
|
||||
@@ -43,6 +43,67 @@ export function createSSE(store: TaskStore) {
|
||||
store.on("task:deleted", onDeleted);
|
||||
store.on("task:merged", onMerged);
|
||||
|
||||
// Mission store event listeners (only wired up when missionStore is provided)
|
||||
const onMissionCreated = (data: any) => {
|
||||
res.write(`event: mission:created\ndata: ${JSON.stringify(data)}\n\n`);
|
||||
};
|
||||
const onMissionUpdated = (data: any) => {
|
||||
res.write(`event: mission:updated\ndata: ${JSON.stringify(data)}\n\n`);
|
||||
};
|
||||
const onMissionDeleted = (data: any) => {
|
||||
res.write(`event: mission:deleted\ndata: ${JSON.stringify(data)}\n\n`);
|
||||
};
|
||||
const onMilestoneCreated = (data: any) => {
|
||||
res.write(`event: milestone:created\ndata: ${JSON.stringify(data)}\n\n`);
|
||||
};
|
||||
const onMilestoneUpdated = (data: any) => {
|
||||
res.write(`event: milestone:updated\ndata: ${JSON.stringify(data)}\n\n`);
|
||||
};
|
||||
const onMilestoneDeleted = (data: any) => {
|
||||
res.write(`event: milestone:deleted\ndata: ${JSON.stringify(data)}\n\n`);
|
||||
};
|
||||
const onSliceCreated = (data: any) => {
|
||||
res.write(`event: slice:created\ndata: ${JSON.stringify(data)}\n\n`);
|
||||
};
|
||||
const onSliceUpdated = (data: any) => {
|
||||
res.write(`event: slice:updated\ndata: ${JSON.stringify(data)}\n\n`);
|
||||
};
|
||||
const onSliceDeleted = (data: any) => {
|
||||
res.write(`event: slice:deleted\ndata: ${JSON.stringify(data)}\n\n`);
|
||||
};
|
||||
const onSliceActivated = (data: any) => {
|
||||
res.write(`event: slice:activated\ndata: ${JSON.stringify(data)}\n\n`);
|
||||
};
|
||||
const onFeatureCreated = (data: any) => {
|
||||
res.write(`event: feature:created\ndata: ${JSON.stringify(data)}\n\n`);
|
||||
};
|
||||
const onFeatureUpdated = (data: any) => {
|
||||
res.write(`event: feature:updated\ndata: ${JSON.stringify(data)}\n\n`);
|
||||
};
|
||||
const onFeatureDeleted = (data: any) => {
|
||||
res.write(`event: feature:deleted\ndata: ${JSON.stringify(data)}\n\n`);
|
||||
};
|
||||
const onFeatureLinked = (data: any) => {
|
||||
res.write(`event: feature:linked\ndata: ${JSON.stringify(data)}\n\n`);
|
||||
};
|
||||
|
||||
if (missionStore) {
|
||||
missionStore.on("mission:created", onMissionCreated);
|
||||
missionStore.on("mission:updated", onMissionUpdated);
|
||||
missionStore.on("mission:deleted", onMissionDeleted);
|
||||
missionStore.on("milestone:created", onMilestoneCreated);
|
||||
missionStore.on("milestone:updated", onMilestoneUpdated);
|
||||
missionStore.on("milestone:deleted", onMilestoneDeleted);
|
||||
missionStore.on("slice:created", onSliceCreated);
|
||||
missionStore.on("slice:updated", onSliceUpdated);
|
||||
missionStore.on("slice:deleted", onSliceDeleted);
|
||||
missionStore.on("slice:activated", onSliceActivated);
|
||||
missionStore.on("feature:created", onFeatureCreated);
|
||||
missionStore.on("feature:updated", onFeatureUpdated);
|
||||
missionStore.on("feature:deleted", onFeatureDeleted);
|
||||
missionStore.on("feature:linked", onFeatureLinked);
|
||||
}
|
||||
|
||||
// Heartbeat every 30s to keep connection alive
|
||||
const heartbeat = setInterval(() => {
|
||||
res.write(": heartbeat\n\n");
|
||||
@@ -56,6 +117,22 @@ export function createSSE(store: TaskStore) {
|
||||
store.off("task:updated", onUpdated);
|
||||
store.off("task:deleted", onDeleted);
|
||||
store.off("task:merged", onMerged);
|
||||
if (missionStore) {
|
||||
missionStore.off("mission:created", onMissionCreated);
|
||||
missionStore.off("mission:updated", onMissionUpdated);
|
||||
missionStore.off("mission:deleted", onMissionDeleted);
|
||||
missionStore.off("milestone:created", onMilestoneCreated);
|
||||
missionStore.off("milestone:updated", onMilestoneUpdated);
|
||||
missionStore.off("milestone:deleted", onMilestoneDeleted);
|
||||
missionStore.off("slice:created", onSliceCreated);
|
||||
missionStore.off("slice:updated", onSliceUpdated);
|
||||
missionStore.off("slice:deleted", onSliceDeleted);
|
||||
missionStore.off("slice:activated", onSliceActivated);
|
||||
missionStore.off("feature:created", onFeatureCreated);
|
||||
missionStore.off("feature:updated", onFeatureUpdated);
|
||||
missionStore.off("feature:deleted", onFeatureDeleted);
|
||||
missionStore.off("feature:linked", onFeatureLinked);
|
||||
}
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
@@ -509,7 +509,16 @@ export class TerminalService extends EventEmitter {
|
||||
|
||||
// Flush buffered output to clients (throttled)
|
||||
const flushOutput = () => {
|
||||
if (session.outputBuffer.length === 0) return;
|
||||
// Guard against firing after session was killed
|
||||
if (!this.sessions.has(id)) {
|
||||
session.flushTimeout = null;
|
||||
return;
|
||||
}
|
||||
|
||||
if (session.outputBuffer.length === 0) {
|
||||
session.flushTimeout = null;
|
||||
return;
|
||||
}
|
||||
|
||||
let dataToSend = session.outputBuffer;
|
||||
if (dataToSend.length > OUTPUT_BATCH_SIZE) {
|
||||
@@ -527,16 +536,18 @@ export class TerminalService extends EventEmitter {
|
||||
|
||||
// Forward data events with throttling
|
||||
ptyProcess.onData((data: string) => {
|
||||
if (session.resizeInProgress) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Append to scrollback buffer
|
||||
// Always append to scrollback buffer so no output is lost
|
||||
session.scrollbackBuffer += data;
|
||||
if (session.scrollbackBuffer.length > MAX_SCROLLBACK_SIZE) {
|
||||
session.scrollbackBuffer = session.scrollbackBuffer.slice(-MAX_SCROLLBACK_SIZE);
|
||||
}
|
||||
|
||||
// During resize, buffer to scrollback only — suppress delivery to avoid
|
||||
// rendering artifacts, but don't drop the data entirely
|
||||
if (session.resizeInProgress) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Buffer output for throttled delivery
|
||||
session.outputBuffer += data;
|
||||
|
||||
@@ -548,6 +559,15 @@ export class TerminalService extends EventEmitter {
|
||||
// Handle exit
|
||||
ptyProcess.onExit(({ exitCode }: { exitCode: number; signal?: number }) => {
|
||||
console.info(`Session exited with code ${exitCode ?? 0} (${id})`);
|
||||
// Clean up timers before removing session
|
||||
if (session.flushTimeout) {
|
||||
clearTimeout(session.flushTimeout);
|
||||
session.flushTimeout = null;
|
||||
}
|
||||
if (session.resizeDebounceTimeout) {
|
||||
clearTimeout(session.resizeDebounceTimeout);
|
||||
session.resizeDebounceTimeout = null;
|
||||
}
|
||||
this.sessions.delete(id);
|
||||
this.exitCallbacks.forEach((cb) => cb(id, exitCode ?? 0));
|
||||
this.emit("exit", id, exitCode ?? 0);
|
||||
@@ -661,6 +681,15 @@ export class TerminalService extends EventEmitter {
|
||||
setTimeout(() => {
|
||||
if (this.sessions.has(sessionId)) {
|
||||
console.info(`Session ${sessionId} still alive after SIGTERM, sending SIGKILL`);
|
||||
// Clean up timers before removing session
|
||||
if (session.flushTimeout) {
|
||||
clearTimeout(session.flushTimeout);
|
||||
session.flushTimeout = null;
|
||||
}
|
||||
if (session.resizeDebounceTimeout) {
|
||||
clearTimeout(session.resizeDebounceTimeout);
|
||||
session.resizeDebounceTimeout = null;
|
||||
}
|
||||
try {
|
||||
this.killPtyProcess(session.pty, "SIGKILL");
|
||||
} catch {
|
||||
@@ -756,6 +785,11 @@ export class TerminalService extends EventEmitter {
|
||||
try {
|
||||
if (session.flushTimeout) {
|
||||
clearTimeout(session.flushTimeout);
|
||||
session.flushTimeout = null;
|
||||
}
|
||||
if (session.resizeDebounceTimeout) {
|
||||
clearTimeout(session.resizeDebounceTimeout);
|
||||
session.resizeDebounceTimeout = null;
|
||||
}
|
||||
this.killPtyProcess(session.pty);
|
||||
} catch {
|
||||
@@ -775,6 +809,10 @@ export function getTerminalService(projectRoot?: string, maxSessions?: number):
|
||||
if (!projectRoot) {
|
||||
throw new Error("TerminalService requires projectRoot for initialization");
|
||||
}
|
||||
// Clean up old instance to avoid leaking PTY processes
|
||||
if (terminalService) {
|
||||
terminalService.cleanup();
|
||||
}
|
||||
terminalService = new TerminalService(projectRoot, maxSessions);
|
||||
initializedRoot = projectRoot;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user