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 @@ -704,7 +704,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 @@ -777,6 +777,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
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import {
import { useSettingsStore } from "@features/settings/stores/settingsStore";
import { SkillButtonActionMessage } from "@features/skill-buttons/components/SkillButtonActionMessage";
import { ArrowDown, XCircle } from "@phosphor-icons/react";
import { WorkerPoolContextProvider } from "@pierre/diffs/react";
import WorkerUrl from "@pierre/diffs/worker/worker.js?worker&url";
import { Box, Button, Flex, Text } from "@radix-ui/themes";
import type { AcpMessage } from "@shared/types/session-events";
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
Expand All @@ -30,6 +32,19 @@ import { UserMessage } from "./session-update/UserMessage";
import { UserShellExecuteView } from "./session-update/UserShellExecuteView";
import { VirtualizedList, type VirtualizedListHandle } from "./VirtualizedList";

function diffsWorkerFactory(): Worker {
return new Worker(WorkerUrl, { type: "module" });
}

const DIFFS_POOL_OPTIONS = {
workerFactory: diffsWorkerFactory,
totalASTLRUCacheSize: 200,
};

const DIFFS_HIGHLIGHTER_OPTIONS = {
theme: { dark: "github-dark" as const, light: "github-light" as const },
};

interface ConversationViewProps {
events: AcpMessage[];
isPromptPending: boolean | null;
Expand Down Expand Up @@ -216,51 +231,56 @@ export function ConversationView({
const getItemKey = useCallback((item: ConversationItem) => item.id, []);

return (
<div className="relative flex-1">
<div
id="fullscreen-portal"
className="pointer-events-none absolute inset-0 z-20"
/>
<WorkerPoolContextProvider
poolOptions={DIFFS_POOL_OPTIONS}
highlighterOptions={DIFFS_HIGHLIGHTER_OPTIONS}
>
<div className="relative flex-1">
<div
id="fullscreen-portal"
className="pointer-events-none absolute inset-0 z-20"
/>

<VirtualizedList
ref={listRef}
items={items}
getItemKey={getItemKey}
renderItem={renderItem}
onScrollStateChange={handleScrollStateChange}
keepMounted={mcpAppIndices}
className="absolute inset-0 bg-background"
itemClassName="mx-auto px-2 py-1.5"
itemStyle={{ maxWidth: CHAT_CONTENT_MAX_WIDTH }}
footer={
<div className={compact ? "pb-1" : "pb-16"}>
<SessionFooter
isPromptPending={isPromptPending}
promptStartedAt={promptStartedAt}
lastGenerationDuration={
lastTurnInfo?.isComplete
? Math.max(0, lastTurnInfo.durationMs - pausedDurationMs)
: null
}
lastStopReason={lastTurnInfo?.stopReason}
queuedCount={queuedMessages.length}
hasPendingPermission={pendingPermissionsCount > 0}
pausedDurationMs={pausedDurationMs}
isCompacting={isCompacting}
usage={contextUsage}
/>
</div>
}
/>
{showScrollButton && (
<Box className="absolute right-4 bottom-4 z-10">
<Button size="1" variant="solid" onClick={scrollToBottom}>
<ArrowDown size={14} weight="bold" />
Scroll to bottom
</Button>
</Box>
)}
</div>
<VirtualizedList
ref={listRef}
items={items}
getItemKey={getItemKey}
renderItem={renderItem}
onScrollStateChange={handleScrollStateChange}
keepMounted={mcpAppIndices}
className="absolute inset-0 bg-background"
itemClassName="mx-auto px-2 py-1.5"
itemStyle={{ maxWidth: CHAT_CONTENT_MAX_WIDTH }}
footer={
<div className={compact ? "pb-1" : "pb-16"}>
<SessionFooter
isPromptPending={isPromptPending}
promptStartedAt={promptStartedAt}
lastGenerationDuration={
lastTurnInfo?.isComplete
? Math.max(0, lastTurnInfo.durationMs - pausedDurationMs)
: null
}
lastStopReason={lastTurnInfo?.stopReason}
queuedCount={queuedMessages.length}
hasPendingPermission={pendingPermissionsCount > 0}
pausedDurationMs={pausedDurationMs}
isCompacting={isCompacting}
usage={contextUsage}
/>
</div>
}
/>
{showScrollButton && (
<Box className="absolute right-4 bottom-4 z-10">
<Button size="1" variant="solid" onClick={scrollToBottom}>
<ArrowDown size={14} weight="bold" />
Scroll to bottom
</Button>
</Box>
)}
</div>
</WorkerPoolContextProvider>
);
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { EditorView } from "@codemirror/view";
import { MultiFileDiff, WorkerPoolContextProvider } from "@pierre/diffs/react";
import WorkerUrl from "@pierre/diffs/worker/worker.js?worker&url";
import { MultiFileDiff } from "@pierre/diffs/react";
import { Code } from "@radix-ui/themes";
import { useThemeStore } from "@stores/themeStore";
import { compactHomePath } from "@utils/path";
Expand All @@ -12,17 +11,14 @@ import {
useCodePreviewExtensions,
} from "./useCodePreviewExtensions";

function workerFactory(): Worker {
return new Worker(WorkerUrl, { type: "module" });
}

interface CodePreviewProps {
content: string;
filePath?: string;
showPath?: boolean;
oldContent?: string | null;
firstLineNumber?: number;
maxHeight?: string;
cacheKey?: string;
}

export function CodePreview({
Expand All @@ -32,6 +28,7 @@ export function CodePreview({
oldContent,
firstLineNumber = 1,
maxHeight,
cacheKey,
}: CodePreviewProps) {
const isDiff = oldContent !== undefined && oldContent !== null;

Expand All @@ -43,6 +40,7 @@ export function CodePreview({
showPath={showPath}
oldContent={oldContent}
maxHeight={maxHeight}
cacheKey={cacheKey}
/>
);
}
Expand All @@ -64,23 +62,33 @@ function DiffPreview({
showPath,
oldContent,
maxHeight,
cacheKey,
}: {
content: string;
filePath?: string;
showPath?: boolean;
oldContent: string;
maxHeight?: string;
cacheKey?: string;
}) {
const isDarkMode = useThemeStore((s) => s.isDarkMode);
const fileName = filePath?.split("/").pop() ?? "file";

const oldFile = useMemo(
() => ({ name: fileName, contents: oldContent }),
[fileName, oldContent],
() => ({
name: fileName,
contents: oldContent,
...(cacheKey ? { cacheKey: `${cacheKey}:old` } : {}),
}),
[fileName, oldContent, cacheKey],
);
const newFile = useMemo(
() => ({ name: fileName, contents: content }),
[fileName, content],
() => ({
name: fileName,
contents: content,
...(cacheKey ? { cacheKey: `${cacheKey}:new` } : {}),
}),
[fileName, content, cacheKey],
);
const options = useMemo(
() => ({
Expand All @@ -103,18 +111,7 @@ function DiffPreview({
</div>
)}
<div style={maxHeight ? { maxHeight, overflow: "auto" } : undefined}>
<WorkerPoolContextProvider
poolOptions={{ workerFactory }}
highlighterOptions={{
theme: { dark: "github-dark", light: "github-light" },
}}
>
<MultiFileDiff
oldFile={oldFile}
newFile={newFile}
options={options}
/>
</WorkerPoolContextProvider>
<MultiFileDiff oldFile={oldFile} newFile={newFile} options={options} />
</div>
</div>
);
Expand Down
Loading
Loading