Skip to content
This repository was archived by the owner on Aug 6, 2026. It is now read-only.
Closed
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
5 changes: 5 additions & 0 deletions apps/code/src/main/platform-adapters/electron-context-menu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,11 @@ function toElectronItem(item: ContextMenuItem): MenuItemConstructorOptions {
enabled: action.enabled ?? true,
accelerator: action.accelerator,
};
// Electron only renders a checkmark on checkbox/radio items.
if (action.checked !== undefined) {
options.type = "checkbox";
options.checked = action.checked;
}
if (action.icon) {
options.icon = resizeIcon(action.icon);
}
Expand Down
6 changes: 6 additions & 0 deletions apps/web/src/web-host-router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
type CloudRegion,
getCloudUrlFromRegion,
tabsSnapshotSchema,
taskLabelSchema,
} from "@posthog/shared";
import { getAuthenticatedClient } from "@posthog/ui/features/auth/authClientImperative";
import { z } from "zod";
Expand Down Expand Up @@ -318,6 +319,11 @@ const workspaceStubRouter = router({
togglePin: publicProcedure
.input(z.object({ taskId: z.string() }))
.mutation(({ input }) => webTaskMetadataStore.togglePin(input.taskId)),
setTaskLabel: publicProcedure
.input(z.object({ taskId: z.string(), label: taskLabelSchema.nullable() }))
.mutation(({ input }) =>
webTaskMetadataStore.setLabel(input.taskId, input.label),
),
markViewed: publicProcedure
.input(z.object({ taskId: z.string() }))
.mutation(({ input }) => {
Expand Down
12 changes: 12 additions & 0 deletions apps/web/src/web-task-metadata-store.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { type TaskLabel, taskLabelSchema } from "@posthog/shared";
import { z } from "zod";
import { createRecordStore } from "./web-local-store";

Expand All @@ -11,6 +12,8 @@ const taskMetadataSchema = z.object({
pinnedAt: z.string().nullable(),
lastViewedAt: z.string().nullable(),
lastActivityAt: z.string().nullable(),
// catch(null) so rows persisted before labels shipped aren't shed on load.
label: taskLabelSchema.nullable().catch(null),
});

export type TaskMetadata = z.infer<typeof taskMetadataSchema>;
Expand All @@ -19,6 +22,7 @@ const EMPTY: TaskMetadata = {
pinnedAt: null,
lastViewedAt: null,
lastActivityAt: null,
label: null,
};

const store = createRecordStore(
Expand Down Expand Up @@ -63,6 +67,14 @@ export const webTaskMetadataStore = {
update(taskId, { lastActivityAt: new Date().toISOString() });
},

setLabel(
taskId: string,
label: TaskLabel | null,
): { label: TaskLabel | null } {
update(taskId, { label });
return { label };
},

remove(taskId: string): void {
const current = store.get();
if (!(taskId in current)) return;
Expand Down
52 changes: 52 additions & 0 deletions packages/core/src/context-menu/context-menu.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,58 @@ describe("ContextMenuService.showTaskContextMenu", () => {
);
});

it("offers a Label submenu with every label plus None, checking the current one", async () => {
const menu = new FakeContextMenu();
makeService(menu).showTaskContextMenu({
...baseTask,
currentLabel: "active",
});
await menu.shown;
const submenu = findItem(menu.lastItems, "Label").submenu ?? [];
expect(labels(submenu)).toEqual([
"High priority",
"Active",
"Deprioritized",
"Done",
"None",
]);
expect(findItem(submenu, "Active").checked).toBe(true);
expect(findItem(submenu, "High priority").checked).toBe(false);
expect(findItem(submenu, "None").checked).toBe(false);
});

it("checks None in the Label submenu when the task is unlabeled", async () => {
const menu = new FakeContextMenu();
makeService(menu).showTaskContextMenu({ ...baseTask, currentLabel: null });
await menu.shown;
const submenu = findItem(menu.lastItems, "Label").submenu ?? [];
expect(findItem(submenu, "None").checked).toBe(true);
});

it("resolves set-label with the clicked label, and null for None", async () => {
const pick = new FakeContextMenu();
const picked = makeService(pick).showTaskContextMenu(baseTask);
await pick.shown;
findItem(
findItem(pick.lastItems, "Label").submenu ?? [],
"High priority",
).click();
expect(await picked).toEqual({
action: { type: "set-label", label: "high-priority" },
});

const clear = new FakeContextMenu();
const cleared = makeService(clear).showTaskContextMenu({
...baseTask,
currentLabel: "done",
});
await clear.shown;
findItem(findItem(clear.lastItems, "Label").submenu ?? [], "None").click();
expect(await cleared).toEqual({
action: { type: "set-label", label: null },
});
});

it("resolves to null when the menu is dismissed", async () => {
const menu = new FakeContextMenu();
const result = makeService(menu).showTaskContextMenu(baseTask);
Expand Down
23 changes: 23 additions & 0 deletions packages/core/src/context-menu/context-menu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
type IContextMenu,
} from "@posthog/platform/context-menu";
import { DIALOG_SERVICE, type IDialog } from "@posthog/platform/dialog";
import { TASK_LABEL_META, TASK_LABELS } from "@posthog/shared";
import { inject, injectable } from "inversify";
import {
CONTEXT_MENU_EXTERNAL_APPS_SERVICE,
Expand Down Expand Up @@ -40,6 +41,7 @@ import type {
ConfirmOptions,
MenuItemDef,
SeparatorDef,
SubmenuItemDef,
} from "./types";

@injectable()
Expand Down Expand Up @@ -118,6 +120,7 @@ export class ContextMenuService {
isInCommandCenter,
hasEmptyCommandCenterCell,
channels,
currentLabel,
} = input;
const { apps, lastUsedAppId } = await this.getExternalAppsData();
const hasPath = worktreePath || folderPath;
Expand All @@ -139,9 +142,28 @@ export class ContextMenuService {
]
: [];

// Radio-style label picker: every label plus "None", the current one checked.
const labelSubmenu: SubmenuItemDef<TaskAction> = {
type: "submenu",
label: "Label",
items: [
...TASK_LABELS.map((label) => ({
label: TASK_LABEL_META[label].displayName,
checked: (currentLabel ?? null) === label,
action: { type: "set-label" as const, label },
})),
{
label: "None",
checked: (currentLabel ?? null) === null,
action: { type: "set-label" as const, label: null },
},
],
};

return this.showMenu<TaskAction>([
this.item(isPinned ? "Unpin" : "Pin", { type: "pin" }),
this.item("Rename", { type: "rename" }),
labelSubmenu,
...(canStop
? [this.separator(), this.item("Stop task", { type: "stop" as const })]
: []),
Expand Down Expand Up @@ -367,6 +389,7 @@ export class ContextMenuService {
submenu: def.items.map((sub) => ({
label: sub.label,
icon: sub.icon,
checked: sub.checked,
click: () => resolve({ action: sub.action }),
})),
click: () => {},
Expand Down
8 changes: 8 additions & 0 deletions packages/core/src/context-menu/schemas.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { taskLabelSchema } from "@posthog/shared";
import { z } from "zod";

export const taskContextMenuInput = z.object({
Expand All @@ -9,6 +10,9 @@ export const taskContextMenuInput = z.object({
canStop: z.boolean().optional(),
isInCommandCenter: z.boolean().optional(),
hasEmptyCommandCenterCell: z.boolean().optional(),
// The task's current label, so the Label submenu can check it. Omitted and
// null both read as unlabeled.
currentLabel: taskLabelSchema.nullable().optional(),
// Top-level desktop_file_system channels available as "File to…" targets.
// Omit (or pass empty) to hide the submenu entirely.
channels: z.array(z.object({ id: z.string(), name: z.string() })).optional(),
Expand Down Expand Up @@ -53,6 +57,10 @@ const taskAction = z.discriminatedUnion("type", [
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-channel"), channelId: z.string() }),
z.object({
type: z.literal("set-label"),
label: taskLabelSchema.nullable(),
}),
]);

const bulkTaskAction = z.discriminatedUnion("type", [
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/context-menu/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ export interface SubmenuItemDef<T> {
items: Array<{
label: string;
icon?: string;
/** Checkmark for radio-style submenus (e.g. the current task label). */
checked?: boolean;
action: T;
}>;
}
Expand Down
82 changes: 81 additions & 1 deletion packages/core/src/sidebar/buildSidebarData.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
import { describe, expect, it } from "vitest";
import { limitTasksPerGroup, sliceVisibleTasks } from "./buildSidebarData";
import {
type DeriveTaskDataContext,
deriveTaskData,
limitTasksPerGroup,
partitionAndSortTasks,
type SidebarTask,
sliceVisibleTasks,
} from "./buildSidebarData";
import type { TaskData, TaskGroup } from "./sidebarData.types";

function makeTask(id: string): TaskData {
Expand All @@ -18,6 +25,7 @@ function makeTask(id: string): TaskData {
cloudPrUrl: null,
branchName: null,
linkedBranch: null,
label: null,
};
}

Expand All @@ -29,6 +37,78 @@ function makeGroup(id: string, taskCount: number): TaskGroup {
};
}

describe("deriveTaskData", () => {
const baseTask: SidebarTask = {
id: "t1",
title: "Task",
created_at: "2026-01-01T00:00:00.000Z",
updated_at: "2026-01-02T00:00:00.000Z",
};
const baseCtx: DeriveTaskDataContext = {
session: undefined,
workspace: undefined,
timestamp: undefined,
pinnedIds: new Set<string>(),
suspendedIds: new Set<string>(),
slackTaskIds: new Set<string>(),
slackThreadUrlByTaskId: new Map<string, string>(),
};

it("carries the label from the metadata record", () => {
const data = deriveTaskData(baseTask, {
...baseCtx,
timestamp: { lastViewedAt: null, lastActivityAt: null, label: "done" },
});
expect(data.label).toBe("done");
});

it("defaults the label to null when the task has no metadata record", () => {
expect(deriveTaskData(baseTask, baseCtx).label).toBeNull();
});
});

describe("partitionAndSortTasks with priority sort", () => {
it("orders by label rank, unlabeled between active and deprioritized", () => {
const tasks: TaskData[] = [
{ ...makeTask("done"), label: "done" },
{ ...makeTask("none") },
{ ...makeTask("high"), label: "high-priority" },
{ ...makeTask("deprio"), label: "deprioritized" },
{ ...makeTask("active"), label: "active" },
];
const { sortedUnpinnedTasks } = partitionAndSortTasks(tasks, "priority");
expect(sortedUnpinnedTasks.map((t) => t.id)).toEqual([
"high",
"active",
"none",
"deprio",
"done",
]);
});

it("breaks rank ties by most recent activity", () => {
const tasks: TaskData[] = [
{ ...makeTask("older"), label: "active", lastActivityAt: 1 },
{ ...makeTask("newer"), label: "active", lastActivityAt: 2 },
];
const { sortedUnpinnedTasks } = partitionAndSortTasks(tasks, "priority");
expect(sortedUnpinnedTasks.map((t) => t.id)).toEqual(["newer", "older"]);
});

it("still partitions pinned tasks out first", () => {
const tasks: TaskData[] = [
{ ...makeTask("pinned"), label: "done", isPinned: true },
{ ...makeTask("high"), label: "high-priority" },
];
const { pinnedTasks, sortedUnpinnedTasks } = partitionAndSortTasks(
tasks,
"priority",
);
expect(pinnedTasks.map((t) => t.id)).toEqual(["pinned"]);
expect(sortedUnpinnedTasks.map((t) => t.id)).toEqual(["high"]);
});
});

describe("sliceVisibleTasks", () => {
it("caps the flat list to the visible count and reports hasMore", () => {
const tasks = Array.from({ length: 30 }, (_, i) => makeTask(String(i)));
Expand Down
25 changes: 19 additions & 6 deletions packages/core/src/sidebar/buildSidebarData.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
import { readPrUrls, type WorkspaceMode } from "@posthog/shared";
import {
readPrUrls,
type TaskLabel,
taskLabelRank,
type WorkspaceMode,
} from "@posthog/shared";
import type { Task, TaskRunStatus } from "@posthog/shared/domain-types";
import { getRepositoryInfo } from "./groupTasks";
import type { TaskData, TaskGroup } from "./sidebarData.types";

export type SortMode = "updated" | "created";
export type SortMode = "updated" | "created" | "priority";
export type OrganizeMode = "by-project" | "chronological";

export interface FullTask {
Expand Down Expand Up @@ -123,6 +128,7 @@ export interface TaskWorkspace {
export interface TaskTimestamp {
lastViewedAt?: number | null;
lastActivityAt?: number | null;
label?: TaskLabel | null;
}

export interface DeriveTaskDataContext {
Expand Down Expand Up @@ -188,6 +194,7 @@ export function deriveTaskData(
cloudPrUrl,
branchName: workspace?.branchName ?? null,
linkedBranch: workspace?.linkedBranch ?? null,
label: timestamp?.label ?? null,
};
}

Expand Down Expand Up @@ -220,13 +227,19 @@ export function filterByWorkspaceMode(
}

function getSortValue(task: TaskData, sortMode: SortMode): number {
return sortMode === "updated" ? task.lastActivityAt : task.createdAt;
return sortMode === "created" ? task.createdAt : task.lastActivityAt;
}

function sortTasks(tasks: TaskData[], sortMode: SortMode): TaskData[] {
return [...tasks].sort(
(a, b) => getSortValue(b, sortMode) - getSortValue(a, sortMode),
);
return [...tasks].sort((a, b) => {
// Priority sorts by user-set label rank first, falling back to recency
// within a rank (getSortValue reads lastActivityAt for non-"created").
if (sortMode === "priority") {
const byRank = taskLabelRank(a.label) - taskLabelRank(b.label);
if (byRank !== 0) return byRank;
}
return getSortValue(b, sortMode) - getSortValue(a, sortMode);
});
}

export interface PartitionedTasks {
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/sidebar/filterByWorkspaceMode.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ const task = (overrides: Partial<TaskData>): TaskData => ({
cloudPrUrl: null,
branchName: null,
linkedBranch: null,
label: null,
...overrides,
});

Expand Down
1 change: 1 addition & 0 deletions packages/core/src/sidebar/runEnvironment.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ const task = (overrides: Partial<TaskData>): TaskData => ({
cloudPrUrl: null,
branchName: null,
linkedBranch: null,
label: null,
...overrides,
});

Expand Down
Loading
Loading