fix(FN-000): scope dashboard project flows
This commit is contained in:
@@ -72,7 +72,7 @@ describe("useAgentLogs", () => {
|
||||
expect(result.current.entries).toEqual(historicalLogs);
|
||||
});
|
||||
|
||||
expect(mockFetchAgentLogs).toHaveBeenCalledWith("FN-001");
|
||||
expect(mockFetchAgentLogs).toHaveBeenCalledWith("FN-001", undefined);
|
||||
expect(MockEventSource.instances).toHaveLength(1);
|
||||
expect(MockEventSource.instances[0].url).toBe("/api/tasks/FN-001/logs/stream");
|
||||
});
|
||||
|
||||
@@ -41,7 +41,7 @@ describe("useBatchBadgeFetch", () => {
|
||||
await result.current.fetchBatch(["FN-001"]);
|
||||
});
|
||||
|
||||
expect(mockFetchBatchStatus).toHaveBeenCalledWith(["FN-001"]);
|
||||
expect(mockFetchBatchStatus).toHaveBeenCalledWith(["FN-001"], undefined);
|
||||
expect(mockFetchBatchStatus).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ describe("useChangedFiles", () => {
|
||||
expect(result.current.error).toBeNull();
|
||||
expect(result.current.files).toHaveLength(2);
|
||||
expect(result.current.selectedFile?.path).toBe("src/a.ts");
|
||||
expect(mockFetchTaskFileDiffs).toHaveBeenCalledWith("KB-651");
|
||||
expect(mockFetchTaskFileDiffs).toHaveBeenCalledWith("KB-651", undefined);
|
||||
});
|
||||
|
||||
it("does not fetch for tasks without worktrees or inactive columns", async () => {
|
||||
|
||||
@@ -26,7 +26,7 @@ describe("useSessionFiles", () => {
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
|
||||
expect(result.current.files).toEqual(["src/a.ts", "src/b.ts"]);
|
||||
expect(mockFetchSessionFiles).toHaveBeenCalledWith("FN-123");
|
||||
expect(mockFetchSessionFiles).toHaveBeenCalledWith("FN-123", undefined);
|
||||
});
|
||||
|
||||
it("does not fetch for tasks without worktrees or inactive columns", async () => {
|
||||
|
||||
@@ -676,7 +676,7 @@ describe("useTasks", () => {
|
||||
expect(mockUpdateTask).toHaveBeenCalledWith("FN-001", {
|
||||
title: "New Title",
|
||||
description: "New Description",
|
||||
});
|
||||
}, undefined);
|
||||
expect(returnedTask).toEqual(updatedTask);
|
||||
expect(result.current.tasks[0].title).toBe("New Title");
|
||||
expect(result.current.tasks[0].description).toBe("New Description");
|
||||
|
||||
@@ -21,7 +21,7 @@ function capLogEntries(entries: AgentLogEntry[]): AgentLogEntry[] {
|
||||
* When `enabled` becomes false or the component unmounts, the EventSource
|
||||
* is closed to avoid unnecessary SSE connections.
|
||||
*/
|
||||
export function useAgentLogs(taskId: string | null, enabled: boolean) {
|
||||
export function useAgentLogs(taskId: string | null, enabled: boolean, projectId?: string) {
|
||||
const [entries, setEntries] = useState<AgentLogEntry[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const eventSourceRef = useRef<EventSource | null>(null);
|
||||
@@ -45,7 +45,7 @@ export function useAgentLogs(taskId: string | null, enabled: boolean) {
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const historical = await fetchAgentLogs(currentTaskId);
|
||||
const historical = await fetchAgentLogs(currentTaskId, projectId);
|
||||
if (cancelled) return;
|
||||
setEntries(capLogEntries(historical));
|
||||
} catch {
|
||||
@@ -56,7 +56,8 @@ export function useAgentLogs(taskId: string | null, enabled: boolean) {
|
||||
}
|
||||
|
||||
// Open SSE connection for live updates
|
||||
const es = new EventSource(`/api/tasks/${currentTaskId}/logs/stream`);
|
||||
const query = projectId ? `?projectId=${encodeURIComponent(projectId)}` : "";
|
||||
const es = new EventSource(`/api/tasks/${currentTaskId}/logs/stream${query}`);
|
||||
eventSourceRef.current = es;
|
||||
|
||||
es.addEventListener("agent:log", (e) => {
|
||||
@@ -79,7 +80,7 @@ export function useAgentLogs(taskId: string | null, enabled: boolean) {
|
||||
eventSourceRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [taskId, enabled]);
|
||||
}, [taskId, enabled, projectId]);
|
||||
|
||||
const clear = useCallback(() => setEntries([]), []);
|
||||
|
||||
|
||||
@@ -12,13 +12,17 @@ const batchBadgeStore = {
|
||||
/** Maximum age of cached batch data in milliseconds (5 seconds) */
|
||||
const CACHE_MAX_AGE_MS = 5000;
|
||||
|
||||
function getScopedTaskKey(taskId: string, projectId?: string): string {
|
||||
return projectId ? `${projectId}::${taskId}` : taskId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if fresh batch data exists for a task ID.
|
||||
* @param taskId - The task ID to check
|
||||
* @returns The cached data if fresh, undefined otherwise
|
||||
*/
|
||||
export function getFreshBatchData(taskId: string): { result: BatchStatusResult[string]; timestamp: number } | undefined {
|
||||
const cached = batchBadgeStore.data.get(taskId);
|
||||
export function getFreshBatchData(taskId: string, projectId?: string): { result: BatchStatusResult[string]; timestamp: number } | undefined {
|
||||
const cached = batchBadgeStore.data.get(getScopedTaskKey(taskId, projectId));
|
||||
if (!cached) return undefined;
|
||||
|
||||
const now = Date.now();
|
||||
@@ -49,7 +53,7 @@ interface UseBatchBadgeFetchResult {
|
||||
* - Exponential backoff retry: handles 429 rate limit errors with up to 3 retries
|
||||
* - Shared store: data is available across all hook instances
|
||||
*/
|
||||
export function useBatchBadgeFetch(): UseBatchBadgeFetchResult {
|
||||
export function useBatchBadgeFetch(projectId?: string): UseBatchBadgeFetchResult {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const fetchTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
@@ -62,7 +66,7 @@ export function useBatchBadgeFetch(): UseBatchBadgeFetchResult {
|
||||
|
||||
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
||||
try {
|
||||
const results = await fetchBatchStatus(taskIds);
|
||||
const results = await fetchBatchStatus(taskIds, projectId);
|
||||
return results;
|
||||
} catch (err: any) {
|
||||
lastError = err instanceof Error ? err : new Error(String(err));
|
||||
@@ -85,7 +89,7 @@ export function useBatchBadgeFetch(): UseBatchBadgeFetchResult {
|
||||
}
|
||||
|
||||
return {};
|
||||
}, []);
|
||||
}, [projectId]);
|
||||
|
||||
/**
|
||||
* Fetch batch badge statuses for the given task IDs.
|
||||
@@ -98,7 +102,7 @@ export function useBatchBadgeFetch(): UseBatchBadgeFetchResult {
|
||||
const now = Date.now();
|
||||
const fiveSecondsAgo = now - 5000;
|
||||
const hasFreshCache = taskIds.every((id) => {
|
||||
const cached = batchBadgeStore.data.get(id);
|
||||
const cached = batchBadgeStore.data.get(getScopedTaskKey(id, projectId));
|
||||
return cached && cached.timestamp > fiveSecondsAgo;
|
||||
});
|
||||
|
||||
@@ -136,7 +140,7 @@ export function useBatchBadgeFetch(): UseBatchBadgeFetchResult {
|
||||
// Update the store with new data
|
||||
const timestamp = Date.now();
|
||||
for (const [taskId, result] of Object.entries(results)) {
|
||||
batchBadgeStore.data.set(taskId, { result, timestamp });
|
||||
batchBadgeStore.data.set(getScopedTaskKey(taskId, projectId), { result, timestamp });
|
||||
}
|
||||
batchBadgeStore.lastFetchTime = timestamp;
|
||||
} catch (err) {
|
||||
@@ -146,14 +150,14 @@ export function useBatchBadgeFetch(): UseBatchBadgeFetchResult {
|
||||
batchBadgeStore.pendingPromise = null;
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [fetchWithRetry]);
|
||||
}, [fetchWithRetry, projectId]);
|
||||
|
||||
/**
|
||||
* Get cached batch data for a specific task ID.
|
||||
*/
|
||||
const getBatchData = useCallback((taskId: string) => {
|
||||
return batchBadgeStore.data.get(taskId);
|
||||
}, []);
|
||||
return batchBadgeStore.data.get(getScopedTaskKey(taskId, projectId));
|
||||
}, [projectId]);
|
||||
|
||||
return {
|
||||
fetchBatch,
|
||||
|
||||
@@ -11,7 +11,7 @@ interface UseChangedFilesResult {
|
||||
setSelectedFile: (file: TaskFileDiff) => void;
|
||||
}
|
||||
|
||||
export function useChangedFiles(taskId: string, worktree: string | undefined, column: string): UseChangedFilesResult {
|
||||
export function useChangedFiles(taskId: string, worktree: string | undefined, column: string, projectId?: string): UseChangedFilesResult {
|
||||
const [files, setFiles] = useState<TaskFileDiff[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -32,7 +32,7 @@ export function useChangedFiles(taskId: string, worktree: string | undefined, co
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await fetchTaskFileDiffs(taskId);
|
||||
const result = await fetchTaskFileDiffs(taskId, projectId);
|
||||
if (cancelled) return;
|
||||
setFiles(result);
|
||||
setSelectedFile((current) => {
|
||||
@@ -60,7 +60,7 @@ export function useChangedFiles(taskId: string, worktree: string | undefined, co
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [taskId, worktree, column]);
|
||||
}, [taskId, worktree, column, projectId]);
|
||||
|
||||
return { files, loading, error, selectedFile, setSelectedFile };
|
||||
}
|
||||
|
||||
@@ -140,7 +140,7 @@ export function useExecutorStats(projectId?: string): UseExecutorStatsResult {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const data = await fetchExecutorStats();
|
||||
const data = await fetchExecutorStats(projectId);
|
||||
setApiData(data);
|
||||
} catch (err) {
|
||||
if (err instanceof Error && err.name === "AbortError") {
|
||||
@@ -151,7 +151,7 @@ export function useExecutorStats(projectId?: string): UseExecutorStatsResult {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
}, [projectId]);
|
||||
|
||||
// Initial fetch
|
||||
useEffect(() => {
|
||||
|
||||
@@ -8,7 +8,7 @@ interface UseSessionFilesResult {
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
export function useSessionFiles(taskId: string, worktree: string | undefined, column: string): UseSessionFilesResult {
|
||||
export function useSessionFiles(taskId: string, worktree: string | undefined, column: string, projectId?: string): UseSessionFilesResult {
|
||||
const [files, setFiles] = useState<string[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
@@ -24,7 +24,7 @@ export function useSessionFiles(taskId: string, worktree: string | undefined, co
|
||||
async function load() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await fetchSessionFiles(taskId);
|
||||
const result = await fetchSessionFiles(taskId, projectId);
|
||||
if (!cancelled) {
|
||||
setFiles(result);
|
||||
}
|
||||
@@ -40,7 +40,7 @@ export function useSessionFiles(taskId: string, worktree: string | undefined, co
|
||||
}
|
||||
|
||||
void load();
|
||||
}, [taskId, worktree, column]);
|
||||
}, [taskId, worktree, column, projectId]);
|
||||
|
||||
return { files, loading };
|
||||
}
|
||||
|
||||
@@ -49,9 +49,7 @@ export function useTasks(options?: UseTasksOptions) {
|
||||
const requestVersion = ++fetchVersionRef.current;
|
||||
|
||||
try {
|
||||
const fetchedTasks = projectId
|
||||
? await api.fetchProjectTasks(projectId)
|
||||
: await api.fetchTasks();
|
||||
const fetchedTasks = await api.fetchTasks(undefined, undefined, projectId);
|
||||
if (fetchVersionRef.current !== requestVersion) {
|
||||
return;
|
||||
}
|
||||
@@ -93,20 +91,6 @@ export function useTasks(options?: UseTasksOptions) {
|
||||
};
|
||||
}, [refreshTasks]);
|
||||
|
||||
// Fetch initial tasks and recover when the tab becomes visible again.
|
||||
useEffect(() => {
|
||||
void refreshTasks();
|
||||
|
||||
const handleVisibilityChange = () => {
|
||||
void refreshTasks();
|
||||
};
|
||||
|
||||
document.addEventListener("visibilitychange", handleVisibilityChange);
|
||||
return () => {
|
||||
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
||||
};
|
||||
}, [refreshTasks]);
|
||||
|
||||
// SSE live updates
|
||||
// Note: In multi-project mode, SSE receives all task events.
|
||||
// Tasks are filtered by ID match, so cross-project updates won't affect
|
||||
@@ -117,7 +101,8 @@ export function useTasks(options?: UseTasksOptions) {
|
||||
if (connectionNonce > 0) {
|
||||
void refreshTasks();
|
||||
}
|
||||
const es = new EventSource("/api/events");
|
||||
const query = projectId ? `?projectId=${encodeURIComponent(projectId)}` : "";
|
||||
const es = new EventSource(`/api/events${query}`);
|
||||
|
||||
const handleCreated = (e: MessageEvent) => {
|
||||
const task = normalizeTask(JSON.parse(e.data) as Task);
|
||||
@@ -220,31 +205,31 @@ export function useTasks(options?: UseTasksOptions) {
|
||||
closedByCleanup = true;
|
||||
cleanup();
|
||||
};
|
||||
}, [connectionNonce, refreshTasks]);
|
||||
}, [connectionNonce, projectId, refreshTasks]);
|
||||
|
||||
const createTask = useCallback(async (input: TaskCreateInput): Promise<Task> => {
|
||||
return normalizeTask(await api.createTask(input));
|
||||
}, []);
|
||||
return normalizeTask(await api.createTask(input, projectId));
|
||||
}, [projectId]);
|
||||
|
||||
const moveTask = useCallback(async (id: string, column: Column): Promise<Task> => {
|
||||
return normalizeTask(await api.moveTask(id, column));
|
||||
}, []);
|
||||
return normalizeTask(await api.moveTask(id, column, projectId));
|
||||
}, [projectId]);
|
||||
|
||||
const deleteTask = useCallback(async (id: string): Promise<Task> => {
|
||||
return normalizeTask(await api.deleteTask(id));
|
||||
}, []);
|
||||
return normalizeTask(await api.deleteTask(id, projectId));
|
||||
}, [projectId]);
|
||||
|
||||
const mergeTask = useCallback(async (id: string): Promise<MergeResult> => {
|
||||
return api.mergeTask(id);
|
||||
}, []);
|
||||
return api.mergeTask(id, projectId);
|
||||
}, [projectId]);
|
||||
|
||||
const retryTask = useCallback(async (id: string): Promise<Task> => {
|
||||
return normalizeTask(await api.retryTask(id));
|
||||
}, []);
|
||||
return normalizeTask(await api.retryTask(id, projectId));
|
||||
}, [projectId]);
|
||||
|
||||
const duplicateTask = useCallback(async (id: string): Promise<Task> => {
|
||||
return normalizeTask(await api.duplicateTask(id));
|
||||
}, []);
|
||||
return normalizeTask(await api.duplicateTask(id, projectId));
|
||||
}, [projectId]);
|
||||
|
||||
const updateTask = useCallback(async (
|
||||
id: string,
|
||||
@@ -262,7 +247,7 @@ export function useTasks(options?: UseTasksOptions) {
|
||||
}
|
||||
|
||||
try {
|
||||
const updatedTask = normalizeTask(await api.updateTask(id, updates));
|
||||
const updatedTask = normalizeTask(await api.updateTask(id, updates, projectId));
|
||||
setTasks((prev) =>
|
||||
prev.map((t) => (t.id === id ? updatedTask : t))
|
||||
);
|
||||
@@ -275,26 +260,26 @@ export function useTasks(options?: UseTasksOptions) {
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}, []);
|
||||
}, [projectId]);
|
||||
|
||||
const archiveTask = useCallback(async (id: string): Promise<Task> => {
|
||||
const task = normalizeTask(await api.archiveTask(id));
|
||||
const task = normalizeTask(await api.archiveTask(id, projectId));
|
||||
setTasks((prev) =>
|
||||
prev.map((t) => (t.id === id ? task : t))
|
||||
);
|
||||
return task;
|
||||
}, []);
|
||||
}, [projectId]);
|
||||
|
||||
const unarchiveTask = useCallback(async (id: string): Promise<Task> => {
|
||||
const task = normalizeTask(await api.unarchiveTask(id));
|
||||
const task = normalizeTask(await api.unarchiveTask(id, projectId));
|
||||
setTasks((prev) =>
|
||||
prev.map((t) => (t.id === id ? task : t))
|
||||
);
|
||||
return task;
|
||||
}, []);
|
||||
}, [projectId]);
|
||||
|
||||
const archiveAllDone = useCallback(async (): Promise<Task[]> => {
|
||||
const archived = await api.archiveAllDone();
|
||||
const archived = await api.archiveAllDone(projectId);
|
||||
const normalized = archived.map(normalizeTask);
|
||||
setTasks((prev) =>
|
||||
prev.map((t) => {
|
||||
@@ -303,7 +288,7 @@ export function useTasks(options?: UseTasksOptions) {
|
||||
})
|
||||
);
|
||||
return normalized;
|
||||
}, []);
|
||||
}, [projectId]);
|
||||
|
||||
return { tasks, createTask, moveTask, deleteTask, mergeTask, retryTask, duplicateTask, updateTask, archiveTask, unarchiveTask, archiveAllDone };
|
||||
}
|
||||
|
||||
@@ -50,6 +50,12 @@ function generateTabId(): string {
|
||||
return `tab-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
|
||||
}
|
||||
|
||||
function isRelativeUrlFetchError(error: unknown): boolean {
|
||||
const message =
|
||||
error instanceof Error ? error.message : typeof error === "string" ? error : "";
|
||||
return message.includes("Failed to parse URL") || message.includes("Invalid URL");
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for managing multiple terminal sessions with localStorage persistence.
|
||||
*
|
||||
@@ -82,6 +88,7 @@ export function useTerminalSessions(): UseTerminalSessionsReturn {
|
||||
|
||||
// Track whether validation has completed
|
||||
const [isReady, setIsReady] = useState(false);
|
||||
const [serverAvailable, setServerAvailable] = useState(true);
|
||||
|
||||
// Persist tabs to localStorage whenever they change
|
||||
useEffect(() => {
|
||||
@@ -105,6 +112,7 @@ export function useTerminalSessions(): UseTerminalSessionsReturn {
|
||||
if (cancelled) return;
|
||||
|
||||
const validSessionIds = new Set(serverSessions.map((s) => s.id));
|
||||
setServerAvailable(true);
|
||||
|
||||
setTabs((currentTabs) => {
|
||||
if (cancelled) return currentTabs;
|
||||
@@ -143,7 +151,11 @@ export function useTerminalSessions(): UseTerminalSessionsReturn {
|
||||
} catch (err) {
|
||||
// Server listing failed - keep local tabs but mark as unverified
|
||||
// The WebSocket will fail to connect, which is acceptable
|
||||
console.warn("Failed to validate terminal sessions with server:", err);
|
||||
const relativeUrlError = isRelativeUrlFetchError(err);
|
||||
if (!relativeUrlError) {
|
||||
console.warn("Failed to validate terminal sessions with server:", err);
|
||||
}
|
||||
setServerAvailable(!relativeUrlError);
|
||||
// Still mark as ready so the UI can proceed
|
||||
setIsReady(true);
|
||||
}
|
||||
@@ -158,14 +170,18 @@ export function useTerminalSessions(): UseTerminalSessionsReturn {
|
||||
|
||||
// Auto-create first tab if no tabs exist after validation
|
||||
useEffect(() => {
|
||||
if (tabs.length === 0 && isReady) {
|
||||
if (tabs.length === 0 && isReady && serverAvailable) {
|
||||
// Small delay to avoid race condition with the validation effect
|
||||
const timeout = setTimeout(() => {
|
||||
createTabInternal().catch(console.error);
|
||||
createTabInternal().catch((err) => {
|
||||
if (!isRelativeUrlFetchError(err)) {
|
||||
console.error(err);
|
||||
}
|
||||
});
|
||||
}, 0);
|
||||
return () => clearTimeout(timeout);
|
||||
}
|
||||
}, [isReady, tabs.length]); // Run when ready or when tabs become empty
|
||||
}, [isReady, serverAvailable, tabs.length]); // Run when ready or when tabs become empty
|
||||
|
||||
/**
|
||||
* Internal create tab function (used for auto-creation and user-initiated creation)
|
||||
|
||||
Reference in New Issue
Block a user