Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
106 changes: 56 additions & 50 deletions packages/web/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,22 +1,21 @@
import { useState, useCallback, useEffect, useMemo } from "react";
import { QueryClient, QueryClientProvider, useQuery } from "@tanstack/react-query";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { Header } from "./components/layout/Header";
import { Board } from "./components/kanban/Board";
import { TaskDetail } from "./components/task/TaskDetail";
import { CommandPalette } from "./components/layout/CommandPalette";
import { useWebSocket } from "./hooks/useWebSocket";
import { useCommitToasts } from "./hooks/useCommitToasts";
import { useProjects } from "./hooks/useProjects";
import { useProjectTaskOverviews, useProjects } from "./hooks/useProjects";
import { useTasks } from "./hooks/useTasks";
import { useTheme } from "./hooks/useTheme";
import { useKeyboardShortcut } from "./hooks/useKeyboardShortcut";
import { ChatBubble } from "./components/chat/ChatBubble";
import { ChatPanel } from "./components/chat/ChatPanel";
import { calculateTaskMetrics } from "./lib/taskMetrics";
import { calculateOverviewMetrics, calculateTaskMetrics } from "./lib/taskMetrics";
import { readStorage, writeStorage, removeStorage } from "./lib/storage";
import { STORAGE_KEYS } from "./lib/storageKeys";
import { api } from "./lib/api";
import type { Project, Task } from "@aif/shared/browser";
import type { Project } from "@aif/shared/browser";
import { ProjectRuntimeSettings } from "./components/project/ProjectRuntimeSettings";
import { ProjectsOverview } from "./components/project/ProjectsOverview";
import { ToastProvider } from "./components/ui/toast";
Expand All @@ -30,13 +29,31 @@ const queryClient = new QueryClient({
},
});

const PROJECT_ROUTE_PATTERN = /^\/project\/([^/]+)(?:\/task\/([^/]+))?/;

function readInitialSelection(): { projectId: string | null; taskId: string | null } {
const match = window.location.pathname.match(PROJECT_ROUTE_PATTERN);
if (match) {
return { projectId: match[1] ?? null, taskId: match[2] ?? null };
}

return {
projectId: readStorage(STORAGE_KEYS.SELECTED_PROJECT),
taskId: null,
};
}

function AppContent() {
useWebSocket();
useCommitToasts();
const { theme, toggleTheme } = useTheme();
const { data: projects } = useProjects();
const [selectedProjectId, setSelectedProjectId] = useState<string | null>(null);
const [selectedTaskId, setSelectedTaskId] = useState<string | null>(null);
const [selectedProjectId, setSelectedProjectId] = useState<string | null>(
() => readInitialSelection().projectId,
);
const [selectedTaskId, setSelectedTaskId] = useState<string | null>(
() => readInitialSelection().taskId,
);
const [commandOpen, setCommandOpen] = useState(false);
const [chatOpen, setChatOpen] = useState(false);
const [runtimeSettingsOpen, setRuntimeSettingsOpen] = useState(false);
Expand All @@ -52,18 +69,17 @@ function AppContent() {
() => projects?.find((candidate) => candidate.id === selectedProjectId) ?? null,
[projects, selectedProjectId],
);
const { data: projectTasks } = useTasks(project?.id ?? null);
const { data: allTasks } = useQuery<Task[]>({
queryKey: ["tasks", "all"],
queryFn: () => api.listTasks(),
enabled: !project,
});
const { data: projectTasks } = useTasks(selectedProjectId);
const { data: projectTaskOverviews } = useProjectTaskOverviews(!selectedProjectId);
const taskMetrics = useMemo(
() => calculateTaskMetrics((project ? projectTasks : allTasks) ?? []),
[project, projectTasks, allTasks],
() =>
selectedProjectId
? calculateTaskMetrics(projectTasks ?? [])
: calculateOverviewMetrics(projectTaskOverviews ?? []),
[projectTaskOverviews, projectTasks, selectedProjectId],
);
const aggregateProjectTotals = useMemo(() => {
if (project || !projects?.length) return null;
if (selectedProjectId || !projects?.length) return null;
return projects.reduce(
(acc, p) => ({
tokenInput: acc.tokenInput + (p.tokenInput ?? 0),
Expand All @@ -73,7 +89,7 @@ function AppContent() {
}),
{ tokenInput: 0, tokenOutput: 0, tokenTotal: 0, costUsd: 0 },
);
}, [project, projects]);
}, [projects, selectedProjectId]);

useEffect(() => {
writeStorage(STORAGE_KEYS.DENSITY, density);
Expand All @@ -83,50 +99,40 @@ function AppContent() {
writeStorage(STORAGE_KEYS.VIEW_MODE, viewMode);
}, [viewMode]);

// Restore state from URL or localStorage on initial load
// Validate restored state after projects load.
useEffect(() => {
if (!projects?.length) return;
if (selectedProjectId) return;
if (!projects || !selectedProjectId) return;

const match = window.location.pathname.match(/^\/project\/([^/]+)(?:\/task\/([^/]+))?/);
if (match) {
const urlProjectId = match[1];
const urlTaskId = match[2] ?? null;
const found = projects.find((p) => p.id === urlProjectId);
if (found) {
queueMicrotask(() => {
setSelectedProjectId(found.id);
writeStorage(STORAGE_KEYS.SELECTED_PROJECT, found.id);
if (urlTaskId) setSelectedTaskId(urlTaskId);
});
return;
}
const found = projects.find((p) => p.id === selectedProjectId);
if (found) {
writeStorage(STORAGE_KEYS.SELECTED_PROJECT, found.id);
return;
}

const savedId = readStorage(STORAGE_KEYS.SELECTED_PROJECT);
if (savedId) {
const found = projects.find((p) => p.id === savedId);
if (found) {
queueMicrotask(() => {
setSelectedProjectId(found.id);
});
}
}
console.debug("[app] Clearing stale selected project", {
selectedProjectId,
reason: "missing_from_projects",
});

const clearTimer = window.setTimeout(() => {
setSelectedProjectId(null);
setSelectedTaskId(null);
removeStorage(STORAGE_KEYS.SELECTED_PROJECT);
}, 0);

return () => window.clearTimeout(clearTimer);
}, [projects, selectedProjectId]);

// Handle browser back/forward
useEffect(() => {
const onPopState = () => {
const match = window.location.pathname.match(/^\/project\/([^/]+)(?:\/task\/([^/]+))?/);
const match = window.location.pathname.match(PROJECT_ROUTE_PATTERN);
if (match) {
const urlProjectId = match[1];
const urlTaskId = match[2] ?? null;
const found = projects?.find((p) => p.id === urlProjectId);
if (found) {
setSelectedProjectId(found.id);
setSelectedTaskId(urlTaskId);
return;
}
setSelectedProjectId(urlProjectId);
setSelectedTaskId(urlTaskId);
return;
}
setSelectedProjectId(null);
setSelectedTaskId(null);
Expand Down Expand Up @@ -253,7 +259,7 @@ function AppContent() {
onOpenChange={setCommandOpen}
projects={projects ?? []}
tasks={projectTasks ?? []}
selectedProjectId={project?.id ?? null}
selectedProjectId={selectedProjectId}
density={density}
theme={theme}
onSelectProject={handleSelectProject}
Expand Down
25 changes: 24 additions & 1 deletion packages/web/src/__tests__/AppSmoke.test.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { describe, it, expect, vi } from "vitest";
import { afterEach, beforeEach, describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";

// Stub network-dependent hooks so the component tree renders without real API calls.
Expand Down Expand Up @@ -57,8 +57,21 @@ vi.mock("@tanstack/react-query", async (importOriginal) => {
});

const App = (await import("../App")).default;
const { useProjectTaskOverviews } = await import("@/hooks/useProjects");
const { useTasks } = await import("@/hooks/useTasks");

describe("App smoke test", () => {
beforeEach(() => {
window.history.pushState(null, "", "/");
window.localStorage.clear();
vi.clearAllMocks();
});

afterEach(() => {
window.history.pushState(null, "", "/");
window.localStorage.clear();
});

it("renders without crashing (providers are wired correctly)", () => {
expect(() => render(<App />)).not.toThrow();
});
Expand All @@ -67,4 +80,14 @@ describe("App smoke test", () => {
render(<App />);
expect(screen.getByText("No projects yet")).toBeInTheDocument();
});

it("uses the project id from the URL for the initial task query", () => {
const projectId = "00000000-0000-4000-8000-000000000001";
window.history.pushState(null, "", `/project/${projectId}`);

render(<App />);

expect(vi.mocked(useTasks)).toHaveBeenCalledWith(projectId);
expect(vi.mocked(useProjectTaskOverviews)).toHaveBeenCalledWith(false);
});
});
91 changes: 91 additions & 0 deletions packages/web/src/__tests__/ProjectsOverview.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import type { Project, ProjectTaskOverview } from "@aif/shared/browser";
import { ProjectsOverview } from "@/components/project/ProjectsOverview";

// Regression test for the empty-project loading behavior.
//
// ProjectsOverview derives `isLoading` from the overview query state, so a
// project with zero tasks must render its card (0 / 0 badge) instead of
// hanging on skeleton cards forever.

const emptyProject: Project = {
id: "proj-empty",
name: "Empty Project",
rootPath: "/tmp/empty",
plannerMaxBudgetUsd: null,
planCheckerMaxBudgetUsd: null,
implementerMaxBudgetUsd: null,
reviewSidecarMaxBudgetUsd: null,
autoQueueMode: false,
parallelEnabled: false,
defaultTaskRuntimeProfileId: null,
defaultPlanRuntimeProfileId: null,
defaultReviewRuntimeProfileId: null,
defaultChatRuntimeProfileId: null,
tokenInput: undefined,
tokenOutput: undefined,
tokenTotal: undefined,
costUsd: undefined,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
};

const emptyOverview: ProjectTaskOverview = {
projectId: "proj-empty",
totalTasks: 0,
completedTasks: 0,
verifiedTasks: 0,
backlogTasks: 0,
activeTasks: 0,
blockedTasks: 0,
autoModeTasks: 0,
fixTasks: 0,
totalRetries: 0,
totalTokenInput: 0,
totalTokenOutput: 0,
totalTokenTotal: 0,
totalCostUsd: 0,
statusCounts: {
backlog: 0,
planning: 0,
plan_ready: 0,
implementing: 0,
review: 0,
blocked_external: 0,
done: 0,
verified: 0,
},
statusPreviews: {
backlog: [],
planning: [],
plan_ready: [],
implementing: [],
review: [],
blocked_external: [],
done: [],
verified: [],
},
};

describe("ProjectsOverview empty-project loading regression", () => {
it("does not stay in loading state when a project has zero tasks", () => {
// Simulate the resolved state of useProjectTaskOverviews: the overview
// query resolved successfully with a zero-task project (no loading).
vi.mock("@/hooks/useProjects", () => ({
useProjectTaskOverviews: () => ({
data: [emptyOverview],
isLoading: false,
}),
}));

render(<ProjectsOverview projects={[emptyProject]} onSelectProject={() => {}} />);

// The project card must render (not skeleton cards). The "0 / 0" badge
// confirms the empty project resolved to a real, non-loading card.
expect(screen.getByText("Empty Project")).toBeDefined();
expect(screen.getByText("0 / 0")).toBeDefined();
// No skeleton should be rendered.
expect(screen.queryByRole("status")).toBeNull();
});
});
39 changes: 39 additions & 0 deletions packages/web/src/__tests__/api.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { api } from "@/lib/api";

function jsonResponse(body: unknown): Response {
return new Response(JSON.stringify(body), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}

describe("api client", () => {
beforeEach(() => {
vi.stubGlobal(
"fetch",
vi.fn(async () => jsonResponse([])),
);
});

afterEach(() => {
vi.unstubAllGlobals();
});

it("requires projectId for task list requests", async () => {
await api.listTasks("project 1");

const fetchMock = vi.mocked(fetch);
const [url] = fetchMock.mock.calls[0];
expect(String(url)).toContain("/tasks?projectId=project%201");
expect(String(url)).not.toBe("/tasks");
});

it("uses explicit project overview endpoint", async () => {
await api.listProjectTaskOverviews();

const fetchMock = vi.mocked(fetch);
const [url] = fetchMock.mock.calls[0];
expect(String(url)).toContain("/projects/overview");
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { describe, it, expect, vi } from "vitest";
import { QueryClient } from "@tanstack/react-query";
import { invalidateProjectTaskOverviews } from "@/hooks/useProjects";

/**
* Regression for the WS overview-invalidation gap (#143 review, must-fix #2).
*
* `project:runtime_limit_updated` fires when persisted runtime-limit / last
* usage state changes. The overview aggregates token/cost fields, so this
* event must invalidate the `["projectTaskOverviews"]` query — otherwise the
* dashboard header and project cards show stale token/cost after usage
* updates. The useWebSocket handler calls `invalidateProjectTaskOverviews`
* (from useProjects) in that branch; this test pins that helper so a future
* change that drops or renames the query key surfaces immediately.
*/
describe("invalidateProjectTaskOverviews (WS runtime_limit_updated branch)", () => {
it("invalidates the projectTaskOverviews query", () => {
const queryClient = new QueryClient();
const invalidateSpy = vi.spyOn(queryClient, "invalidateQueries");

invalidateProjectTaskOverviews(queryClient);

expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ["projectTaskOverviews"] });
});
});
Loading
Loading