diff --git a/apps/code/src/main/services/context-menu/schemas.ts b/apps/code/src/main/services/context-menu/schemas.ts index 9620d3ba87..c72bcbcac0 100644 --- a/apps/code/src/main/services/context-menu/schemas.ts +++ b/apps/code/src/main/services/context-menu/schemas.ts @@ -8,6 +8,9 @@ export const taskContextMenuInput = z.object({ isSuspended: z.boolean().optional(), isInCommandCenter: z.boolean().optional(), hasEmptyCommandCenterCell: z.boolean().optional(), + fileToFolders: z + .array(z.object({ id: z.string(), path: z.string() })) + .optional(), }); export const bulkTaskContextMenuInput = z.object({ @@ -47,6 +50,7 @@ const taskAction = z.discriminatedUnion("type", [ z.object({ type: z.literal("delete") }), z.object({ type: z.literal("add-to-command-center") }), z.object({ type: z.literal("external-app"), action: externalAppAction }), + z.object({ type: z.literal("file-to"), folderPath: z.string() }), ]); const bulkTaskAction = z.discriminatedUnion("type", [ diff --git a/apps/code/src/main/services/context-menu/service.ts b/apps/code/src/main/services/context-menu/service.ts index 93376654c7..ba5e5a13a8 100644 --- a/apps/code/src/main/services/context-menu/service.ts +++ b/apps/code/src/main/services/context-menu/service.ts @@ -113,13 +113,32 @@ export class ContextMenuService { isSuspended, isInCommandCenter, hasEmptyCommandCenterCell, + fileToFolders, } = input; const { apps, lastUsedAppId } = await this.getExternalAppsData(); const hasPath = worktreePath || folderPath; + const fileToItems: MenuItemDef[] = + fileToFolders && fileToFolders.length > 0 + ? [ + this.separator(), + { + type: "submenu", + label: "File to...", + items: fileToFolders.map((folder) => ({ + label: folder.path, + action: { + type: "file-to" as const, + folderPath: folder.path, + }, + })), + }, + ] + : []; return this.showMenu([ this.item(isPinned ? "Unpin" : "Pin", { type: "pin" }), this.item("Rename", { type: "rename" }), + ...fileToItems, ...(worktreePath ? [ this.separator(), diff --git a/apps/code/src/renderer/api/posthogClient.ts b/apps/code/src/renderer/api/posthogClient.ts index 1647e1e136..41c4cf9b14 100644 --- a/apps/code/src/renderer/api/posthogClient.ts +++ b/apps/code/src/renderer/api/posthogClient.ts @@ -134,6 +134,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"; @@ -958,6 +1004,223 @@ export class PostHogAPIClient { return all; } + // The desktop file system tree lives on its own server-controlled "desktop" + // surface, served from a route that is not in the generated OpenAPI client, + // so we use the raw fetcher and follow pagination manually. + async getDesktopFileSystem(): Promise { + const DESKTOP_FILE_SYSTEM_MAX_PAGES = 50; + const teamId = await this.getTeamId(); + const all: Schemas.FileSystem[] = []; + let urlPath: string = `/api/projects/${teamId}/desktop_file_system/`; + for (let i = 0; i < DESKTOP_FILE_SYSTEM_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 desktop file system: ${response.statusText}`, + ); + } + const page = (await response.json()) as Schemas.PaginatedFileSystemList; + all.push(...page.results); + if (!page.next) return all; + const nextUrl = new URL(page.next); + urlPath = `${nextUrl.pathname}${nextUrl.search}`; + } + log.warn( + `getDesktopFileSystem hit MAX_PAGES (${DESKTOP_FILE_SYSTEM_MAX_PAGES}); returning partial results`, + { returned: all.length }, + ); + return all; + } + + // Create a top-level channel (a folder row whose path is a single segment) on + // the desktop file system surface. Uses the raw fetcher for the same reason as + // getDesktopFileSystem: this route is not in the generated OpenAPI client. + async createDesktopFileSystemChannel( + name: string, + ): Promise { + const teamId = await this.getTeamId(); + const urlPath = `/api/projects/${teamId}/desktop_file_system/`; + const url = new URL(`${this.api.baseUrl}${urlPath}`); + const response = await this.api.fetcher.fetch({ + method: "post", + url, + path: urlPath, + overrides: { + body: JSON.stringify({ path: name, type: "folder", depth: 1 }), + }, + }); + if (!response.ok) { + throw new Error( + `Failed to create desktop file system channel: ${response.statusText}`, + ); + } + return (await response.json()) as Schemas.FileSystem; + } + + // Create a leaf file system entry (e.g. filing a task under a channel folder) + // on the desktop surface. `path` is slash-delimited and includes the parent + // folder path; `ref` links the entry back to its source domain object. + async createDesktopFileSystemEntry(input: { + path: string; + type: string; + ref?: string; + href?: string; + }): Promise { + const teamId = await this.getTeamId(); + const urlPath = `/api/projects/${teamId}/desktop_file_system/`; + const url = new URL(`${this.api.baseUrl}${urlPath}`); + const depth = input.path.split("/").filter((s) => s.length > 0).length; + const response = await this.api.fetcher.fetch({ + method: "post", + url, + path: urlPath, + overrides: { + body: JSON.stringify({ + path: input.path, + type: input.type, + depth, + ref: input.ref, + href: input.href, + }), + }, + }); + if (!response.ok) { + throw new Error( + `Failed to create desktop file system entry: ${response.statusText}`, + ); + } + return (await response.json()) as Schemas.FileSystem; + } + + // Delete a desktop file system entry by id (used to remove top-level channels). + async deleteDesktopFileSystem(id: string): Promise { + const teamId = await this.getTeamId(); + const urlPath = `/api/projects/${teamId}/desktop_file_system/${encodeURIComponent(id)}/`; + 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 desktop file system channel: ${response.statusText}`, + ); + } + } + + // 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 getTask(taskId: string) { const teamId = await this.getTeamId(); const data = await this.api.get(`/api/projects/{project_id}/tasks/{id}/`, { diff --git a/apps/code/src/renderer/features/folder-context/components/FolderContextView.tsx b/apps/code/src/renderer/features/folder-context/components/FolderContextView.tsx new file mode 100644 index 0000000000..dbe1f5b499 --- /dev/null +++ b/apps/code/src/renderer/features/folder-context/components/FolderContextView.tsx @@ -0,0 +1,349 @@ +import { MarkdownRenderer } from "@features/editor/components/MarkdownRenderer"; +import { + useFolderInstructions, + useFolderInstructionsMutations, + useFolderInstructionsVersions, +} from "@features/folder-context/hooks/useFolderInstructions"; +import { useDesktopFileSystem } from "@features/sidebar/hooks/useDesktopFileSystem"; +import { useSetHeaderContent } from "@hooks/useSetHeaderContent"; +import { FileText, Hash } from "@phosphor-icons/react"; +import { + Box, + Button, + Callout, + Flex, + ScrollArea, + SegmentedControl, + Select, + Spinner, + Text, + TextArea, +} from "@radix-ui/themes"; +import { FolderInstructionsConflictError } from "@renderer/api/posthogClient"; +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 FolderContextViewProps { + folderId: string; +} + +export function FolderContextView({ folderId }: FolderContextViewProps) { + // Resolve the folder display name from the cached desktop file system list, + // so we don't make a second network call just for the header label. + const { data: items = [] } = useDesktopFileSystem(); + const folder = useMemo( + () => items.find((item) => item.id === folderId) ?? null, + [items, folderId], + ); + + const { + data: latest, + isLoading: isLoadingLatest, + isFetching: isFetchingLatest, + error: latestError, + } = useFolderInstructions(folderId); + + const { data: versions = [], isLoading: isLoadingVersions } = + useFolderInstructionsVersions(folderId); + + const { publish, isPublishing, publishError } = + useFolderInstructionsMutations(folderId); + + 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 headerContent = useMemo( + () => ( + + + + {folder?.path ?? "Folder"} + + / + + + CONTEXT.md + + + ), + [folder?.path], + ); + 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"); + }} + /> + ) + ) : ( +