From a42ffcfbe23e97982ae5d2bec2cf0475fccacc47 Mon Sep 17 00:00:00 2001 From: Raquel Smith Date: Tue, 2 Jun 2026 14:28:57 -0700 Subject: [PATCH 1/6] feat(sidebar): add feature-flagged file-system tree sidebar Behind the `posthog-code-file-system-sidebar` flag (force-enabled in dev), replace the sidebar's repo/task list with a tree of items from the PostHog file-system "desktop" surface (PostHog PR #61047). Folders render with a `#` icon and are expandable like the current repo sections; leaf items render as inert rows for now. - posthogClient: getDesktopFileSystem() hits the server-controlled /api/projects/{id}/desktop_file_system/ route via the raw fetcher with pagination-following (route isn't in the generated OpenAPI client). - buildFileSystemTree(): pure util turning the flat list into a nested tree (derives intermediate folders, attaches explicit folder rows, guards empty segments, sorts folders-first then alphabetically) + unit tests. - useDesktopFileSystem(): useAuthenticatedQuery wrapper mirroring useTaskSummaries. - FileSystemTreeView: recursive renderer reusing SidebarSection (folders) and SidebarItem (leaves), with empty state and fs:-namespaced collapse keys. - SidebarSection: additive `depth` prop for nested indentation (depth=0 keeps the existing TaskListView rendering unchanged). - SidebarMenu: flag branch between the new tree view and TaskListView. Generated-By: PostHog Code Task-Id: 26a4498f-f1b9-405b-a439-56af14877c6d --- apps/code/src/renderer/api/posthogClient.ts | 33 ++++++ .../sidebar/components/FileSystemTreeView.tsx | 88 +++++++++++++++ .../sidebar/components/SidebarMenu.tsx | 25 ++++- .../sidebar/components/SidebarSection.tsx | 9 +- .../sidebar/hooks/useDesktopFileSystem.ts | 15 +++ .../sidebar/utils/fileSystemTree.test.ts | 101 +++++++++++++++++ .../features/sidebar/utils/fileSystemTree.ts | 106 ++++++++++++++++++ apps/code/src/shared/constants.ts | 1 + 8 files changed, 376 insertions(+), 2 deletions(-) create mode 100644 apps/code/src/renderer/features/sidebar/components/FileSystemTreeView.tsx create mode 100644 apps/code/src/renderer/features/sidebar/hooks/useDesktopFileSystem.ts create mode 100644 apps/code/src/renderer/features/sidebar/utils/fileSystemTree.test.ts create mode 100644 apps/code/src/renderer/features/sidebar/utils/fileSystemTree.ts diff --git a/apps/code/src/renderer/api/posthogClient.ts b/apps/code/src/renderer/api/posthogClient.ts index 150b501901..7a686a6c86 100644 --- a/apps/code/src/renderer/api/posthogClient.ts +++ b/apps/code/src/renderer/api/posthogClient.ts @@ -957,6 +957,39 @@ 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; + } + 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/sidebar/components/FileSystemTreeView.tsx b/apps/code/src/renderer/features/sidebar/components/FileSystemTreeView.tsx new file mode 100644 index 0000000000..ce943f1042 --- /dev/null +++ b/apps/code/src/renderer/features/sidebar/components/FileSystemTreeView.tsx @@ -0,0 +1,88 @@ +import { Hash } from "@phosphor-icons/react"; +import { Flex, Text } from "@radix-ui/themes"; +import { useSidebarStore } from "../stores/sidebarStore"; +import type { FileSystemTreeNode } from "../utils/fileSystemTree"; +import { SidebarItem } from "./SidebarItem"; +import { SidebarSection } from "./SidebarSection"; + +// Persisted collapse state is shared with the task view's repo groups; namespace +// file system keys so the two never collide. +const collapseKey = (path: string) => `fs:${path}`; + +// Cap the visual indent so deeply nested paths don't push labels off-screen, +// while keeping the true depth available for keys. +const MAX_VISUAL_DEPTH = 6; + +interface FileSystemTreeNodeRowProps { + node: FileSystemTreeNode; + depth: number; + collapsedSections: Set; + toggleSection: (id: string) => void; +} + +function FileSystemTreeNodeRow({ + node, + depth, + collapsedSections, + toggleSection, +}: FileSystemTreeNodeRowProps) { + const visualDepth = Math.min(depth, MAX_VISUAL_DEPTH); + + if (!node.isFolder) { + // Leaf rows are inert for now — a click hook is intentionally left unwired. + return ; + } + + const key = collapseKey(node.path); + const isExpanded = !collapsedSections.has(key); + + return ( + } + depth={visualDepth} + isExpanded={isExpanded} + onToggle={() => toggleSection(key)} + addSpacingBefore={false} + tooltipContent={node.path} + > + {node.children.map((child) => ( + + ))} + + ); +} + +export function FileSystemTreeView({ nodes }: { nodes: FileSystemTreeNode[] }) { + const collapsedSections = useSidebarStore((state) => state.collapsedSections); + const toggleSection = useSidebarStore((state) => state.toggleSection); + + if (nodes.length === 0) { + return ( + + Nothing here yet + + ); + } + + return ( + + {nodes.map((node) => ( + + ))} + + ); +} diff --git a/apps/code/src/renderer/features/sidebar/components/SidebarMenu.tsx b/apps/code/src/renderer/features/sidebar/components/SidebarMenu.tsx index 89ff09e1c4..0ee49a589f 100644 --- a/apps/code/src/renderer/features/sidebar/components/SidebarMenu.tsx +++ b/apps/code/src/renderer/features/sidebar/components/SidebarMenu.tsx @@ -12,10 +12,12 @@ import { } from "@features/tasks/hooks/useArchiveTask"; import { useRenameTask, useTasks } from "@features/tasks/hooks/useTasks"; import { useWorkspaces } from "@features/workspace/hooks/useWorkspace"; +import { useFeatureFlag } from "@hooks/useFeatureFlag"; import { useTaskContextMenu } from "@hooks/useTaskContextMenu"; import { ScrollArea, Separator } from "@posthog/quill"; import { Box, Flex } from "@radix-ui/themes"; import { trpcClient } from "@renderer/trpc/client"; +import { FILE_SYSTEM_SIDEBAR_FLAG } from "@shared/constants"; import type { Task } from "@shared/types"; import { useCommandMenuStore } from "@stores/commandMenuStore"; import { useNavigationStore } from "@stores/navigationStore"; @@ -24,11 +26,14 @@ import { useQueryClient } from "@tanstack/react-query"; import { logger } from "@utils/logger"; import { toast } from "@utils/toast"; import { memo, useCallback, useEffect, useMemo, useRef } from "react"; +import { useDesktopFileSystem } from "../hooks/useDesktopFileSystem"; import { usePinnedTasks } from "../hooks/usePinnedTasks"; import { useSidebarData } from "../hooks/useSidebarData"; import { useTaskViewed } from "../hooks/useTaskViewed"; import { useSidebarStore } from "../stores/sidebarStore"; import { useTaskSelectionStore } from "../stores/taskSelectionStore"; +import { buildFileSystemTree } from "../utils/fileSystemTree"; +import { FileSystemTreeView } from "./FileSystemTreeView"; import { CommandCenterItem } from "./items/CommandCenterItem"; import { InboxItem, NewTaskItem } from "./items/HomeItem"; import { McpServersItem } from "./items/McpServersItem"; @@ -68,6 +73,13 @@ function SidebarMenuComponent() { const sidebarData = useSidebarData({ activeView: view, }); + + const useFsSidebar = + useFeatureFlag(FILE_SYSTEM_SIDEBAR_FLAG) || import.meta.env.DEV; + const { data: fsItems = [], isLoading: fsLoading } = useDesktopFileSystem({ + enabled: useFsSidebar, + }); + const fsTree = useMemo(() => buildFileSystemTree(fsItems), [fsItems]); const inboxPollingActive = useRendererWindowFocusStore((s) => s.focused); const { data: inboxProbe } = useInboxReports( { status: INBOX_PIPELINE_STATUS_FILTER }, @@ -406,7 +418,18 @@ function SidebarMenuComponent() { - {sidebarData.isLoading ? ( + {useFsSidebar ? ( + fsLoading ? ( + } + label="Loading..." + disabled + /> + ) : ( + + ) + ) : sidebarData.isLoading ? ( } diff --git a/apps/code/src/renderer/features/sidebar/components/SidebarSection.tsx b/apps/code/src/renderer/features/sidebar/components/SidebarSection.tsx index c0efbb7292..ae339ed76e 100644 --- a/apps/code/src/renderer/features/sidebar/components/SidebarSection.tsx +++ b/apps/code/src/renderer/features/sidebar/components/SidebarSection.tsx @@ -4,6 +4,10 @@ import { Button } from "@posthog/quill"; import * as Collapsible from "@radix-ui/react-collapsible"; import { useState } from "react"; +// Mirrors SidebarItem's indent math so folder headers and leaf rows align at +// each nesting level. +const INDENT_SIZE = 8; + interface SidebarSectionProps { id: string; label: string; @@ -12,6 +16,7 @@ interface SidebarSectionProps { onToggle: () => void; children: React.ReactNode; addSpacingBefore?: boolean; + depth?: number; onContextMenu?: (e: React.MouseEvent) => void; tooltipContent?: string; onNewTask?: () => void; @@ -26,6 +31,7 @@ export function SidebarSection({ onToggle, children, addSpacingBefore, + depth = 0, onContextMenu, tooltipContent, onNewTask, @@ -40,8 +46,9 @@ export function SidebarSection({ + ); + })} + + ); +} diff --git a/apps/code/src/renderer/features/sidebar/stores/sidebarStore.ts b/apps/code/src/renderer/features/sidebar/stores/sidebarStore.ts index b87d80c2f5..994ab86718 100644 --- a/apps/code/src/renderer/features/sidebar/stores/sidebarStore.ts +++ b/apps/code/src/renderer/features/sidebar/stores/sidebarStore.ts @@ -14,6 +14,7 @@ interface SidebarStoreState { sortMode: "updated" | "created"; showAllUsers: boolean; showInternal: boolean; + activePanel: "tasks" | "files"; } interface SidebarStoreActions { @@ -32,6 +33,7 @@ interface SidebarStoreActions { setSortMode: (mode: SidebarStoreState["sortMode"]) => void; setShowAllUsers: (showAllUsers: boolean) => void; setShowInternal: (showInternal: boolean) => void; + setActivePanel: (panel: SidebarStoreState["activePanel"]) => void; } type SidebarStore = SidebarStoreState & SidebarStoreActions; @@ -50,6 +52,7 @@ export const useSidebarStore = create()( sortMode: "updated", showAllUsers: false, showInternal: false, + activePanel: "files", setOpen: (open) => set({ open, hasUserSetOpen: true }), setOpenAuto: (open) => set((state) => (state.hasUserSetOpen ? state : { open })), @@ -100,6 +103,7 @@ export const useSidebarStore = create()( setSortMode: (sortMode) => set({ sortMode }), setShowAllUsers: (showAllUsers) => set({ showAllUsers }), setShowInternal: (showInternal) => set({ showInternal }), + setActivePanel: (activePanel) => set({ activePanel }), }), { name: "sidebar-storage", @@ -114,6 +118,7 @@ export const useSidebarStore = create()( sortMode: state.sortMode, showAllUsers: state.showAllUsers, showInternal: state.showInternal, + activePanel: state.activePanel, }), merge: (persisted, current) => { const persistedState = persisted as { @@ -127,6 +132,7 @@ export const useSidebarStore = create()( sortMode?: SidebarStoreState["sortMode"]; showAllUsers?: boolean; showInternal?: boolean; + activePanel?: SidebarStoreState["activePanel"]; }; return { ...current, @@ -145,6 +151,7 @@ export const useSidebarStore = create()( sortMode: persistedState.sortMode ?? current.sortMode, showAllUsers: persistedState.showAllUsers ?? current.showAllUsers, showInternal: persistedState.showInternal ?? current.showInternal, + activePanel: persistedState.activePanel ?? current.activePanel, }; }, }, From b7b0008ca7a0e22dcbf753a944e4298b7515e1ff Mon Sep 17 00:00:00 2001 From: Raquel Smith Date: Wed, 3 Jun 2026 13:15:41 -0700 Subject: [PATCH 3/6] feat(sidebar): rename Files panel to Channels with add/delete Rename the file-system sidebar panel label from "Files" to "Channels" (keeping the persisted "files" panel value), and add the ability to create and delete top-level channels synced to the cloud desktop_file_system surface. - SidebarPanelToggle: label "Channels" with Hash icon - posthogClient: createDesktopFileSystemChannel / deleteDesktopFileSystem - useDesktopFileSystemMutations: create/delete with query invalidation - ChannelsHeader: inline add-channel input - SidebarSection: generic hover delete action - FileSystemTreeView: delete top-level channels behind a confirmation Generated-By: PostHog Code Task-Id: b3c22a1c-8b74-4790-b7a1-673715dee381 --- apps/code/src/renderer/api/posthogClient.ts | 42 +++++++++ .../sidebar/components/ChannelsHeader.tsx | 75 +++++++++++++++ .../sidebar/components/FileSystemTreeView.tsx | 94 ++++++++++++++++--- .../sidebar/components/SidebarMenu.tsx | 26 +++-- .../sidebar/components/SidebarPanelToggle.tsx | 7 +- .../sidebar/components/SidebarSection.tsx | 35 ++++++- .../sidebar/hooks/useDesktopFileSystem.ts | 53 ++++++++++- 7 files changed, 305 insertions(+), 27 deletions(-) create mode 100644 apps/code/src/renderer/features/sidebar/components/ChannelsHeader.tsx diff --git a/apps/code/src/renderer/api/posthogClient.ts b/apps/code/src/renderer/api/posthogClient.ts index 7a686a6c86..cf2e4e8bfb 100644 --- a/apps/code/src/renderer/api/posthogClient.ts +++ b/apps/code/src/renderer/api/posthogClient.ts @@ -990,6 +990,48 @@ export class PostHogAPIClient { 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; + } + + // 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}`, + ); + } + } + 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/sidebar/components/ChannelsHeader.tsx b/apps/code/src/renderer/features/sidebar/components/ChannelsHeader.tsx new file mode 100644 index 0000000000..f3a09044ab --- /dev/null +++ b/apps/code/src/renderer/features/sidebar/components/ChannelsHeader.tsx @@ -0,0 +1,75 @@ +import { Plus } from "@phosphor-icons/react"; +import { Flex, IconButton, Text, TextField } from "@radix-ui/themes"; +import { useState } from "react"; +import { useDesktopFileSystemMutations } from "../hooks/useDesktopFileSystem"; + +// Header above the channel tree with an inline "add channel" affordance. The +// add form is purely local UI state; the create itself goes through the cloud +// mutation hook. +export function ChannelsHeader() { + const { createChannel, isCreating } = useDesktopFileSystemMutations(); + const [isAdding, setIsAdding] = useState(false); + const [draft, setDraft] = useState(""); + + const cancel = () => { + setIsAdding(false); + setDraft(""); + }; + + const submit = async () => { + const name = draft.trim(); + if (!name) { + cancel(); + return; + } + try { + await createChannel(name); + cancel(); + } catch { + // Keep the input open so the user can retry; the mutation surfaces the error. + } + }; + + return ( + + + + Channels + + setIsAdding(true)} + > + + + + {isAdding && ( + setDraft(e.target.value)} + onBlur={() => { + if (!draft.trim()) cancel(); + }} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + void submit(); + } else if (e.key === "Escape") { + e.preventDefault(); + cancel(); + } + }} + /> + )} + + ); +} diff --git a/apps/code/src/renderer/features/sidebar/components/FileSystemTreeView.tsx b/apps/code/src/renderer/features/sidebar/components/FileSystemTreeView.tsx index ce943f1042..b92fe7e338 100644 --- a/apps/code/src/renderer/features/sidebar/components/FileSystemTreeView.tsx +++ b/apps/code/src/renderer/features/sidebar/components/FileSystemTreeView.tsx @@ -1,5 +1,7 @@ import { Hash } from "@phosphor-icons/react"; -import { Flex, Text } from "@radix-ui/themes"; +import { AlertDialog, Button, Flex, Spinner, Text } from "@radix-ui/themes"; +import { useState } from "react"; +import { useDesktopFileSystemMutations } from "../hooks/useDesktopFileSystem"; import { useSidebarStore } from "../stores/sidebarStore"; import type { FileSystemTreeNode } from "../utils/fileSystemTree"; import { SidebarItem } from "./SidebarItem"; @@ -18,6 +20,7 @@ interface FileSystemTreeNodeRowProps { depth: number; collapsedSections: Set; toggleSection: (id: string) => void; + onDeleteChannel: (node: FileSystemTreeNode) => void; } function FileSystemTreeNodeRow({ @@ -25,6 +28,7 @@ function FileSystemTreeNodeRow({ depth, collapsedSections, toggleSection, + onDeleteChannel, }: FileSystemTreeNodeRowProps) { const visualDepth = Math.min(depth, MAX_VISUAL_DEPTH); @@ -35,6 +39,9 @@ function FileSystemTreeNodeRow({ const key = collapseKey(node.path); const isExpanded = !collapsedSections.has(key); + // Only real top-level channel rows are deletable: depth 0 with a backing cloud + // row (derived intermediate folders have no item/id and can't be removed). + const isDeletableChannel = depth === 0 && Boolean(node.item?.id); return ( toggleSection(key)} addSpacingBefore={false} tooltipContent={node.path} + onDelete={isDeletableChannel ? () => onDeleteChannel(node) : undefined} + deleteTooltip="Delete channel" > {node.children.map((child) => ( ))} @@ -63,26 +73,84 @@ function FileSystemTreeNodeRow({ export function FileSystemTreeView({ nodes }: { nodes: FileSystemTreeNode[] }) { const collapsedSections = useSidebarStore((state) => state.collapsedSections); const toggleSection = useSidebarStore((state) => state.toggleSection); + const { deleteChannel, isDeleting } = useDesktopFileSystemMutations(); + const [pendingDelete, setPendingDelete] = useState( + null, + ); + + const confirmDelete = async () => { + const id = pendingDelete?.item?.id; + if (!id) return; + try { + await deleteChannel(id); + } finally { + setPendingDelete(null); + } + }; if (nodes.length === 0) { return ( - Nothing here yet + No channels yet ); } return ( - - {nodes.map((node) => ( - - ))} - + <> + + {nodes.map((node) => ( + + ))} + + + { + if (!open && !isDeleting) setPendingDelete(null); + }} + > + + + Delete channel "{pendingDelete?.name}"? + + + + This removes the channel for everyone in your project. This can't + be undone here. + + + + + + + + + + + ); } diff --git a/apps/code/src/renderer/features/sidebar/components/SidebarMenu.tsx b/apps/code/src/renderer/features/sidebar/components/SidebarMenu.tsx index e8e4292381..f737b0fbad 100644 --- a/apps/code/src/renderer/features/sidebar/components/SidebarMenu.tsx +++ b/apps/code/src/renderer/features/sidebar/components/SidebarMenu.tsx @@ -33,6 +33,7 @@ import { useTaskViewed } from "../hooks/useTaskViewed"; import { useSidebarStore } from "../stores/sidebarStore"; import { useTaskSelectionStore } from "../stores/taskSelectionStore"; import { buildFileSystemTree } from "../utils/fileSystemTree"; +import { ChannelsHeader } from "./ChannelsHeader"; import { FileSystemTreeView } from "./FileSystemTreeView"; import { CommandCenterItem } from "./items/CommandCenterItem"; import { InboxItem, NewTaskItem } from "./items/HomeItem"; @@ -434,16 +435,21 @@ function SidebarMenuComponent() { )} {showFiles ? ( - fsLoading ? ( - } - label="Loading..." - disabled - /> - ) : ( - - ) + <> + + {fsLoading ? ( + + } + label="Loading..." + disabled + /> + ) : ( + + )} + ) : sidebarData.isLoading ? ( }, + { value: "files", label: "Channels", icon: }, { value: "tasks", label: "Tasks", icon: }, ]; diff --git a/apps/code/src/renderer/features/sidebar/components/SidebarSection.tsx b/apps/code/src/renderer/features/sidebar/components/SidebarSection.tsx index ae339ed76e..a4ce8fd321 100644 --- a/apps/code/src/renderer/features/sidebar/components/SidebarSection.tsx +++ b/apps/code/src/renderer/features/sidebar/components/SidebarSection.tsx @@ -1,5 +1,10 @@ import { Tooltip } from "@components/ui/Tooltip"; -import { CaretDownIcon, CaretRightIcon, Plus } from "@phosphor-icons/react"; +import { + CaretDownIcon, + CaretRightIcon, + Plus, + Trash, +} from "@phosphor-icons/react"; import { Button } from "@posthog/quill"; import * as Collapsible from "@radix-ui/react-collapsible"; import { useState } from "react"; @@ -21,6 +26,8 @@ interface SidebarSectionProps { tooltipContent?: string; onNewTask?: () => void; newTaskTooltip?: string; + onDelete?: () => void; + deleteTooltip?: string; dragHandleRef?: React.RefCallback; } @@ -36,6 +43,8 @@ export function SidebarSection({ tooltipContent, onNewTask, newTaskTooltip, + onDelete, + deleteTooltip, dragHandleRef, }: SidebarSectionProps) { const [isHovered, setIsHovered] = useState(false); @@ -106,6 +115,30 @@ export function SidebarSection({ )} + {onDelete && isHovered && ( + + {/* biome-ignore lint/a11y/useSemanticElements: Cannot use button inside parent button (Collapsible.Trigger) */} + { + e.preventDefault(); + e.stopPropagation(); + onDelete(); + }} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + e.stopPropagation(); + onDelete(); + } + }} + > + + + + )} diff --git a/apps/code/src/renderer/features/sidebar/hooks/useDesktopFileSystem.ts b/apps/code/src/renderer/features/sidebar/hooks/useDesktopFileSystem.ts index 4d1bb61cd5..d9c778ab46 100644 --- a/apps/code/src/renderer/features/sidebar/hooks/useDesktopFileSystem.ts +++ b/apps/code/src/renderer/features/sidebar/hooks/useDesktopFileSystem.ts @@ -1,11 +1,15 @@ +import { useOptionalAuthenticatedClient } from "@features/auth/hooks/authClient"; import { useAuthenticatedQuery } from "@hooks/useAuthenticatedQuery"; import type { Schemas } from "@renderer/api/generated"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useCallback } from "react"; const DESKTOP_FILE_SYSTEM_POLL_INTERVAL_MS = 30_000; +const DESKTOP_FILE_SYSTEM_QUERY_KEY = ["desktop-file-system"] as const; export function useDesktopFileSystem(options?: { enabled?: boolean }) { return useAuthenticatedQuery( - ["desktop-file-system"], + DESKTOP_FILE_SYSTEM_QUERY_KEY, (client) => client.getDesktopFileSystem(), { enabled: options?.enabled ?? true, @@ -13,3 +17,50 @@ export function useDesktopFileSystem(options?: { enabled?: boolean }) { }, ); } + +// Create/delete top-level channels on the desktop file system surface. Both +// mutations invalidate the shared query key so the tree refetches immediately +// rather than waiting on the 30s poll. +export function useDesktopFileSystemMutations() { + const client = useOptionalAuthenticatedClient(); + const queryClient = useQueryClient(); + + const invalidate = useCallback(() => { + void queryClient.invalidateQueries({ + queryKey: DESKTOP_FILE_SYSTEM_QUERY_KEY, + }); + }, [queryClient]); + + const createMutation = useMutation({ + mutationFn: async (name: string) => { + if (!client) throw new Error("Not authenticated"); + return client.createDesktopFileSystemChannel(name); + }, + onSuccess: invalidate, + }); + + const deleteMutation = useMutation({ + mutationFn: async (id: string) => { + if (!client) throw new Error("Not authenticated"); + return client.deleteDesktopFileSystem(id); + }, + onSuccess: invalidate, + }); + + const createChannel = useCallback( + (name: string) => createMutation.mutateAsync(name), + [createMutation], + ); + + const deleteChannel = useCallback( + (id: string) => deleteMutation.mutateAsync(id), + [deleteMutation], + ); + + return { + createChannel, + deleteChannel, + isCreating: createMutation.isPending, + isDeleting: deleteMutation.isPending, + }; +} From 73fd8a6fd2cac6f3f003f00d8ac922dcd1c6d0d5 Mon Sep 17 00:00:00 2001 From: Raquel Smith Date: Wed, 3 Jun 2026 13:46:20 -0700 Subject: [PATCH 4/6] feat(sidebar): use Slack-style modal for channel creation Replace the inline channel-name input in the sidebar with a Slack-style "Create a channel" modal: a Name field with a # prefix, character counter, helper text, and a Create action. Generated-By: PostHog Code Task-Id: b3c22a1c-8b74-4790-b7a1-673715dee381 --- .../sidebar/components/ChannelsHeader.tsx | 60 ++------- .../sidebar/components/CreateChannelModal.tsx | 118 ++++++++++++++++++ 2 files changed, 126 insertions(+), 52 deletions(-) create mode 100644 apps/code/src/renderer/features/sidebar/components/CreateChannelModal.tsx diff --git a/apps/code/src/renderer/features/sidebar/components/ChannelsHeader.tsx b/apps/code/src/renderer/features/sidebar/components/ChannelsHeader.tsx index f3a09044ab..869e94d2c0 100644 --- a/apps/code/src/renderer/features/sidebar/components/ChannelsHeader.tsx +++ b/apps/code/src/renderer/features/sidebar/components/ChannelsHeader.tsx @@ -1,34 +1,12 @@ import { Plus } from "@phosphor-icons/react"; -import { Flex, IconButton, Text, TextField } from "@radix-ui/themes"; +import { Flex, IconButton, Text } from "@radix-ui/themes"; import { useState } from "react"; -import { useDesktopFileSystemMutations } from "../hooks/useDesktopFileSystem"; +import { CreateChannelModal } from "./CreateChannelModal"; -// Header above the channel tree with an inline "add channel" affordance. The -// add form is purely local UI state; the create itself goes through the cloud -// mutation hook. +// Header above the channel tree with an "add channel" affordance that opens a +// Slack-style create-channel modal. export function ChannelsHeader() { - const { createChannel, isCreating } = useDesktopFileSystemMutations(); - const [isAdding, setIsAdding] = useState(false); - const [draft, setDraft] = useState(""); - - const cancel = () => { - setIsAdding(false); - setDraft(""); - }; - - const submit = async () => { - const name = draft.trim(); - if (!name) { - cancel(); - return; - } - try { - await createChannel(name); - cancel(); - } catch { - // Keep the input open so the user can retry; the mutation surfaces the error. - } - }; + const [isModalOpen, setIsModalOpen] = useState(false); return ( @@ -41,35 +19,13 @@ export function ChannelsHeader() { variant="ghost" color="gray" size="1" - aria-label="Add channel" - onClick={() => setIsAdding(true)} + aria-label="Create channel" + onClick={() => setIsModalOpen(true)} > - {isAdding && ( - setDraft(e.target.value)} - onBlur={() => { - if (!draft.trim()) cancel(); - }} - onKeyDown={(e) => { - if (e.key === "Enter") { - e.preventDefault(); - void submit(); - } else if (e.key === "Escape") { - e.preventDefault(); - cancel(); - } - }} - /> - )} + ); } diff --git a/apps/code/src/renderer/features/sidebar/components/CreateChannelModal.tsx b/apps/code/src/renderer/features/sidebar/components/CreateChannelModal.tsx new file mode 100644 index 0000000000..8d4a8d4716 --- /dev/null +++ b/apps/code/src/renderer/features/sidebar/components/CreateChannelModal.tsx @@ -0,0 +1,118 @@ +import { Button } from "@components/ui/Button"; +import { Hash, X } from "@phosphor-icons/react"; +import { Dialog, Flex, IconButton, Text, TextField } from "@radix-ui/themes"; +import { useEffect, useState } from "react"; +import { useDesktopFileSystemMutations } from "../hooks/useDesktopFileSystem"; + +// Matches Slack's "Create a channel" naming constraint. +const MAX_CHANNEL_NAME_LENGTH = 80; + +interface CreateChannelModalProps { + open: boolean; + onOpenChange: (open: boolean) => void; +} + +export function CreateChannelModal({ + open, + onOpenChange, +}: CreateChannelModalProps) { + const { createChannel, isCreating } = useDesktopFileSystemMutations(); + const [name, setName] = useState(""); + + // Reset the field each time the modal opens so a previous draft never lingers. + useEffect(() => { + if (open) setName(""); + }, [open]); + + const trimmed = name.trim(); + const remaining = MAX_CHANNEL_NAME_LENGTH - name.length; + + const submit = async () => { + if (!trimmed) return; + try { + await createChannel(trimmed); + onOpenChange(false); + } catch { + // Keep the modal open so the user can retry; the mutation surfaces the error. + } + }; + + return ( + { + if (!isCreating) onOpenChange(next); + }} + > + + + + Create a channel + + + + + + + + + + + Name + + setName(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + void submit(); + } + }} + > + + + + + + {remaining} + + + + + Channels are where conversations happen around a topic. Use a name + that is easy to find and understand. + + + + + + + + + ); +} From 8f5fc8c93d7593022bba897bc3e71ba489bf0628 Mon Sep 17 00:00:00 2001 From: Raquel Smith Date: Thu, 4 Jun 2026 14:22:35 -0700 Subject: [PATCH 5/6] feat(code): file tasks to channels from sidebar context menu Adds a "File to..." submenu to the task right-click menu listing desktop file system folder channels. Selecting one creates a task-typed entry under that channel that links back to the source task via `ref`, so clicking it in the channels sidebar opens the task in the main pane. Task leaves render indented with a code icon. Co-Authored-By: Claude Opus 4.7 --- .../src/main/services/context-menu/schemas.ts | 4 +++ .../src/main/services/context-menu/service.ts | 19 ++++++++++ apps/code/src/renderer/api/posthogClient.ts | 35 +++++++++++++++++++ .../sidebar/components/FileSystemTreeView.tsx | 17 +++++++-- .../sidebar/components/SidebarMenu.tsx | 33 +++++++++++++++-- .../sidebar/hooks/useDesktopFileSystem.ts | 21 +++++++++++ .../src/renderer/hooks/useTaskContextMenu.ts | 8 +++++ 7 files changed, 132 insertions(+), 5 deletions(-) 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 37b57c11da..b63ba4b5df 100644 --- a/apps/code/src/renderer/api/posthogClient.ts +++ b/apps/code/src/renderer/api/posthogClient.ts @@ -1016,6 +1016,41 @@ export class PostHogAPIClient { 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(); diff --git a/apps/code/src/renderer/features/sidebar/components/FileSystemTreeView.tsx b/apps/code/src/renderer/features/sidebar/components/FileSystemTreeView.tsx index b92fe7e338..55c649c453 100644 --- a/apps/code/src/renderer/features/sidebar/components/FileSystemTreeView.tsx +++ b/apps/code/src/renderer/features/sidebar/components/FileSystemTreeView.tsx @@ -1,5 +1,6 @@ -import { Hash } from "@phosphor-icons/react"; +import { Code, Hash } from "@phosphor-icons/react"; import { AlertDialog, Button, Flex, Spinner, Text } from "@radix-ui/themes"; +import { navigateToTaskDetail } from "@renderer/navigationBridge"; import { useState } from "react"; import { useDesktopFileSystemMutations } from "../hooks/useDesktopFileSystem"; import { useSidebarStore } from "../stores/sidebarStore"; @@ -33,8 +34,18 @@ function FileSystemTreeNodeRow({ const visualDepth = Math.min(depth, MAX_VISUAL_DEPTH); if (!node.isFolder) { - // Leaf rows are inert for now — a click hook is intentionally left unwired. - return ; + // Task leaves (filed via the task context menu) carry `ref = taskId`; + // clicking navigates to the task detail in the main pane. + const isTask = node.item?.type === "task"; + const taskRef = isTask && node.item?.ref ? node.item.ref : null; + return ( + : undefined} + onClick={taskRef ? () => navigateToTaskDetail(taskRef) : undefined} + /> + ); } const key = collapseKey(node.path); diff --git a/apps/code/src/renderer/features/sidebar/components/SidebarMenu.tsx b/apps/code/src/renderer/features/sidebar/components/SidebarMenu.tsx index 593cf7ddc9..2dd2d68472 100644 --- a/apps/code/src/renderer/features/sidebar/components/SidebarMenu.tsx +++ b/apps/code/src/renderer/features/sidebar/components/SidebarMenu.tsx @@ -34,7 +34,10 @@ import { useQueryClient } from "@tanstack/react-query"; import { logger } from "@utils/logger"; import { toast } from "@utils/toast"; import { memo, useCallback, useEffect, useMemo, useRef } from "react"; -import { useDesktopFileSystem } from "../hooks/useDesktopFileSystem"; +import { + useDesktopFileSystem, + useDesktopFileSystemMutations, +} from "../hooks/useDesktopFileSystem"; import { usePinnedTasks } from "../hooks/usePinnedTasks"; import { useSidebarData } from "../hooks/useSidebarData"; import { useTaskViewed } from "../hooks/useTaskViewed"; @@ -83,10 +86,22 @@ function SidebarMenuComponent() { // The file-system tree is only ever shown behind the flag; without it we // always fall back to the task list regardless of the persisted panel. const showFiles = fsSidebarEnabled && activePanel === "files"; + // Keep the file-system list loaded whenever the feature is available, not + // only when its panel is active, so the task right-click "File to..." submenu + // has the channel list ready immediately. const { data: fsItems = [], isLoading: fsLoading } = useDesktopFileSystem({ - enabled: showFiles, + enabled: fsSidebarEnabled, }); const fsTree = useMemo(() => buildFileSystemTree(fsItems), [fsItems]); + const { fileEntry } = useDesktopFileSystemMutations(); + const fileToFolders = useMemo( + () => + fsItems + .filter((item) => item.type === "folder") + .map((item) => ({ id: item.id, path: item.path })) + .sort((a, b) => a.path.localeCompare(b.path)), + [fsItems], + ); const inboxPollingActive = useRendererWindowFocusStore((s) => s.focused); const { data: inboxProbe } = useInboxReports( { status: INBOX_PIPELINE_STATUS_FILTER }, @@ -303,6 +318,7 @@ function SidebarMenuComponent() { isSuspended: taskData?.isSuspended, isInCommandCenter, hasEmptyCommandCenterCell, + fileToFolders: fsSidebarEnabled ? fileToFolders : undefined, onTogglePin: () => togglePin(taskId), onArchivePrior: handleArchivePrior, onAddToCommandCenter: () => { @@ -315,6 +331,19 @@ function SidebarMenuComponent() { toast.info("Command center is full"); } }, + onFileTo: async (channelPath) => { + try { + await fileEntry({ + path: `${channelPath}/${task.title}`, + type: "task", + ref: task.id, + }); + toast.success(`Filed to ${channelPath}`); + } catch (error) { + log.error("Failed to file task", error); + toast.error("Failed to file task"); + } + }, }); } }; diff --git a/apps/code/src/renderer/features/sidebar/hooks/useDesktopFileSystem.ts b/apps/code/src/renderer/features/sidebar/hooks/useDesktopFileSystem.ts index d9c778ab46..a34879dcdc 100644 --- a/apps/code/src/renderer/features/sidebar/hooks/useDesktopFileSystem.ts +++ b/apps/code/src/renderer/features/sidebar/hooks/useDesktopFileSystem.ts @@ -47,6 +47,19 @@ export function useDesktopFileSystemMutations() { onSuccess: invalidate, }); + const fileEntryMutation = useMutation({ + mutationFn: async (input: { + path: string; + type: string; + ref?: string; + href?: string; + }) => { + if (!client) throw new Error("Not authenticated"); + return client.createDesktopFileSystemEntry(input); + }, + onSuccess: invalidate, + }); + const createChannel = useCallback( (name: string) => createMutation.mutateAsync(name), [createMutation], @@ -57,10 +70,18 @@ export function useDesktopFileSystemMutations() { [deleteMutation], ); + const fileEntry = useCallback( + (input: { path: string; type: string; ref?: string; href?: string }) => + fileEntryMutation.mutateAsync(input), + [fileEntryMutation], + ); + return { createChannel, deleteChannel, + fileEntry, isCreating: createMutation.isPending, isDeleting: deleteMutation.isPending, + isFiling: fileEntryMutation.isPending, }; } diff --git a/apps/code/src/renderer/hooks/useTaskContextMenu.ts b/apps/code/src/renderer/hooks/useTaskContextMenu.ts index 31c48107a5..28529bb493 100644 --- a/apps/code/src/renderer/hooks/useTaskContextMenu.ts +++ b/apps/code/src/renderer/hooks/useTaskContextMenu.ts @@ -29,9 +29,11 @@ export function useTaskContextMenu() { isSuspended?: boolean; isInCommandCenter?: boolean; hasEmptyCommandCenterCell?: boolean; + fileToFolders?: { id: string; path: string }[]; onTogglePin?: () => void; onArchivePrior?: (taskId: string) => void; onAddToCommandCenter?: () => void; + onFileTo?: (folderPath: string) => void | Promise; }, ) => { event.preventDefault(); @@ -44,9 +46,11 @@ export function useTaskContextMenu() { isSuspended, isInCommandCenter, hasEmptyCommandCenterCell, + fileToFolders, onTogglePin, onArchivePrior, onAddToCommandCenter, + onFileTo, } = options ?? {}; try { @@ -58,6 +62,7 @@ export function useTaskContextMenu() { isSuspended, isInCommandCenter, hasEmptyCommandCenterCell, + fileToFolders, }); if (!result.action) return; @@ -92,6 +97,9 @@ export function useTaskContextMenu() { case "add-to-command-center": onAddToCommandCenter?.(); break; + case "file-to": + await onFileTo?.(result.action.folderPath); + break; case "external-app": { const effectivePath = worktreePath ?? folderPath; if (effectivePath) { From 033b79f0f64e86b4e174268c292036df35938959 Mon Sep 17 00:00:00 2001 From: Raquel Smith Date: Fri, 5 Jun 2026 17:27:02 -0700 Subject: [PATCH 6/6] feat(sidebar): add versioned CONTEXT.md per folder Wires the desktop folder-instructions endpoints from PostHog/posthog#61494 into a per-folder CONTEXT.md leaf in the channels sidebar. Clicking opens a deep-linkable route with rendered/edit toggle, save publishes a new version with optimistic concurrency, and a version dropdown surfaces history. Refetches on every mount so the view reflects edits made by other users or agents. Co-Authored-By: Claude Opus 4.7 --- apps/code/src/renderer/api/posthogClient.ts | 153 ++++++++ .../components/FolderContextView.tsx | 349 ++++++++++++++++++ .../hooks/useFolderInstructions.ts | 120 ++++++ .../sidebar/components/FileSystemTreeView.tsx | 25 +- apps/code/src/renderer/navigationBridge.ts | 7 + apps/code/src/renderer/routeTree.gen.ts | 22 ++ .../code/channels/$folderId/context.tsx | 11 + 7 files changed, 685 insertions(+), 2 deletions(-) create mode 100644 apps/code/src/renderer/features/folder-context/components/FolderContextView.tsx create mode 100644 apps/code/src/renderer/features/folder-context/hooks/useFolderInstructions.ts create mode 100644 apps/code/src/renderer/routes/code/channels/$folderId/context.tsx diff --git a/apps/code/src/renderer/api/posthogClient.ts b/apps/code/src/renderer/api/posthogClient.ts index dd2ebc638f..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"; @@ -1068,6 +1114,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 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"); + }} + /> + ) + ) : ( +