Skip to content
Open
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
1,979 changes: 1,105 additions & 874 deletions apps/code/src/renderer/api/generated.ts

Large diffs are not rendered by default.

104 changes: 104 additions & 0 deletions apps/code/src/renderer/api/posthogClient.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -281,4 +281,108 @@ describe("PostHogAPIClient", () => {
await expect(client.getSignalReport("abc")).rejects.toThrow("[500]");
});
});

describe("getTaskSummaries", () => {
const SUMMARIES_PATH = "/api/projects/123/tasks/summaries/";

function buildClient(fetch: ReturnType<typeof vi.fn>) {
const client = new PostHogAPIClient(
"http://localhost:8000",
async () => "token",
async () => "token",
123,
);
(
client as unknown as {
api: { baseUrl: string; fetcher: { fetch: typeof fetch } };
}
).api = { baseUrl: "http://localhost:8000", fetcher: { fetch } };
return client;
}

function page(results: object[], next: string | null = null) {
return {
ok: true,
json: async () => ({ count: 0, previous: null, next, results }),
};
}

function buildFetchForPages(...pages: ReturnType<typeof page>[]) {
const fetch = vi.fn();
for (const p of pages) fetch.mockResolvedValueOnce(p);
return fetch;
}

it("returns immediately for empty input without hitting the network", async () => {
const fetch = vi.fn();
await expect(buildClient(fetch).getTaskSummaries([])).resolves.toEqual(
[],
);
expect(fetch).not.toHaveBeenCalled();
});

it("returns single-page results without further requests", async () => {
const fetch = buildFetchForPages(page([{ id: "a" }]));
await expect(buildClient(fetch).getTaskSummaries(["a"])).resolves.toEqual(
[{ id: "a" }],
);
expect(fetch).toHaveBeenCalledTimes(1);
});

it.each([
{
name: "same-host next URL",
nextUrl: `http://localhost:8000${SUMMARIES_PATH}?limit=2&offset=2`,
expectedSecondPath: `${SUMMARIES_PATH}?limit=2&offset=2`,
},
{
name: "cross-host next URL (proxy variance)",
nextUrl: `https://internal.posthog.example${SUMMARIES_PATH}?limit=1&offset=1`,
expectedSecondPath: `${SUMMARIES_PATH}?limit=1&offset=1`,
},
])(
"follows the next cursor across pages and merges results: $name",
async ({ nextUrl, expectedSecondPath }) => {
const fetch = buildFetchForPages(
page([{ id: "a" }, { id: "b" }], nextUrl),
page([{ id: "c" }]),
);
await expect(
buildClient(fetch).getTaskSummaries(["a", "b", "c"]),
).resolves.toEqual([{ id: "a" }, { id: "b" }, { id: "c" }]);
expect(fetch).toHaveBeenCalledTimes(2);
expect(fetch.mock.calls[0][0]).toMatchObject({
method: "post",
path: SUMMARIES_PATH,
});
expect(fetch.mock.calls[1][0]).toMatchObject({
method: "post",
path: expectedSecondPath,
});
},
);

it("throws when the server responds non-OK", async () => {
const fetch = vi
.fn()
.mockResolvedValue({ ok: false, statusText: "Bad Request" });
await expect(buildClient(fetch).getTaskSummaries(["a"])).rejects.toThrow(
"Bad Request",
);
});

it("returns partial results when MAX_PAGES is exceeded", async () => {
const fetch = vi
.fn()
.mockResolvedValue(
page(
[{ id: "x" }],
`http://localhost:8000${SUMMARIES_PATH}?offset=1`,
),
);
const result = await buildClient(fetch).getTaskSummaries(["a"]);
expect(fetch).toHaveBeenCalledTimes(50);
expect(result.length).toBe(50);
});
});
});
38 changes: 36 additions & 2 deletions apps/code/src/renderer/api/posthogClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ export const MCP_CATEGORIES = [
export type McpCategory = Schemas.CategoryEnum;
export type McpApprovalState =
Schemas.MCPServerInstallationToolApprovalStateEnum;
export type McpAuthType = Schemas.AuthType9cbEnum;
export type McpAuthType = Schemas.MCPAuthTypeEnum;
export type McpRecommendedServer = Schemas.MCPServerTemplate;
export type McpServerInstallation = Schemas.MCPServerInstallation;
export type McpInstallationTool = Schemas.MCPServerInstallationTool;
Expand Down Expand Up @@ -783,7 +783,7 @@ export class PostHogAPIClient {
"/api/projects/{project_id}/external_data_sources/",
{
path: { project_id: projectId.toString() },
body: payload as unknown as Schemas.ExternalDataSourceSerializers,
body: payload as unknown as Schemas.ExternalDataSourceCreate,
withResponse: true,
throwOnStatusError: false,
},
Expand Down Expand Up @@ -861,6 +861,40 @@ export class PostHogAPIClient {
return data.results ?? [];
}

async getTaskSummaries(ids: string[]) {
if (ids.length === 0) return [];
const TASK_SUMMARIES_MAX_PAGES = 50;
const teamId = await this.getTeamId();
const all: Schemas.TaskSummary[] = [];
let urlPath: string = `/api/projects/${teamId}/tasks/summaries/`;
for (let i = 0; i < TASK_SUMMARIES_MAX_PAGES; i++) {
const url = new URL(`${this.api.baseUrl}${urlPath}`);
const response = await this.api.fetcher.fetch({
method: "post",
url,
path: urlPath,
overrides: {
body: JSON.stringify({ ids } satisfies Schemas.TaskSummariesRequest),
},
});
if (!response.ok) {
throw new Error(
`Failed to fetch task summaries: ${response.statusText}`,
);
}
const page = (await response.json()) as Schemas.PaginatedTaskSummaryList;
all.push(...page.results);
if (!page.next) return all;
const nextUrl = new URL(page.next);
urlPath = `${nextUrl.pathname}${nextUrl.search}`;
}
log.warn(
`getTaskSummaries hit MAX_PAGES (${TASK_SUMMARIES_MAX_PAGES}); returning partial results`,
{ ids: ids.length, returned: all.length },
);
return all;
}

async getTask(taskId: string) {
const teamId = await this.getTeamId();
const data = await this.api.get(`/api/projects/{project_id}/tasks/{id}/`, {
Expand Down
85 changes: 71 additions & 14 deletions apps/code/src/renderer/features/sidebar/hooks/useSidebarData.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,9 @@ import { useArchivedTaskIds } from "@features/archive/hooks/useArchivedTaskIds";
import { useProvisioningStore } from "@features/provisioning/stores/provisioningStore";
import { useSessions } from "@features/sessions/stores/sessionStore";
import { useSuspendedTaskIds } from "@features/suspension/hooks/useSuspendedTaskIds";
import { useTasks } from "@features/tasks/hooks/useTasks";
import { useTaskSummaries, useTasks } from "@features/tasks/hooks/useTasks";
import { useWorkspaces } from "@features/workspace/hooks/useWorkspace";
import type { Schemas } from "@renderer/api/generated";
import type { Task, TaskRunStatus } from "@shared/types";
import { useEffect, useMemo, useRef } from "react";
import { useSidebarStore } from "../stores/sidebarStore";
Expand All @@ -14,6 +15,7 @@ import {
groupByRepository,
type TaskRepositoryInfo,
} from "../utils/groupTasks";
import { computeSummaryIds } from "../utils/summaryIds";
import { usePinnedTasks } from "./usePinnedTasks";
import { useTaskViewed } from "./useTaskViewed";

Expand Down Expand Up @@ -89,15 +91,75 @@ export function useSidebarData({
}: UseSidebarDataProps): SidebarData {
const showAllUsers = useSidebarStore((state) => state.showAllUsers);
const showInternal = useSidebarStore((state) => state.showInternal);
const { data: rawTasks = [], isFetched: isTasksFetched } = useTasks({
showAllUsers,
showInternal,
});
const { data: workspaces, isFetched: isWorkspacesFetched } = useWorkspaces();
const archivedTaskIds = useArchivedTaskIds();
const suspendedTaskIds = useSuspendedTaskIds();
const provisioningTaskIds = useProvisioningStore((s) => s.activeTasks);
const isLoading = !isTasksFetched || !isWorkspacesFetched;
const sessions = useSessions();
const { timestamps } = useTaskViewed();
const historyVisibleCount = useSidebarStore(
(state) => state.historyVisibleCount,
);
const { pinnedTaskIds } = usePinnedTasks();

const summaryIds = useMemo(
() =>
showAllUsers
? []
: computeSummaryIds({
workspaceIds: workspaces ? Object.keys(workspaces) : [],
pinnedTaskIds,
provisioningTaskIds,
archivedTaskIds,
}),
[
showAllUsers,
workspaces,
pinnedTaskIds,
provisioningTaskIds,
archivedTaskIds,
],
);

const { data: summaryTasks = [], isLoading: isSummariesLoading } =
useTaskSummaries(summaryIds, { enabled: !showAllUsers });
// showAllUsers stays on the heavy /tasks/ list endpoint until that path gets
// its own optimization (e.g. server-side recency pagination). The mapping
// below narrows full Task → TaskSummary so downstream sidebar code stays uniform.
const { data: fullTasks = [], isLoading: isTasksLoading } = useTasks(
{ showAllUsers, showInternal },
{ enabled: showAllUsers },
);

type SidebarTask = Schemas.TaskSummary & {
latest_run:
| (Schemas.TaskSummary["latest_run"] & {
output?: { pr_url?: unknown } | null;
})
| null;
};

const rawTasks: SidebarTask[] = useMemo(() => {
if (!showAllUsers) return summaryTasks;
return fullTasks.map((t) => ({
id: t.id,
title: t.title,
repository: t.repository ?? null,
created_at: t.created_at,
updated_at: t.updated_at,
latest_run: t.latest_run
? {
status: t.latest_run.status,
environment: t.latest_run.environment ?? null,
output: t.latest_run.output ?? null,
}
: null,
}));
}, [showAllUsers, summaryTasks, fullTasks]);

const isPrimaryLoading = showAllUsers ? isTasksLoading : isSummariesLoading;
const isLoading = isPrimaryLoading || !isWorkspacesFetched;

const allTasks = useMemo(
() =>
rawTasks.filter(
Expand All @@ -117,12 +179,6 @@ export function useSidebarData({
provisioningTaskIds,
],
);
const sessions = useSessions();
const { timestamps } = useTaskViewed();
const historyVisibleCount = useSidebarStore(
(state) => state.historyVisibleCount,
);
const { pinnedTaskIds } = usePinnedTasks();
const organizeMode = useSidebarStore((state) => state.organizeMode);
const sortMode = useSidebarStore((state) => state.sortMode);
const folderOrder = useSidebarStore((state) => state.folderOrder);
Expand Down Expand Up @@ -182,8 +238,9 @@ export function useSidebarData({
needsPermission: (session?.pendingPermissions?.size ?? 0) > 0,
repository: getRepositoryInfo(task, workspace?.folderPath),
folderId: workspace?.folderId || undefined,
taskRunStatus: session?.cloudStatus ?? task.latest_run?.status,
taskRunEnvironment: task.latest_run?.environment,
taskRunStatus:
session?.cloudStatus ?? task.latest_run?.status ?? undefined,
taskRunEnvironment: task.latest_run?.environment ?? undefined,
folderPath: workspace?.folderPath ?? null,
cloudPrUrl,
branchName: workspace?.branchName ?? null,
Expand Down
3 changes: 1 addition & 2 deletions apps/code/src/renderer/features/sidebar/utils/groupTasks.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { getTaskRepository, parseRepository } from "@renderer/utils/repository";
import type { Task } from "@shared/types";
import { normalizeRepoKey } from "@shared/utils/repo";

export interface TaskRepositoryInfo {
Expand All @@ -19,7 +18,7 @@ export interface TaskGroup<T extends GroupableTask> {
}

export function getRepositoryInfo(
task: Task,
task: { repository?: string | null },
folderPath?: string,
): TaskRepositoryInfo | null {
const repository = getTaskRepository(task);
Expand Down
57 changes: 57 additions & 0 deletions apps/code/src/renderer/features/sidebar/utils/summaryIds.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { describe, expect, it } from "vitest";
import { computeSummaryIds } from "./summaryIds";

describe("computeSummaryIds", () => {
const cases: Array<{
name: string;
workspaceIds?: Iterable<string>;
pinnedTaskIds?: Iterable<string>;
provisioningTaskIds?: Iterable<string>;
archivedTaskIds?: Iterable<string>;
expected: string[];
}> = [
{
name: "unions workspace, pinned, and provisioning ids",
workspaceIds: ["a", "b"],
pinnedTaskIds: ["b", "c"],
provisioningTaskIds: ["d"],
expected: ["a", "b", "c", "d"],
},
{
name: "removes archived ids from the union",
workspaceIds: ["a", "b", "c"],
pinnedTaskIds: ["d"],
archivedTaskIds: ["b", "d"],
expected: ["a", "c"],
},
{
name: "deduplicates ids appearing in multiple sources",
workspaceIds: ["a", "a"],
pinnedTaskIds: ["a"],
provisioningTaskIds: ["a"],
expected: ["a"],
},
{
name: "returns empty array when all inputs are empty",
expected: [],
},
{
name: "accepts Sets as well as arrays",
workspaceIds: new Set(["a"]),
pinnedTaskIds: new Set(["b"]),
archivedTaskIds: new Set(["a"]),
expected: ["b"],
},
];

it.each(cases)("$name", (c) => {
expect(
computeSummaryIds({
workspaceIds: c.workspaceIds ?? [],
pinnedTaskIds: c.pinnedTaskIds ?? [],
provisioningTaskIds: c.provisioningTaskIds ?? [],
archivedTaskIds: c.archivedTaskIds ?? [],
}).sort(),
).toEqual(c.expected.sort());
});
});
13 changes: 13 additions & 0 deletions apps/code/src/renderer/features/sidebar/utils/summaryIds.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
export function computeSummaryIds(input: {
workspaceIds: Iterable<string>;
pinnedTaskIds: Iterable<string>;
provisioningTaskIds: Iterable<string>;
archivedTaskIds: Iterable<string>;
}): string[] {
const ids = new Set<string>();
for (const id of input.workspaceIds) ids.add(id);
for (const id of input.pinnedTaskIds) ids.add(id);
for (const id of input.provisioningTaskIds) ids.add(id);
for (const id of input.archivedTaskIds) ids.delete(id);
return Array.from(ids);
}
Loading
Loading