Skip to content

Commit ed8ad0e

Browse files
raquelmsmithclaude
andauthored
feat(canvas): per-channel CONTEXT.md replacing Settings entry (#2589)
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
1 parent c8bdcaf commit ed8ad0e

8 files changed

Lines changed: 663 additions & 61 deletions

File tree

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

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -249,6 +249,52 @@ export interface ExternalDataSource {
249249
schemas?: ExternalDataSourceSchema[] | string;
250250
}
251251

252+
export interface FolderInstructionsUser {
253+
id?: number;
254+
uuid?: string;
255+
first_name?: string;
256+
last_name?: string | null;
257+
email?: string;
258+
}
259+
260+
export interface FolderInstructions {
261+
id: string;
262+
content: string;
263+
version: number;
264+
is_latest: boolean;
265+
created_by: FolderInstructionsUser | null;
266+
created_at: string;
267+
updated_at: string;
268+
}
269+
270+
export interface FolderInstructionsVersion {
271+
id: string;
272+
version: number;
273+
is_latest: boolean;
274+
created_by: FolderInstructionsUser | null;
275+
created_at: string;
276+
}
277+
278+
interface PaginatedFolderInstructionsVersions {
279+
count: number;
280+
next: string | null;
281+
previous: string | null;
282+
results: FolderInstructionsVersion[];
283+
}
284+
285+
// Thrown when PUT /instructions/ rejects a publish because the caller's
286+
// `base_version` is older than the current latest. Callers can re-fetch and
287+
// retry against the new latest.
288+
export class FolderInstructionsConflictError extends Error {
289+
status = 409;
290+
constructor(
291+
message = "Folder instructions changed since you started editing",
292+
) {
293+
super(message);
294+
this.name = "FolderInstructionsConflictError";
295+
}
296+
}
297+
252298
export interface TaskArtifactUploadRequest {
253299
name: string;
254300
type: "user_attachment";
@@ -848,6 +894,113 @@ export class PostHogAPIClient {
848894
}
849895
}
850896

897+
// Per-folder, versioned markdown instructions for a desktop folder. The
898+
// endpoint is keyed on the FileSystem row id (must be `type === "folder"`).
899+
// Returns the current latest version or null when none has been published.
900+
async getDesktopFolderInstructions(
901+
folderId: string,
902+
): Promise<FolderInstructions | null> {
903+
const teamId = await this.getTeamId();
904+
const urlPath = `/api/projects/${teamId}/desktop_file_system/${encodeURIComponent(folderId)}/instructions/`;
905+
const url = new URL(`${this.api.baseUrl}${urlPath}`);
906+
const response = await this.api.fetcher.fetch({
907+
method: "get",
908+
url,
909+
path: urlPath,
910+
});
911+
if (response.status === 404) return null;
912+
if (!response.ok) {
913+
throw new Error(
914+
`Failed to fetch folder instructions: ${response.statusText}`,
915+
);
916+
}
917+
return (await response.json()) as FolderInstructions;
918+
}
919+
920+
// Publish a new version of the folder's instructions. Pass `base_version`
921+
// (the latest version the editor was started from) for optimistic
922+
// concurrency; use 0 when no instructions exist yet. A 409 turns into a
923+
// typed `FolderInstructionsConflictError` so the UI can prompt to reload.
924+
async putDesktopFolderInstructions(
925+
folderId: string,
926+
input: { content: string; base_version?: number },
927+
): Promise<FolderInstructions> {
928+
const teamId = await this.getTeamId();
929+
const urlPath = `/api/projects/${teamId}/desktop_file_system/${encodeURIComponent(folderId)}/instructions/`;
930+
const url = new URL(`${this.api.baseUrl}${urlPath}`);
931+
const response = await this.api.fetcher.fetch({
932+
method: "put",
933+
url,
934+
path: urlPath,
935+
overrides: {
936+
body: JSON.stringify(input),
937+
},
938+
});
939+
if (response.status === 409) {
940+
throw new FolderInstructionsConflictError();
941+
}
942+
if (!response.ok) {
943+
throw new Error(
944+
`Failed to publish folder instructions: ${response.statusText}`,
945+
);
946+
}
947+
return (await response.json()) as FolderInstructions;
948+
}
949+
950+
// Soft-delete all versions of this folder's instructions. The folder row
951+
// itself is not affected.
952+
async deleteDesktopFolderInstructions(folderId: string): Promise<void> {
953+
const teamId = await this.getTeamId();
954+
const urlPath = `/api/projects/${teamId}/desktop_file_system/${encodeURIComponent(folderId)}/instructions/`;
955+
const url = new URL(`${this.api.baseUrl}${urlPath}`);
956+
const response = await this.api.fetcher.fetch({
957+
method: "delete",
958+
url,
959+
path: urlPath,
960+
});
961+
if (!response.ok && response.status !== 404) {
962+
throw new Error(
963+
`Failed to delete folder instructions: ${response.statusText}`,
964+
);
965+
}
966+
}
967+
968+
// List version metadata (no content) newest-first. Single page is enough for
969+
// the typical UI; we cap follow-up pages to avoid runaway pagination on
970+
// pathological histories.
971+
async listDesktopFolderInstructionVersions(
972+
folderId: string,
973+
): Promise<FolderInstructionsVersion[]> {
974+
const VERSIONS_MAX_PAGES = 20;
975+
const teamId = await this.getTeamId();
976+
const all: FolderInstructionsVersion[] = [];
977+
let urlPath = `/api/projects/${teamId}/desktop_file_system/${encodeURIComponent(folderId)}/instructions/versions/`;
978+
for (let i = 0; i < VERSIONS_MAX_PAGES; i++) {
979+
const url = new URL(`${this.api.baseUrl}${urlPath}`);
980+
const response = await this.api.fetcher.fetch({
981+
method: "get",
982+
url,
983+
path: urlPath,
984+
});
985+
if (!response.ok) {
986+
throw new Error(
987+
`Failed to fetch folder instruction versions: ${response.statusText}`,
988+
);
989+
}
990+
const page =
991+
(await response.json()) as PaginatedFolderInstructionsVersions;
992+
all.push(...page.results);
993+
if (!page.next) return all;
994+
const nextUrl = new URL(page.next);
995+
urlPath = `${nextUrl.pathname}${nextUrl.search}`;
996+
}
997+
log.warn(
998+
`listDesktopFolderInstructionVersions hit MAX_PAGES (${VERSIONS_MAX_PAGES}); returning partial results`,
999+
{ folderId, returned: all.length },
1000+
);
1001+
return all;
1002+
}
1003+
8511004
async getGithubLogin(): Promise<string | null> {
8521005
const data = (await this.api.get("/api/users/{uuid}/github_login/", {
8531006
path: { uuid: "@me" },

packages/ui/src/features/canvas/components/ChannelsList.tsx

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import {
22
CodeIcon,
33
DotsThreeIcon,
44
FileIcon,
5+
FileTextIcon,
56
FolderIcon,
67
PencilSimpleIcon,
78
PlusIcon,
@@ -353,11 +354,12 @@ function ChannelSection({
353354
);
354355
})}
355356
<NavButton
356-
label="Settings"
357-
active={pathname.startsWith(`${base}/settings`)}
357+
label="CONTEXT.md"
358+
icon={<FileTextIcon size={14} className="text-gray-9" />}
359+
active={pathname.startsWith(`${base}/context`)}
358360
onClick={() =>
359361
navigate({
360-
to: "/website/$channelId/settings",
362+
to: "/website/$channelId/context",
361363
params: { channelId: channel.id },
362364
})
363365
}

0 commit comments

Comments
 (0)