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

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