Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 36 additions & 1 deletion packages/core/src/sidebar/buildSidebarData.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -117,6 +117,7 @@ export interface TaskWorkspace {
folderPath?: string | null;
branchName?: string | null;
linkedBranch?: string | null;
mode?: WorkspaceMode;
}

export interface TaskTimestamp {
Expand Down Expand Up @@ -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,
Expand All @@ -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<WorkspaceMode, true> = {
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;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 The short-circuit compares array lengths rather than checking that all current modes are actually present in enabledModes. If a mode is ever removed from WorkspaceMode (e.g., going from 3 → 2 modes), a user with 3 persisted entries satisfies 3 >= 2 and bypasses the filter entirely — showing tasks for modes they thought they had disabled. Using ALL_WORKSPACE_MODES.every(m => enabledModes.includes(m)) checks for actual set membership instead.

Suggested change
if (enabledModes.length >= ALL_WORKSPACE_MODES.length) return tasks;
if (ALL_WORKSPACE_MODES.every((m) => enabledModes.includes(m))) 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;
}
Expand Down
59 changes: 59 additions & 0 deletions packages/core/src/sidebar/filterByWorkspaceMode.test.ts
Original file line number Diff line number Diff line change
@@ -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>): 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);
});
});
2 changes: 2 additions & 0 deletions packages/core/src/sidebar/sidebarData.types.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { WorkspaceMode } from "@posthog/shared";
import type { TaskRunStatus } from "@posthog/shared/domain-types";
import type {
TaskGroup as GenericTaskGroup,
Expand All @@ -18,6 +19,7 @@ export interface TaskData {
folderId?: string;
taskRunStatus?: TaskRunStatus;
taskRunEnvironment?: "local" | "cloud";
workspaceMode?: WorkspaceMode;
originProduct?: string;
slackThreadUrl?: string;
folderPath: string | null;
Expand Down
42 changes: 42 additions & 0 deletions packages/ui/src/features/sidebar/components/TasksHeader.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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<WorkspaceMode, { label: string; icon: Icon }> = {
worktree: { label: "Worktree", icon: GitBranch },
local: { label: "Local", icon: Desktop },
cloud: { label: "Cloud", icon: Cloud },
};

function AddFolderButton() {
const trpcClient = useHostTRPCClient();
const { addFolder } = useFolders();
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -163,6 +183,28 @@ function TaskFilterMenu() {
</DropdownMenuRadioGroup>
</>
)}

<DropdownMenuSeparator />

<DropdownMenuSub>
<DropdownMenuSubTrigger>Environment</DropdownMenuSubTrigger>
<DropdownMenuSubContent side="right" sideOffset={4}>
{ALL_WORKSPACE_MODES.map((mode) => {
const { label, icon: Icon } = ENVIRONMENT_META[mode];
return (
<DropdownMenuCheckboxItem
key={mode}
checked={taskTypeFilter.includes(mode)}
closeOnClick={false}
onCheckedChange={() => toggleTaskType(mode)}
>
<Icon size={14} />
{label}
</DropdownMenuCheckboxItem>
);
})}
</DropdownMenuSubContent>
</DropdownMenuSub>
</DropdownMenuContent>
</DropdownMenu>
);
Expand Down
15 changes: 15 additions & 0 deletions packages/ui/src/features/sidebar/sidebarStore.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -14,6 +16,7 @@ interface SidebarStoreState {
sortMode: "updated" | "created";
showAllUsers: boolean;
showInternal: boolean;
taskTypeFilter: WorkspaceMode[];
}

interface SidebarStoreActions {
Expand All @@ -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;
Expand All @@ -50,6 +54,7 @@ export const useSidebarStore = create<SidebarStore>()(
sortMode: "updated",
showAllUsers: false,
showInternal: false,
taskTypeFilter: [...ALL_WORKSPACE_MODES],
setOpen: (open) => set({ open, hasUserSetOpen: true }),
setOpenAuto: (open) =>
set((state) => (state.hasUserSetOpen ? state : { open })),
Expand Down Expand Up @@ -100,6 +105,12 @@ export const useSidebarStore = create<SidebarStore>()(
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",
Expand All @@ -114,6 +125,7 @@ export const useSidebarStore = create<SidebarStore>()(
sortMode: state.sortMode,
showAllUsers: state.showAllUsers,
showInternal: state.showInternal,
taskTypeFilter: state.taskTypeFilter,
}),
merge: (persisted, current) => {
const persistedState = persisted as {
Expand All @@ -127,6 +139,7 @@ export const useSidebarStore = create<SidebarStore>()(
sortMode?: SidebarStoreState["sortMode"];
showAllUsers?: boolean;
showInternal?: boolean;
taskTypeFilter?: WorkspaceMode[];
};
return {
...current,
Expand All @@ -145,6 +158,8 @@ export const useSidebarStore = create<SidebarStore>()(
sortMode: persistedState.sortMode ?? current.sortMode,
showAllUsers: persistedState.showAllUsers ?? current.showAllUsers,
showInternal: persistedState.showInternal ?? current.showInternal,
taskTypeFilter:
persistedState.taskTypeFilter ?? current.taskTypeFilter,
};
},
},
Expand Down
11 changes: 9 additions & 2 deletions packages/ui/src/features/sidebar/useSidebarData.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {
deriveTaskData,
type FullTask,
filterByWorkspaceMode,
filterVisibleTasks,
narrowFullTask,
partitionAndSortTasks,
Expand Down Expand Up @@ -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(
() =>
Expand Down Expand Up @@ -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(
Expand Down
Loading