diff --git a/packages/api-client/src/posthog-client.ts b/packages/api-client/src/posthog-client.ts index 85e127cba..e5762e38a 100644 --- a/packages/api-client/src/posthog-client.ts +++ b/packages/api-client/src/posthog-client.ts @@ -249,6 +249,52 @@ export interface ExternalDataSource { schemas?: ExternalDataSourceSchema[] | string; } +export interface FolderInstructionsUser { + id?: number; + uuid?: string; + first_name?: string; + last_name?: string | null; + email?: string; +} + +export interface FolderInstructions { + id: string; + content: string; + version: number; + is_latest: boolean; + created_by: FolderInstructionsUser | null; + created_at: string; + updated_at: string; +} + +export interface FolderInstructionsVersion { + id: string; + version: number; + is_latest: boolean; + created_by: FolderInstructionsUser | null; + created_at: string; +} + +interface PaginatedFolderInstructionsVersions { + count: number; + next: string | null; + previous: string | null; + results: FolderInstructionsVersion[]; +} + +// Thrown when PUT /instructions/ rejects a publish because the caller's +// `base_version` is older than the current latest. Callers can re-fetch and +// retry against the new latest. +export class FolderInstructionsConflictError extends Error { + status = 409; + constructor( + message = "Folder instructions changed since you started editing", + ) { + super(message); + this.name = "FolderInstructionsConflictError"; + } +} + export interface TaskArtifactUploadRequest { name: string; type: "user_attachment"; @@ -848,6 +894,113 @@ export class PostHogAPIClient { } } + // Per-folder, versioned markdown instructions for a desktop folder. The + // endpoint is keyed on the FileSystem row id (must be `type === "folder"`). + // Returns the current latest version or null when none has been published. + async getDesktopFolderInstructions( + folderId: string, + ): Promise { + const teamId = await this.getTeamId(); + const urlPath = `/api/projects/${teamId}/desktop_file_system/${encodeURIComponent(folderId)}/instructions/`; + const url = new URL(`${this.api.baseUrl}${urlPath}`); + const response = await this.api.fetcher.fetch({ + method: "get", + url, + path: urlPath, + }); + if (response.status === 404) return null; + if (!response.ok) { + throw new Error( + `Failed to fetch folder instructions: ${response.statusText}`, + ); + } + return (await response.json()) as FolderInstructions; + } + + // Publish a new version of the folder's instructions. Pass `base_version` + // (the latest version the editor was started from) for optimistic + // concurrency; use 0 when no instructions exist yet. A 409 turns into a + // typed `FolderInstructionsConflictError` so the UI can prompt to reload. + async putDesktopFolderInstructions( + folderId: string, + input: { content: string; base_version?: number }, + ): Promise { + const teamId = await this.getTeamId(); + const urlPath = `/api/projects/${teamId}/desktop_file_system/${encodeURIComponent(folderId)}/instructions/`; + const url = new URL(`${this.api.baseUrl}${urlPath}`); + const response = await this.api.fetcher.fetch({ + method: "put", + url, + path: urlPath, + overrides: { + body: JSON.stringify(input), + }, + }); + if (response.status === 409) { + throw new FolderInstructionsConflictError(); + } + if (!response.ok) { + throw new Error( + `Failed to publish folder instructions: ${response.statusText}`, + ); + } + return (await response.json()) as FolderInstructions; + } + + // Soft-delete all versions of this folder's instructions. The folder row + // itself is not affected. + async deleteDesktopFolderInstructions(folderId: string): Promise { + const teamId = await this.getTeamId(); + const urlPath = `/api/projects/${teamId}/desktop_file_system/${encodeURIComponent(folderId)}/instructions/`; + const url = new URL(`${this.api.baseUrl}${urlPath}`); + const response = await this.api.fetcher.fetch({ + method: "delete", + url, + path: urlPath, + }); + if (!response.ok && response.status !== 404) { + throw new Error( + `Failed to delete folder instructions: ${response.statusText}`, + ); + } + } + + // List version metadata (no content) newest-first. Single page is enough for + // the typical UI; we cap follow-up pages to avoid runaway pagination on + // pathological histories. + async listDesktopFolderInstructionVersions( + folderId: string, + ): Promise { + const VERSIONS_MAX_PAGES = 20; + const teamId = await this.getTeamId(); + const all: FolderInstructionsVersion[] = []; + let urlPath = `/api/projects/${teamId}/desktop_file_system/${encodeURIComponent(folderId)}/instructions/versions/`; + for (let i = 0; i < VERSIONS_MAX_PAGES; i++) { + const url = new URL(`${this.api.baseUrl}${urlPath}`); + const response = await this.api.fetcher.fetch({ + method: "get", + url, + path: urlPath, + }); + if (!response.ok) { + throw new Error( + `Failed to fetch folder instruction versions: ${response.statusText}`, + ); + } + const page = + (await response.json()) as PaginatedFolderInstructionsVersions; + all.push(...page.results); + if (!page.next) return all; + const nextUrl = new URL(page.next); + urlPath = `${nextUrl.pathname}${nextUrl.search}`; + } + log.warn( + `listDesktopFolderInstructionVersions hit MAX_PAGES (${VERSIONS_MAX_PAGES}); returning partial results`, + { folderId, returned: all.length }, + ); + return all; + } + async getGithubLogin(): Promise { const data = (await this.api.get("/api/users/{uuid}/github_login/", { path: { uuid: "@me" }, diff --git a/packages/ui/src/features/canvas/components/ChannelsList.tsx b/packages/ui/src/features/canvas/components/ChannelsList.tsx index bfe14a712..881f8671d 100644 --- a/packages/ui/src/features/canvas/components/ChannelsList.tsx +++ b/packages/ui/src/features/canvas/components/ChannelsList.tsx @@ -2,6 +2,7 @@ import { CodeIcon, DotsThreeIcon, FileIcon, + FileTextIcon, FolderIcon, PencilSimpleIcon, PlusIcon, @@ -353,11 +354,12 @@ function ChannelSection({ ); })} } + active={pathname.startsWith(`${base}/context`)} onClick={() => navigate({ - to: "/website/$channelId/settings", + to: "/website/$channelId/context", params: { channelId: channel.id }, }) } diff --git a/packages/ui/src/features/canvas/components/WebsiteContext.tsx b/packages/ui/src/features/canvas/components/WebsiteContext.tsx new file mode 100644 index 000000000..f9cef94f2 --- /dev/null +++ b/packages/ui/src/features/canvas/components/WebsiteContext.tsx @@ -0,0 +1,353 @@ +import { FileTextIcon, HashIcon } from "@phosphor-icons/react"; +import { FolderInstructionsConflictError } from "@posthog/api-client/posthog-client"; +import { useChannels } from "@posthog/ui/features/canvas/hooks/useChannels"; +import { + useFolderInstructions, + useFolderInstructionsMutations, + useFolderInstructionsVersions, +} from "@posthog/ui/features/canvas/hooks/useFolderInstructions"; +import { MarkdownRenderer } from "@posthog/ui/features/editor/components/MarkdownRenderer"; +import { useSetHeaderContent } from "@posthog/ui/hooks/useSetHeaderContent"; +import { + Box, + Button, + Callout, + Flex, + ScrollArea, + SegmentedControl, + Select, + Spinner, + Text, + TextArea, +} from "@radix-ui/themes"; +import { useEffect, useMemo, useState } from "react"; + +type Mode = "rendered" | "edit"; + +// Initial markdown shown when a folder has no instructions yet — gives both +// humans and agents a structural starting point instead of a blank screen. +const EMPTY_TEMPLATE = "# Folder context\n\nDescribe what lives here.\n"; + +interface WebsiteContextProps { + channelId: string; +} + +export function WebsiteContext({ channelId }: WebsiteContextProps) { + // Resolve the channel name from the cached channels list, so we don't make + // a second network call just for the header label. + const { channels } = useChannels(); + const channel = useMemo( + () => channels.find((c) => c.id === channelId) ?? null, + [channels, channelId], + ); + + const { + data: latest, + isLoading: isLoadingLatest, + isFetching: isFetchingLatest, + error: latestError, + } = useFolderInstructions(channelId); + + const { data: versions = [], isLoading: isLoadingVersions } = + useFolderInstructionsVersions(channelId); + + const { publish, isPublishing, publishError } = + useFolderInstructionsMutations(channelId); + + const [mode, setMode] = useState("rendered"); + const [draft, setDraft] = useState(""); + const [hasDraft, setHasDraft] = useState(false); + + // Seed the editor draft from the latest content the first time we land on + // edit mode (or whenever latest changes while we're not actively editing). + // We don't blow away an in-flight edit just because the cache refetched. + useEffect(() => { + if (hasDraft) return; + setDraft(latest?.content ?? ""); + }, [latest?.content, hasDraft]); + + const channelName = channel?.name ?? "Channel"; + const headerContent = useMemo( + () => ( + + + + {channelName} + + / + + + CONTEXT.md + + + ), + [channelName], + ); + useSetHeaderContent(headerContent); + + const onSave = async () => { + try { + await publish({ + content: draft, + // base_version=0 signals "no prior version" to the optimistic + // concurrency check; otherwise we send the version we started from. + baseVersion: latest?.version ?? 0, + }); + setHasDraft(false); + setMode("rendered"); + } catch { + // Errors surface through `publishError` below; nothing to do here. + } + }; + + const isConflict = publishError instanceof FolderInstructionsConflictError; + + // Allow inspecting an older version read-only. When `null`, we're showing + // either the latest (rendered/edit) or the empty state. + const [selectedVersionId, setSelectedVersionId] = useState( + null, + ); + + // Picking a past version forces rendered mode and shows that version's + // metadata; we don't currently fetch the historical content body, so the + // viewer falls back to "Open latest in editor" when there is no body. + // (Backend exposes content only via the `latest` endpoint today.) + const selectedVersion = useMemo(() => { + if (!selectedVersionId) return null; + return versions.find((v) => v.id === selectedVersionId) ?? null; + }, [selectedVersionId, versions]); + + if (isLoadingLatest) { + return ( + + + + ); + } + + if (latestError) { + return ( + + + + Failed to load folder instructions: {latestError.message} + + + + ); + } + + // Treat `null` (404: never published), `undefined` (query disabled), AND a + // row with whitespace-only content as "no instructions" so we render the + // empty state — otherwise MarkdownRenderer paints an invisible empty block + // and the page looks blank. + const renderedContent = latest?.content ?? ""; + const hasInstructions = renderedContent.trim().length > 0; + + return ( + + + + setMode(value as Mode)} + size="1" + > + + Rendered + + Edit + + + {/* Background-refetch indicator: the initial load uses the full-screen + spinner below; this only fires on revalidations (every mount, plus + after publish/delete invalidations) so the user knows the view is + live and not just stale cache. */} + {isFetchingLatest && !isLoadingLatest ? ( + + + Refreshing… + + ) : null} + + {versions.length > 0 ? ( + { + if (value === "latest") { + setSelectedVersionId(null); + } else { + setSelectedVersionId(value); + setMode("rendered"); + } + }} + disabled={isLoadingVersions} + > + + + + Latest (v{latest?.version ?? "—"}) + + {versions + .filter((v) => !v.is_latest) + .map((v) => ( + + v{v.version} · {formatTimestamp(v.created_at)} + + ))} + + + ) : null} + + + {mode === "edit" ? ( + + {hasDraft ? ( + + ) : null} + + + ) : null} + + + {publishError ? ( + + + + {isConflict + ? "Someone else saved a newer version. Reload to merge your changes." + : `Save failed: ${publishError.message}`} + + + + ) : null} + + + + {selectedVersion ? ( + + + Viewing v{selectedVersion.version} metadata. Past content is not + fetched today — switch to "Latest" to read or edit current + content. + + + ) : mode === "rendered" ? ( + hasInstructions ? ( + + + + ) : ( + { + setDraft(EMPTY_TEMPLATE); + setHasDraft(true); + setMode("edit"); + }} + /> + ) + ) : ( +