fix(FN-000): scope dashboard project flows

This commit is contained in:
gsxdsm
2026-04-02 17:51:04 -07:00
parent bf12c22efb
commit 05f2114743
63 changed files with 1332 additions and 904 deletions

View File

@@ -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

View File

@@ -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",
});
}

View File

@@ -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) {

View File

@@ -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>
);
}
}

View File

@@ -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}
/>

View File

@@ -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}

View File

@@ -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;

View File

@@ -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}

View File

@@ -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}
/>
)}

View File

@@ -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);
}

View File

@@ -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(

View File

@@ -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);

View File

@@ -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

View File

@@ -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);
}

View File

@@ -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);

View File

@@ -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) {

View File

@@ -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) {

View File

@@ -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(() => {

View File

@@ -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) {

View File

@@ -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 }));
}}
/>

View File

@@ -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();
});

View File

@@ -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();

View File

@@ -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",

View File

@@ -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(() => {

View File

@@ -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();

View File

@@ -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) {

View File

@@ -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}

View File

@@ -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 {

View File

@@ -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

View File

@@ -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}

View File

@@ -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);
});
});

View File

@@ -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);
});
});
});

View File

@@ -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(

View File

@@ -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 />);

View File

@@ -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} />);

View File

@@ -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);

View File

@@ -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);

View File

@@ -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");
});
});

View File

@@ -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 () => {

View File

@@ -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);
});

View File

@@ -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);
});
});
});

View File

@@ -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);
});
});

View File

@@ -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");
});
});

View File

@@ -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");
});

View File

@@ -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);
});

View File

@@ -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 () => {

View File

@@ -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 () => {

View File

@@ -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");

View File

@@ -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([]), []);

View File

@@ -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,

View File

@@ -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 };
}

View File

@@ -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(() => {

View File

@@ -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 };
}

View File

@@ -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 };
}

View File

@@ -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)

View File

@@ -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");
});
});

View File

@@ -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();

View File

@@ -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

View File

@@ -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: {} });
});
});

View File

@@ -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?.();
});
});

View File

@@ -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);
}
});
};
}

View File

@@ -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;
}