From a42ffcfbe23e97982ae5d2bec2cf0475fccacc47 Mon Sep 17 00:00:00 2001 From: Raquel Smith Date: Tue, 2 Jun 2026 14:28:57 -0700 Subject: [PATCH 1/4] 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/4] 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/4] 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. + + + + + + + + + ); +}