diff --git a/packages/core/src/sidebar/buildSidebarData.ts b/packages/core/src/sidebar/buildSidebarData.ts index aea6828e27..33c62f10f9 100644 --- a/packages/core/src/sidebar/buildSidebarData.ts +++ b/packages/core/src/sidebar/buildSidebarData.ts @@ -1,4 +1,4 @@ -import { readPrUrls } from "@posthog/shared"; +import { readPrUrls, type WorkspaceMode } from "@posthog/shared"; import type { Task, TaskRunStatus } from "@posthog/shared/domain-types"; import { getRepositoryInfo } from "./groupTasks"; import type { TaskData } from "./sidebarData.types"; @@ -117,6 +117,7 @@ export interface TaskWorkspace { folderPath?: string | null; branchName?: string | null; linkedBranch?: string | null; + mode?: WorkspaceMode; } export interface TaskTimestamp { @@ -175,6 +176,12 @@ export function deriveTaskData( folderId: workspace?.folderId || undefined, taskRunStatus: session?.cloudStatus ?? task.latest_run?.status ?? undefined, taskRunEnvironment: task.latest_run?.environment ?? undefined, + // The `latest_run` fallback only matters in the `showAllUsers` view: the + // default view's `filterVisibleTasks` already restricts to tasks with a + // local `workspace`, so a pure-cloud task without one only shows up there. + workspaceMode: + workspace?.mode ?? + (task.latest_run?.environment === "cloud" ? "cloud" : undefined), originProduct, slackThreadUrl, folderPath: workspace?.folderPath ?? null, @@ -184,6 +191,34 @@ export function deriveTaskData( }; } +// A Record keyed by the full `WorkspaceMode` union, so adding a mode to the +// schema forces a compile error here instead of silently falling out of sync +// with `ALL_WORKSPACE_MODES` (and the filter's "all enabled" short-circuit). +const WORKSPACE_MODE_MEMBERSHIP: Record = { + worktree: true, + local: true, + cloud: true, +}; + +export const ALL_WORKSPACE_MODES: readonly WorkspaceMode[] = Object.keys( + WORKSPACE_MODE_MEMBERSHIP, +) as WorkspaceMode[]; + +/** + * Keeps tasks whose workspace mode is in `enabledModes`. Tasks without a known + * mode always pass so an unclassified task never silently disappears. + */ +export function filterByWorkspaceMode( + tasks: TaskData[], + enabledModes: readonly WorkspaceMode[], +): TaskData[] { + if (enabledModes.length >= ALL_WORKSPACE_MODES.length) return tasks; + return tasks.filter( + (task) => + task.workspaceMode == null || enabledModes.includes(task.workspaceMode), + ); +} + function getSortValue(task: TaskData, sortMode: SortMode): number { return sortMode === "updated" ? task.lastActivityAt : task.createdAt; } diff --git a/packages/core/src/sidebar/filterByWorkspaceMode.test.ts b/packages/core/src/sidebar/filterByWorkspaceMode.test.ts new file mode 100644 index 0000000000..e678f5840f --- /dev/null +++ b/packages/core/src/sidebar/filterByWorkspaceMode.test.ts @@ -0,0 +1,59 @@ +import type { WorkspaceMode } from "@posthog/shared"; +import { describe, expect, it } from "vitest"; +import { ALL_WORKSPACE_MODES, filterByWorkspaceMode } from "./buildSidebarData"; +import type { TaskData } from "./sidebarData.types"; + +const task = (overrides: Partial): TaskData => ({ + id: "t", + title: "t", + createdAt: 0, + lastActivityAt: 0, + isGenerating: false, + isUnread: false, + isPinned: false, + needsPermission: false, + repository: null, + isSuspended: false, + folderPath: null, + cloudPrUrl: null, + branchName: null, + linkedBranch: null, + ...overrides, +}); + +describe("filterByWorkspaceMode", () => { + const worktree = task({ id: "w", workspaceMode: "worktree" }); + const local = task({ id: "l", workspaceMode: "local" }); + const cloud = task({ id: "c", workspaceMode: "cloud" }); + const unknown = task({ id: "u", workspaceMode: undefined }); + const tasks = [worktree, local, cloud, unknown]; + + it.each<{ + name: string; + enabledModes: readonly WorkspaceMode[]; + expected: TaskData[]; + }>([ + { + name: "returns all tasks when every mode is enabled", + enabledModes: ALL_WORKSPACE_MODES, + expected: tasks, + }, + { + name: "keeps only tasks whose mode is enabled, plus unknown-mode tasks", + enabledModes: ["local"], + expected: [local, unknown], + }, + { + name: "keeps multiple enabled modes", + enabledModes: ["worktree", "cloud"], + expected: [worktree, cloud, unknown], + }, + { + name: "keeps only unknown-mode tasks when nothing is enabled", + enabledModes: [], + expected: [unknown], + }, + ])("$name", ({ enabledModes, expected }) => { + expect(filterByWorkspaceMode(tasks, enabledModes)).toEqual(expected); + }); +}); diff --git a/packages/core/src/sidebar/sidebarData.types.ts b/packages/core/src/sidebar/sidebarData.types.ts index cf03c1fc6e..6efc20f4e3 100644 --- a/packages/core/src/sidebar/sidebarData.types.ts +++ b/packages/core/src/sidebar/sidebarData.types.ts @@ -1,3 +1,4 @@ +import type { WorkspaceMode } from "@posthog/shared"; import type { TaskRunStatus } from "@posthog/shared/domain-types"; import type { TaskGroup as GenericTaskGroup, @@ -18,6 +19,7 @@ export interface TaskData { folderId?: string; taskRunStatus?: TaskRunStatus; taskRunEnvironment?: "local" | "cloud"; + workspaceMode?: WorkspaceMode; originProduct?: string; slackThreadUrl?: string; folderPath: string | null; diff --git a/packages/ui/src/features/sidebar/components/TasksHeader.tsx b/packages/ui/src/features/sidebar/components/TasksHeader.tsx index ebb6f84e69..ebe4066a59 100644 --- a/packages/ui/src/features/sidebar/components/TasksHeader.tsx +++ b/packages/ui/src/features/sidebar/components/TasksHeader.tsx @@ -1,19 +1,29 @@ import { + Cloud, + Desktop, FolderPlus, FunnelSimple as FunnelSimpleIcon, + GitBranch, + type Icon, MagnifyingGlass, } from "@phosphor-icons/react"; +import { ALL_WORKSPACE_MODES } from "@posthog/core/sidebar/buildSidebarData"; import { useHostTRPCClient } from "@posthog/host-router/react"; import { Button, DropdownMenu, + DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuSeparator, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, DropdownMenuTrigger, MenuLabel, } from "@posthog/quill"; +import type { WorkspaceMode } from "@posthog/shared"; import { useMeQuery } from "@posthog/ui/features/auth/useMeQuery"; import { useFolders } from "@posthog/ui/features/folders/useFolders"; import { useSidebarStore } from "@posthog/ui/features/sidebar/sidebarStore"; @@ -25,6 +35,14 @@ import { useState } from "react"; const log = logger.scope("tasks-header"); +// Record (not a hand-maintained array) so adding a WorkspaceMode forces a +// compile error here instead of silently missing a checkbox. +const ENVIRONMENT_META: Record = { + worktree: { label: "Worktree", icon: GitBranch }, + local: { label: "Local", icon: Desktop }, + cloud: { label: "Cloud", icon: Cloud }, +}; + function AddFolderButton() { const trpcClient = useHostTRPCClient(); const { addFolder } = useFolders(); @@ -82,6 +100,8 @@ function TaskFilterMenu() { const setSortMode = useSidebarStore((state) => state.setSortMode); const setShowAllUsers = useSidebarStore((state) => state.setShowAllUsers); const setShowInternal = useSidebarStore((state) => state.setShowInternal); + const taskTypeFilter = useSidebarStore((state) => state.taskTypeFilter); + const toggleTaskType = useSidebarStore((state) => state.toggleTaskType); const { data: currentUser } = useMeQuery(); const isStaff = currentUser?.is_staff === true; @@ -163,6 +183,28 @@ function TaskFilterMenu() { )} + + + + + Environment + + {ALL_WORKSPACE_MODES.map((mode) => { + const { label, icon: Icon } = ENVIRONMENT_META[mode]; + return ( + toggleTaskType(mode)} + > + + {label} + + ); + })} + + ); diff --git a/packages/ui/src/features/sidebar/sidebarStore.ts b/packages/ui/src/features/sidebar/sidebarStore.ts index 37f18f4bd9..d2f5506094 100644 --- a/packages/ui/src/features/sidebar/sidebarStore.ts +++ b/packages/ui/src/features/sidebar/sidebarStore.ts @@ -1,3 +1,5 @@ +import { ALL_WORKSPACE_MODES } from "@posthog/core/sidebar/buildSidebarData"; +import type { WorkspaceMode } from "@posthog/shared"; import { create } from "zustand"; import { persist } from "zustand/middleware"; import { SIDEBAR_MIN_WIDTH } from "./constants"; @@ -14,6 +16,7 @@ interface SidebarStoreState { sortMode: "updated" | "created"; showAllUsers: boolean; showInternal: boolean; + taskTypeFilter: WorkspaceMode[]; } interface SidebarStoreActions { @@ -32,6 +35,7 @@ interface SidebarStoreActions { setSortMode: (mode: SidebarStoreState["sortMode"]) => void; setShowAllUsers: (showAllUsers: boolean) => void; setShowInternal: (showInternal: boolean) => void; + toggleTaskType: (mode: WorkspaceMode) => void; } type SidebarStore = SidebarStoreState & SidebarStoreActions; @@ -50,6 +54,7 @@ export const useSidebarStore = create()( sortMode: "updated", showAllUsers: false, showInternal: false, + taskTypeFilter: [...ALL_WORKSPACE_MODES], setOpen: (open) => set({ open, hasUserSetOpen: true }), setOpenAuto: (open) => set((state) => (state.hasUserSetOpen ? state : { open })), @@ -100,6 +105,12 @@ export const useSidebarStore = create()( setSortMode: (sortMode) => set({ sortMode }), setShowAllUsers: (showAllUsers) => set({ showAllUsers }), setShowInternal: (showInternal) => set({ showInternal }), + toggleTaskType: (mode) => + set((state) => ({ + taskTypeFilter: state.taskTypeFilter.includes(mode) + ? state.taskTypeFilter.filter((m) => m !== mode) + : [...state.taskTypeFilter, mode], + })), }), { name: "sidebar-storage", @@ -114,6 +125,7 @@ export const useSidebarStore = create()( sortMode: state.sortMode, showAllUsers: state.showAllUsers, showInternal: state.showInternal, + taskTypeFilter: state.taskTypeFilter, }), merge: (persisted, current) => { const persistedState = persisted as { @@ -127,6 +139,7 @@ export const useSidebarStore = create()( sortMode?: SidebarStoreState["sortMode"]; showAllUsers?: boolean; showInternal?: boolean; + taskTypeFilter?: WorkspaceMode[]; }; return { ...current, @@ -145,6 +158,8 @@ export const useSidebarStore = create()( sortMode: persistedState.sortMode ?? current.sortMode, showAllUsers: persistedState.showAllUsers ?? current.showAllUsers, showInternal: persistedState.showInternal ?? current.showInternal, + taskTypeFilter: + persistedState.taskTypeFilter ?? current.taskTypeFilter, }; }, }, diff --git a/packages/ui/src/features/sidebar/useSidebarData.ts b/packages/ui/src/features/sidebar/useSidebarData.ts index c05be96909..0340ccd69f 100644 --- a/packages/ui/src/features/sidebar/useSidebarData.ts +++ b/packages/ui/src/features/sidebar/useSidebarData.ts @@ -1,6 +1,7 @@ import { deriveTaskData, type FullTask, + filterByWorkspaceMode, filterVisibleTasks, narrowFullTask, partitionAndSortTasks, @@ -51,6 +52,7 @@ export function useSidebarData({ const organizeMode = useSidebarStore((state) => state.organizeMode); const sortMode = useSidebarStore((state) => state.sortMode); const folderOrder = useSidebarStore((state) => state.folderOrder); + const taskTypeFilter = useSidebarStore((state) => state.taskTypeFilter); const summaryIds = useMemo( () => @@ -166,9 +168,14 @@ export function useSidebarData({ ], ); + const filteredTaskData = useMemo( + () => filterByWorkspaceMode(taskData, taskTypeFilter), + [taskData, taskTypeFilter], + ); + const { pinnedTasks, sortedUnpinnedTasks, totalCount } = useMemo( - () => partitionAndSortTasks(taskData, sortMode), - [taskData, sortMode], + () => partitionAndSortTasks(filteredTaskData, sortMode), + [filteredTaskData, sortMode], ); const { flatTasks, hasMore } = useMemo(