Skip to content
This repository was archived by the owner on Aug 6, 2026. It is now read-only.

Commit ea7a3c6

Browse files
authored
refactor(canvas): apply simplify-pass cleanups
Reuse: use escapeXmlAttr from @posthog/shared for prompt metadata; derive platform pins (react version, canvasSdkVersion) from CANVAS_PLATFORM_MANIFEST; route the dashboards-grid delete through deleteCanvasWithUndo. Simplification: single build-record mapping shared by toBuildRecord and tryToBuildRecord; de-duplicate the ActivityView feed body across layouts; drop the freeformChatStore LRU/mount bookkeeping now that per-thread state is tiny; inline the one-line canvasSourcePresentation helpers; delete the orphaned desktop file-system client methods. Efficiency: stop re-validating build records in polled getBuilds; bound canvas data payloads without a per-request TextEncoder; keep onDataRequest referentially stable across builds polls; resolve the home canvas from the query cache before hitting ensureHomeCanvas. Altitude: extract a shared canvas host message router used by BuiltCanvas and FreeformCanvas (both paths now get the concurrency/size/timeout guards); move capability gating onto BuiltCanvas via a capabilities prop; extract the pinned-artifact lifecycle into usePinnedArtifact. Generated-By: PostHog Code Task-Id: aea6c5f4-02ae-448b-b4dc-f10f581ad597
1 parent 4a70cee commit ea7a3c6

19 files changed

Lines changed: 615 additions & 985 deletions

packages/api-client/src/posthog-client.ts

Lines changed: 0 additions & 325 deletions
Original file line numberDiff line numberDiff line change
@@ -589,39 +589,6 @@ export interface ChannelInstructionsVersion {
589589
created_by: ChannelInstructionsUser | null;
590590
}
591591

592-
export interface FolderInstructionsUser {
593-
id?: number;
594-
uuid?: string;
595-
first_name?: string;
596-
last_name?: string | null;
597-
email?: string;
598-
}
599-
600-
export interface FolderInstructions {
601-
id: string;
602-
content: string;
603-
version: number;
604-
is_latest: boolean;
605-
created_by: FolderInstructionsUser | null;
606-
created_at: string;
607-
updated_at: string;
608-
}
609-
610-
export interface FolderInstructionsVersion {
611-
id: string;
612-
version: number;
613-
is_latest: boolean;
614-
created_by: FolderInstructionsUser | null;
615-
created_at: string;
616-
}
617-
618-
interface PaginatedFolderInstructionsVersions {
619-
count: number;
620-
next: string | null;
621-
previous: string | null;
622-
results: FolderInstructionsVersion[];
623-
}
624-
625592
// Thrown when PUT /instructions/ rejects a publish because the caller's
626593
// `base_version` is older than the current latest. Callers can re-fetch and
627594
// retry against the new latest.
@@ -1561,298 +1528,6 @@ export class PostHogAPIClient {
15611528
);
15621529
}
15631530

1564-
// Desktop file system — the backend surface that backs canvas channels
1565-
// (top-level folders) and dashboards. These routes aren't in the generated
1566-
// OpenAPI client, so we use the raw fetcher.
1567-
// Channels are top-level folders on the desktop file system. Filtering to
1568-
// `type=folder` server-side (and requesting a large page) keeps us from
1569-
// paginating over every dashboard and filed task just to populate the
1570-
// sidebar channel list — the bulk of the initial-load cost otherwise.
1571-
async getDesktopFileSystemChannels(): Promise<Schemas.FileSystem[]> {
1572-
const DESKTOP_FILE_SYSTEM_MAX_PAGES = 50;
1573-
const DESKTOP_FILE_SYSTEM_PAGE_SIZE = 200;
1574-
const teamId = await this.getTeamId();
1575-
const all: Schemas.FileSystem[] = [];
1576-
let urlPath: string = `/api/projects/${teamId}/desktop_file_system/?type=folder&limit=${DESKTOP_FILE_SYSTEM_PAGE_SIZE}`;
1577-
for (let i = 0; i < DESKTOP_FILE_SYSTEM_MAX_PAGES; i++) {
1578-
const url = new URL(`${this.api.baseUrl}${urlPath}`);
1579-
const response = await this.api.fetcher.fetch({
1580-
method: "get",
1581-
url,
1582-
path: urlPath,
1583-
});
1584-
if (!response.ok) {
1585-
throw new Error(
1586-
`Failed to fetch desktop file system channels: ${response.statusText}`,
1587-
);
1588-
}
1589-
const page = (await response.json()) as Schemas.PaginatedFileSystemList;
1590-
all.push(...page.results);
1591-
if (!page.next) return all;
1592-
const nextUrl = new URL(page.next);
1593-
urlPath = `${nextUrl.pathname}${nextUrl.search}`;
1594-
}
1595-
log.warn(
1596-
`getDesktopFileSystemChannels hit MAX_PAGES (${DESKTOP_FILE_SYSTEM_MAX_PAGES}); returning partial results`,
1597-
{ returned: all.length },
1598-
);
1599-
return all;
1600-
}
1601-
1602-
// Create a top-level channel (a folder row whose path is a single segment).
1603-
async createDesktopFileSystemChannel(
1604-
name: string,
1605-
): Promise<Schemas.FileSystem> {
1606-
const teamId = await this.getTeamId();
1607-
const urlPath = `/api/projects/${teamId}/desktop_file_system/`;
1608-
const url = new URL(`${this.api.baseUrl}${urlPath}`);
1609-
const response = await this.api.fetcher.fetch({
1610-
method: "post",
1611-
url,
1612-
path: urlPath,
1613-
overrides: {
1614-
body: JSON.stringify({ path: name, type: "folder", depth: 1 }),
1615-
},
1616-
});
1617-
if (!response.ok) {
1618-
throw new Error(
1619-
`Failed to create desktop file system channel: ${response.statusText}`,
1620-
);
1621-
}
1622-
return (await response.json()) as Schemas.FileSystem;
1623-
}
1624-
1625-
// Rename a top-level channel: PATCH its path (a single segment) to the new
1626-
// name. The backend recomputes depth from the path.
1627-
async renameDesktopFileSystemChannel(
1628-
id: string,
1629-
name: string,
1630-
): Promise<Schemas.FileSystem> {
1631-
const teamId = await this.getTeamId();
1632-
const urlPath = `/api/projects/${teamId}/desktop_file_system/${encodeURIComponent(id)}/`;
1633-
const url = new URL(`${this.api.baseUrl}${urlPath}`);
1634-
const response = await this.api.fetcher.fetch({
1635-
method: "patch",
1636-
url,
1637-
path: urlPath,
1638-
overrides: {
1639-
body: JSON.stringify({ path: name }),
1640-
},
1641-
});
1642-
if (!response.ok) {
1643-
throw new Error(
1644-
`Failed to rename desktop file system channel: ${response.statusText}`,
1645-
);
1646-
}
1647-
return (await response.json()) as Schemas.FileSystem;
1648-
}
1649-
1650-
// Delete a desktop file system entry by id (used to remove top-level channels).
1651-
async deleteDesktopFileSystem(id: string): Promise<void> {
1652-
const teamId = await this.getTeamId();
1653-
const urlPath = `/api/projects/${teamId}/desktop_file_system/${encodeURIComponent(id)}/`;
1654-
const url = new URL(`${this.api.baseUrl}${urlPath}`);
1655-
const response = await this.api.fetcher.fetch({
1656-
method: "delete",
1657-
url,
1658-
path: urlPath,
1659-
});
1660-
if (!response.ok && response.status !== 404) {
1661-
throw new Error(
1662-
`Failed to delete desktop file system channel: ${response.statusText}`,
1663-
);
1664-
}
1665-
}
1666-
1667-
// Desktop file system shortcuts — the user-scoped "starred" items on the
1668-
// desktop surface (e.g. starred channels). Unlike the file system rows above,
1669-
// shortcuts are per-user, so they back cross-device starring without leaking
1670-
// one user's stars to their teammates. Not in the generated OpenAPI client,
1671-
// so we use the raw fetcher.
1672-
async getDesktopFileSystemShortcuts(): Promise<Schemas.FileSystemShortcut[]> {
1673-
const SHORTCUTS_MAX_PAGES = 50;
1674-
const SHORTCUTS_PAGE_SIZE = 200;
1675-
const teamId = await this.getTeamId();
1676-
const all: Schemas.FileSystemShortcut[] = [];
1677-
let urlPath: string = `/api/projects/${teamId}/desktop_file_system_shortcut/?limit=${SHORTCUTS_PAGE_SIZE}`;
1678-
for (let i = 0; i < SHORTCUTS_MAX_PAGES; i++) {
1679-
const url = new URL(`${this.api.baseUrl}${urlPath}`);
1680-
const response = await this.api.fetcher.fetch({
1681-
method: "get",
1682-
url,
1683-
path: urlPath,
1684-
});
1685-
if (!response.ok) {
1686-
throw new Error(
1687-
`Failed to fetch desktop file system shortcuts: ${response.statusText}`,
1688-
);
1689-
}
1690-
const page =
1691-
(await response.json()) as Schemas.PaginatedFileSystemShortcutList;
1692-
all.push(...page.results);
1693-
if (!page.next) return all;
1694-
const nextUrl = new URL(page.next);
1695-
urlPath = `${nextUrl.pathname}${nextUrl.search}`;
1696-
}
1697-
log.warn(
1698-
`getDesktopFileSystemShortcuts hit MAX_PAGES (${SHORTCUTS_MAX_PAGES}); returning partial results`,
1699-
{ returned: all.length },
1700-
);
1701-
return all;
1702-
}
1703-
1704-
// Create a desktop shortcut for the current user. For a folder/channel the
1705-
// backend links by `ref` (the folder's full path), with `path` as the label.
1706-
async createDesktopFileSystemShortcut(input: {
1707-
path: string;
1708-
type: string;
1709-
ref?: string;
1710-
href?: string;
1711-
}): Promise<Schemas.FileSystemShortcut> {
1712-
const teamId = await this.getTeamId();
1713-
const urlPath = `/api/projects/${teamId}/desktop_file_system_shortcut/`;
1714-
const url = new URL(`${this.api.baseUrl}${urlPath}`);
1715-
const response = await this.api.fetcher.fetch({
1716-
method: "post",
1717-
url,
1718-
path: urlPath,
1719-
overrides: {
1720-
body: JSON.stringify(input),
1721-
},
1722-
});
1723-
if (!response.ok) {
1724-
throw new Error(
1725-
`Failed to create desktop file system shortcut: ${response.statusText}`,
1726-
);
1727-
}
1728-
return (await response.json()) as Schemas.FileSystemShortcut;
1729-
}
1730-
1731-
// Delete a desktop shortcut by id (used to unstar). A 404 means it's already
1732-
// gone, which is the desired end state, so we treat it as success.
1733-
async deleteDesktopFileSystemShortcut(id: string): Promise<void> {
1734-
const teamId = await this.getTeamId();
1735-
const urlPath = `/api/projects/${teamId}/desktop_file_system_shortcut/${encodeURIComponent(id)}/`;
1736-
const url = new URL(`${this.api.baseUrl}${urlPath}`);
1737-
const response = await this.api.fetcher.fetch({
1738-
method: "delete",
1739-
url,
1740-
path: urlPath,
1741-
});
1742-
if (!response.ok && response.status !== 404) {
1743-
throw new Error(
1744-
`Failed to delete desktop file system shortcut: ${response.statusText}`,
1745-
);
1746-
}
1747-
}
1748-
1749-
// Per-folder, versioned markdown instructions for a desktop folder. The
1750-
// endpoint is keyed on the FileSystem row id (must be `type === "folder"`).
1751-
// Returns the current latest version or null when none has been published.
1752-
async getDesktopFolderInstructions(
1753-
folderId: string,
1754-
): Promise<FolderInstructions | null> {
1755-
const teamId = await this.getTeamId();
1756-
const urlPath = `/api/projects/${teamId}/desktop_file_system/${encodeURIComponent(folderId)}/instructions/`;
1757-
const url = new URL(`${this.api.baseUrl}${urlPath}`);
1758-
const response = await this.api.fetcher.fetch({
1759-
method: "get",
1760-
url,
1761-
path: urlPath,
1762-
});
1763-
if (response.status === 404) return null;
1764-
if (!response.ok) {
1765-
throw new Error(
1766-
`Failed to fetch folder instructions: ${response.statusText}`,
1767-
);
1768-
}
1769-
return (await response.json()) as FolderInstructions;
1770-
}
1771-
1772-
// Publish a new version of the folder's instructions. Pass `base_version`
1773-
// (the latest version the editor was started from) for optimistic
1774-
// concurrency; use 0 when no instructions exist yet. A 409 turns into a
1775-
// typed `FolderInstructionsConflictError` so the UI can prompt to reload.
1776-
async putDesktopFolderInstructions(
1777-
folderId: string,
1778-
input: { content: string; base_version?: number },
1779-
): Promise<FolderInstructions> {
1780-
const teamId = await this.getTeamId();
1781-
const urlPath = `/api/projects/${teamId}/desktop_file_system/${encodeURIComponent(folderId)}/instructions/`;
1782-
const url = new URL(`${this.api.baseUrl}${urlPath}`);
1783-
const response = await this.api.fetcher.fetch({
1784-
method: "put",
1785-
url,
1786-
path: urlPath,
1787-
overrides: {
1788-
body: JSON.stringify(input),
1789-
},
1790-
});
1791-
if (response.status === 409) {
1792-
throw new FolderInstructionsConflictError();
1793-
}
1794-
if (!response.ok) {
1795-
throw new Error(
1796-
`Failed to publish folder instructions: ${response.statusText}`,
1797-
);
1798-
}
1799-
return (await response.json()) as FolderInstructions;
1800-
}
1801-
1802-
// Soft-delete all versions of this folder's instructions. The folder row
1803-
// itself is not affected.
1804-
async deleteDesktopFolderInstructions(folderId: string): Promise<void> {
1805-
const teamId = await this.getTeamId();
1806-
const urlPath = `/api/projects/${teamId}/desktop_file_system/${encodeURIComponent(folderId)}/instructions/`;
1807-
const url = new URL(`${this.api.baseUrl}${urlPath}`);
1808-
const response = await this.api.fetcher.fetch({
1809-
method: "delete",
1810-
url,
1811-
path: urlPath,
1812-
});
1813-
if (!response.ok && response.status !== 404) {
1814-
throw new Error(
1815-
`Failed to delete folder instructions: ${response.statusText}`,
1816-
);
1817-
}
1818-
}
1819-
1820-
// List version metadata (no content) newest-first. Single page is enough for
1821-
// the typical UI; we cap follow-up pages to avoid runaway pagination on
1822-
// pathological histories.
1823-
async listDesktopFolderInstructionVersions(
1824-
folderId: string,
1825-
): Promise<FolderInstructionsVersion[]> {
1826-
const VERSIONS_MAX_PAGES = 20;
1827-
const teamId = await this.getTeamId();
1828-
const all: FolderInstructionsVersion[] = [];
1829-
let urlPath = `/api/projects/${teamId}/desktop_file_system/${encodeURIComponent(folderId)}/instructions/versions/`;
1830-
for (let i = 0; i < VERSIONS_MAX_PAGES; i++) {
1831-
const url = new URL(`${this.api.baseUrl}${urlPath}`);
1832-
const response = await this.api.fetcher.fetch({
1833-
method: "get",
1834-
url,
1835-
path: urlPath,
1836-
});
1837-
if (!response.ok) {
1838-
throw new Error(
1839-
`Failed to fetch folder instruction versions: ${response.statusText}`,
1840-
);
1841-
}
1842-
const page =
1843-
(await response.json()) as PaginatedFolderInstructionsVersions;
1844-
all.push(...page.results);
1845-
if (!page.next) return all;
1846-
const nextUrl = new URL(page.next);
1847-
urlPath = `${nextUrl.pathname}${nextUrl.search}`;
1848-
}
1849-
log.warn(
1850-
`listDesktopFolderInstructionVersions hit MAX_PAGES (${VERSIONS_MAX_PAGES}); returning partial results`,
1851-
{ folderId, returned: all.length },
1852-
);
1853-
return all;
1854-
}
1855-
18561531
// The task currently generating this folder's CONTEXT.md, shared across the
18571532
// project so any user sees an in-progress generation (instead of fragile
18581533
// local state). Keyed on the folder row (which always exists), not the

packages/core/src/canvas/canvasDataService.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,11 +26,21 @@ const FALLBACK_DISTINCT_ID = "freeform-canvas";
2626
const MAX_CANVAS_RESULT_ROWS = 1_000;
2727
const MAX_CANVAS_RESULT_BYTES = 2 * 1024 * 1024;
2828

29+
const utf8Encoder = new TextEncoder();
30+
31+
// True when the JSON's UTF-8 encoding exceeds the byte limit. UTF-8 is 1–3
32+
// bytes per UTF-16 code unit, so the string length bounds the byte count from
33+
// both sides — only payloads in the ambiguous band pay for a full encode.
34+
function exceedsByteLimit(json: string): boolean {
35+
if (json.length > MAX_CANVAS_RESULT_BYTES) return true;
36+
if (json.length * 3 <= MAX_CANVAS_RESULT_BYTES) return false;
37+
return utf8Encoder.encode(json).byteLength > MAX_CANVAS_RESULT_BYTES;
38+
}
39+
2940
function boundedResult(result: CanvasDataResult): CanvasDataResult {
3041
if (
3142
result.results.length > MAX_CANVAS_RESULT_ROWS ||
32-
new TextEncoder().encode(JSON.stringify(result)).byteLength >
33-
MAX_CANVAS_RESULT_BYTES
43+
exceedsByteLimit(JSON.stringify(result))
3444
) {
3545
throw new Error("Canvas data result exceeds the result limit");
3646
}

packages/core/src/canvas/dashboardSchemas.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { CANVAS_PLATFORM_MANIFEST } from "@posthog/shared";
12
import { z } from "zod";
23

34
// A canvas record from the PostHog canvases API, normalized to camelCase and
@@ -49,7 +50,9 @@ export const canvasSourceProjectSchema = z.object({
4950
files: z.record(z.string(), z.string()),
5051
entryHtml: z.string(),
5152
dependencies: z.record(z.string(), z.string()).default({}),
52-
canvasSdkVersion: z.string().default("0.1.0"),
53+
canvasSdkVersion: z
54+
.string()
55+
.default(CANVAS_PLATFORM_MANIFEST.canvasSdkVersion),
5356
assets: z.record(z.string(), z.unknown()).optional(),
5457
capabilities: z.unknown().optional(),
5558
});

0 commit comments

Comments
 (0)