diff --git a/apps/code/src/main/platform-adapters/electron-context-menu.ts b/apps/code/src/main/platform-adapters/electron-context-menu.ts index 4a38374c41..ab1ee35b46 100644 --- a/apps/code/src/main/platform-adapters/electron-context-menu.ts +++ b/apps/code/src/main/platform-adapters/electron-context-menu.ts @@ -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); } diff --git a/apps/web/src/web-host-router.ts b/apps/web/src/web-host-router.ts index d562ba4cd1..1634bf5312 100644 --- a/apps/web/src/web-host-router.ts +++ b/apps/web/src/web-host-router.ts @@ -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"; @@ -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 }) => { diff --git a/apps/web/src/web-task-metadata-store.ts b/apps/web/src/web-task-metadata-store.ts index b7c25182b5..18a1901d5f 100644 --- a/apps/web/src/web-task-metadata-store.ts +++ b/apps/web/src/web-task-metadata-store.ts @@ -1,3 +1,4 @@ +import { type TaskLabel, taskLabelSchema } from "@posthog/shared"; import { z } from "zod"; import { createRecordStore } from "./web-local-store"; @@ -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; @@ -19,6 +22,7 @@ const EMPTY: TaskMetadata = { pinnedAt: null, lastViewedAt: null, lastActivityAt: null, + label: null, }; const store = createRecordStore( @@ -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; diff --git a/packages/core/src/context-menu/context-menu.test.ts b/packages/core/src/context-menu/context-menu.test.ts index b06a4a79cb..5f8732afb3 100644 --- a/packages/core/src/context-menu/context-menu.test.ts +++ b/packages/core/src/context-menu/context-menu.test.ts @@ -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); diff --git a/packages/core/src/context-menu/context-menu.ts b/packages/core/src/context-menu/context-menu.ts index d4437e9488..a6383d1fec 100644 --- a/packages/core/src/context-menu/context-menu.ts +++ b/packages/core/src/context-menu/context-menu.ts @@ -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, @@ -40,6 +41,7 @@ import type { ConfirmOptions, MenuItemDef, SeparatorDef, + SubmenuItemDef, } from "./types"; @injectable() @@ -118,6 +120,7 @@ export class ContextMenuService { isInCommandCenter, hasEmptyCommandCenterCell, channels, + currentLabel, } = input; const { apps, lastUsedAppId } = await this.getExternalAppsData(); const hasPath = worktreePath || folderPath; @@ -139,9 +142,28 @@ export class ContextMenuService { ] : []; + // Radio-style label picker: every label plus "None", the current one checked. + const labelSubmenu: SubmenuItemDef = { + 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([ this.item(isPinned ? "Unpin" : "Pin", { type: "pin" }), this.item("Rename", { type: "rename" }), + labelSubmenu, ...(canStop ? [this.separator(), this.item("Stop task", { type: "stop" as const })] : []), @@ -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: () => {}, diff --git a/packages/core/src/context-menu/schemas.ts b/packages/core/src/context-menu/schemas.ts index b6b664ede4..6e55b5c6e6 100644 --- a/packages/core/src/context-menu/schemas.ts +++ b/packages/core/src/context-menu/schemas.ts @@ -1,3 +1,4 @@ +import { taskLabelSchema } from "@posthog/shared"; import { z } from "zod"; export const taskContextMenuInput = z.object({ @@ -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(), @@ -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", [ diff --git a/packages/core/src/context-menu/types.ts b/packages/core/src/context-menu/types.ts index c76c93d9d1..e3fd32f324 100644 --- a/packages/core/src/context-menu/types.ts +++ b/packages/core/src/context-menu/types.ts @@ -21,6 +21,8 @@ export interface SubmenuItemDef { items: Array<{ label: string; icon?: string; + /** Checkmark for radio-style submenus (e.g. the current task label). */ + checked?: boolean; action: T; }>; } diff --git a/packages/core/src/sidebar/buildSidebarData.test.ts b/packages/core/src/sidebar/buildSidebarData.test.ts index 705fab5251..2c28b70fef 100644 --- a/packages/core/src/sidebar/buildSidebarData.test.ts +++ b/packages/core/src/sidebar/buildSidebarData.test.ts @@ -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 { @@ -18,6 +25,7 @@ function makeTask(id: string): TaskData { cloudPrUrl: null, branchName: null, linkedBranch: null, + label: null, }; } @@ -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(), + suspendedIds: new Set(), + slackTaskIds: new Set(), + slackThreadUrlByTaskId: new Map(), + }; + + 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))); diff --git a/packages/core/src/sidebar/buildSidebarData.ts b/packages/core/src/sidebar/buildSidebarData.ts index 2f15573010..4ebb4e143f 100644 --- a/packages/core/src/sidebar/buildSidebarData.ts +++ b/packages/core/src/sidebar/buildSidebarData.ts @@ -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 { @@ -123,6 +128,7 @@ export interface TaskWorkspace { export interface TaskTimestamp { lastViewedAt?: number | null; lastActivityAt?: number | null; + label?: TaskLabel | null; } export interface DeriveTaskDataContext { @@ -188,6 +194,7 @@ export function deriveTaskData( cloudPrUrl, branchName: workspace?.branchName ?? null, linkedBranch: workspace?.linkedBranch ?? null, + label: timestamp?.label ?? null, }; } @@ -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 { diff --git a/packages/core/src/sidebar/filterByWorkspaceMode.test.ts b/packages/core/src/sidebar/filterByWorkspaceMode.test.ts index e678f5840f..545a488c92 100644 --- a/packages/core/src/sidebar/filterByWorkspaceMode.test.ts +++ b/packages/core/src/sidebar/filterByWorkspaceMode.test.ts @@ -18,6 +18,7 @@ const task = (overrides: Partial): TaskData => ({ cloudPrUrl: null, branchName: null, linkedBranch: null, + label: null, ...overrides, }); diff --git a/packages/core/src/sidebar/runEnvironment.test.ts b/packages/core/src/sidebar/runEnvironment.test.ts index f2a1207455..f4bee39659 100644 --- a/packages/core/src/sidebar/runEnvironment.test.ts +++ b/packages/core/src/sidebar/runEnvironment.test.ts @@ -17,6 +17,7 @@ const task = (overrides: Partial): TaskData => ({ cloudPrUrl: null, branchName: null, linkedBranch: null, + label: null, ...overrides, }); diff --git a/packages/core/src/sidebar/selection.test.ts b/packages/core/src/sidebar/selection.test.ts index 9663cd72f2..a1f0021a29 100644 --- a/packages/core/src/sidebar/selection.test.ts +++ b/packages/core/src/sidebar/selection.test.ts @@ -26,6 +26,7 @@ function makeTaskData(id: string, overrides: Partial = {}): TaskData { cloudPrUrl: null, branchName: null, linkedBranch: null, + label: null, ...overrides, }; } diff --git a/packages/core/src/sidebar/sidebarData.types.ts b/packages/core/src/sidebar/sidebarData.types.ts index 706c139150..225898a20e 100644 --- a/packages/core/src/sidebar/sidebarData.types.ts +++ b/packages/core/src/sidebar/sidebarData.types.ts @@ -1,4 +1,4 @@ -import type { WorkspaceMode } from "@posthog/shared"; +import type { TaskLabel, WorkspaceMode } from "@posthog/shared"; import type { TaskRunStatus } from "@posthog/shared/domain-types"; import type { TaskGroup as GenericTaskGroup, @@ -26,6 +26,8 @@ export interface TaskData { cloudPrUrl: string | null; branchName: string | null; linkedBranch: string | null; + /** User-set label from device-local metadata; null = unlabeled. */ + label: TaskLabel | null; } export type TaskGroup = GenericTaskGroup; diff --git a/packages/core/src/sidebar/taskMeta.test.ts b/packages/core/src/sidebar/taskMeta.test.ts new file mode 100644 index 0000000000..dcebc979eb --- /dev/null +++ b/packages/core/src/sidebar/taskMeta.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from "vitest"; +import { parseTimestamps } from "./taskMeta"; + +describe("parseTimestamps", () => { + it("parses ISO strings to epoch ms and passes the label through", () => { + const result = parseTimestamps({ + t1: { + pinnedAt: null, + lastViewedAt: "2026-01-01T00:00:00.000Z", + lastActivityAt: "2026-01-02T00:00:00.000Z", + label: "high-priority", + }, + }); + + expect(result.t1).toEqual({ + lastViewedAt: new Date("2026-01-01T00:00:00.000Z").getTime(), + lastActivityAt: new Date("2026-01-02T00:00:00.000Z").getTime(), + label: "high-priority", + }); + }); + + it("keeps a null label for an unlabeled task", () => { + const result = parseTimestamps({ + t1: { + pinnedAt: null, + lastViewedAt: null, + lastActivityAt: null, + label: null, + }, + }); + + expect(result.t1).toEqual({ + lastViewedAt: null, + lastActivityAt: null, + label: null, + }); + }); +}); diff --git a/packages/core/src/sidebar/taskMeta.ts b/packages/core/src/sidebar/taskMeta.ts index ccbe03a7f5..7e68df2d53 100644 --- a/packages/core/src/sidebar/taskMeta.ts +++ b/packages/core/src/sidebar/taskMeta.ts @@ -1,12 +1,16 @@ +import type { TaskLabel } from "@posthog/shared"; + export interface RawTaskTimestamp { pinnedAt: string | null; lastViewedAt: string | null; lastActivityAt: string | null; + label: TaskLabel | null; } export interface TaskTimestamps { lastViewedAt: number | null; lastActivityAt: number | null; + label: TaskLabel | null; } export function parseTimestamps( @@ -21,6 +25,7 @@ export function parseTimestamps( lastActivityAt: ts.lastActivityAt ? new Date(ts.lastActivityAt).getTime() : null, + label: ts.label ?? null, }; } return result; diff --git a/packages/core/src/sidebar/taskRunning.test.ts b/packages/core/src/sidebar/taskRunning.test.ts index 6d322c47f5..5248b8dc50 100644 --- a/packages/core/src/sidebar/taskRunning.test.ts +++ b/packages/core/src/sidebar/taskRunning.test.ts @@ -18,6 +18,7 @@ const task = (overrides: Partial): TaskData => ({ cloudPrUrl: null, branchName: null, linkedBranch: null, + label: null, ...overrides, }); diff --git a/packages/core/src/tasks/contextMenuActions.test.ts b/packages/core/src/tasks/contextMenuActions.test.ts index 9e06ca86fe..35c5f586c8 100644 --- a/packages/core/src/tasks/contextMenuActions.test.ts +++ b/packages/core/src/tasks/contextMenuActions.test.ts @@ -32,6 +32,15 @@ describe("resolveTaskContextMenuIntent", () => { ); }); + it("carries the set-label payload, including a null clear", () => { + expect( + resolveTaskContextMenuIntent({ type: "set-label", label: "done" }, {}), + ).toEqual({ type: "set-label", label: "done" }); + expect( + resolveTaskContextMenuIntent({ type: "set-label", label: null }, {}), + ).toEqual({ type: "set-label", label: null }); + }); + it("carries the external-app action payload", () => { expect( resolveTaskContextMenuIntent( diff --git a/packages/core/src/tasks/contextMenuActions.ts b/packages/core/src/tasks/contextMenuActions.ts index a9d12056ea..c22af391e1 100644 --- a/packages/core/src/tasks/contextMenuActions.ts +++ b/packages/core/src/tasks/contextMenuActions.ts @@ -2,6 +2,7 @@ import type { ExternalAppAction, TaskAction, } from "@posthog/core/context-menu/schemas"; +import type { TaskLabel } from "@posthog/shared"; export type TaskContextMenuIntent = | { type: "rename" } @@ -14,6 +15,7 @@ export type TaskContextMenuIntent = | { type: "delete" } | { type: "add-to-command-center" } | { type: "file-to-channel"; channelId: string } + | { type: "set-label"; label: TaskLabel | null } | { type: "external-app"; action: ExternalAppAction }; export function resolveTaskContextMenuIntent( @@ -39,6 +41,8 @@ export function resolveTaskContextMenuIntent( return { type: "add-to-command-center" }; case "file-to-channel": return { type: "file-to-channel", channelId: action.channelId }; + case "set-label": + return { type: "set-label", label: action.label }; case "external-app": return { type: "external-app", action: action.action }; } diff --git a/packages/host-router/src/routers/workspace.router.ts b/packages/host-router/src/routers/workspace.router.ts index 94e8094ea5..4811a52647 100644 --- a/packages/host-router/src/routers/workspace.router.ts +++ b/packages/host-router/src/routers/workspace.router.ts @@ -39,6 +39,8 @@ import { reconcileCloudWorkspacesInput, reconcileCloudWorkspacesOutput, setPrimaryPrUrlInput, + setTaskLabelInput, + setTaskLabelOutput, taskPrStatusInput, taskPrStatusOutput, togglePinInput, @@ -199,6 +201,13 @@ export const workspaceRouter = router({ getMetadata(ctx.container).togglePin(input.taskId), ), + setTaskLabel: publicProcedure + .input(setTaskLabelInput) + .output(setTaskLabelOutput) + .mutation(({ ctx, input }) => + getMetadata(ctx.container).setTaskLabel(input.taskId, input.label), + ), + markViewed: publicProcedure .input(markViewedInput) .mutation(({ ctx, input }) => diff --git a/packages/platform/src/context-menu.ts b/packages/platform/src/context-menu.ts index 5aed4f02fa..6e87b586eb 100644 --- a/packages/platform/src/context-menu.ts +++ b/packages/platform/src/context-menu.ts @@ -3,6 +3,8 @@ export interface ContextMenuAction { icon?: string; enabled?: boolean; accelerator?: string; + /** Renders the item with a checkmark (radio-style selection in submenus). */ + checked?: boolean; submenu?: ContextMenuItem[]; click: () => void | Promise; } diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index ff03602e7e..3bfdb599e6 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -289,6 +289,14 @@ export type { TaskCreationInput, TaskCreationOutput, } from "./task-creation-domain"; +export { + TASK_LABEL_META, + TASK_LABELS, + type TaskLabel, + type TaskLabelMeta, + taskLabelRank, + taskLabelSchema, +} from "./task-label"; export { formatClockTime, formatRelativeTimeLong, diff --git a/packages/shared/src/task-label.test.ts b/packages/shared/src/task-label.test.ts new file mode 100644 index 0000000000..8c4d095ada --- /dev/null +++ b/packages/shared/src/task-label.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from "vitest"; +import { + TASK_LABEL_META, + TASK_LABELS, + type TaskLabel, + taskLabelRank, + taskLabelSchema, +} from "./task-label"; + +describe("taskLabelSchema", () => { + it.each(TASK_LABELS)("accepts %s", (label) => { + expect(taskLabelSchema.parse(label)).toBe(label); + }); + + it("rejects values outside the fixed set", () => { + expect(taskLabelSchema.safeParse("urgent").success).toBe(false); + expect(taskLabelSchema.safeParse("").success).toBe(false); + }); +}); + +describe("TASK_LABEL_META", () => { + it("covers every label with a display name and accent", () => { + for (const label of TASK_LABELS) { + expect(TASK_LABEL_META[label].displayName).toBeTruthy(); + expect(TASK_LABEL_META[label].accent).toMatch(/^var\(--/); + } + }); +}); + +describe("taskLabelRank", () => { + it("orders high-priority and active above unlabeled, deprioritized and done below", () => { + const order: (TaskLabel | null)[] = [ + "high-priority", + "active", + null, + "deprioritized", + "done", + ]; + const ranks = order.map(taskLabelRank); + expect([...ranks].sort((a, b) => a - b)).toEqual(ranks); + expect(new Set(ranks).size).toBe(ranks.length); + }); +}); diff --git a/packages/shared/src/task-label.ts b/packages/shared/src/task-label.ts new file mode 100644 index 0000000000..dc49f98260 --- /dev/null +++ b/packages/shared/src/task-label.ts @@ -0,0 +1,44 @@ +import { z } from "zod"; + +// User-set task labels: a small fixed triage vocabulary. Lives in shared so +// workspace-server (persistence schema), core (sorting/derivation), ui +// (rendering), and the web host all share one source of truth. +export const TASK_LABELS = [ + "high-priority", + "active", + "deprioritized", + "done", +] as const; + +export const taskLabelSchema = z.enum(TASK_LABELS); +export type TaskLabel = z.infer; + +export interface TaskLabelMeta { + displayName: string; + /** Radix accent CSS variable, rendered as the row dot's background. */ + accent: string; + /** Sort position for label-aware ordering; unlabeled tasks rank between + * "active" and "deprioritized" (see {@link taskLabelRank}). */ + rank: number; +} + +export const TASK_LABEL_META: Record = { + "high-priority": { + displayName: "High priority", + accent: "var(--orange-9)", + rank: 0, + }, + active: { displayName: "Active", accent: "var(--blue-9)", rank: 1 }, + deprioritized: { + displayName: "Deprioritized", + accent: "var(--gray-9)", + rank: 3, + }, + done: { displayName: "Done", accent: "var(--grass-9)", rank: 4 }, +}; + +const UNLABELED_RANK = 2; + +export function taskLabelRank(label: TaskLabel | null): number { + return label ? TASK_LABEL_META[label].rank : UNLABELED_RANK; +} diff --git a/packages/ui/src/features/sidebar/components/SidebarMenu.tsx b/packages/ui/src/features/sidebar/components/SidebarMenu.tsx index 3be10a1f11..b5d819e54b 100644 --- a/packages/ui/src/features/sidebar/components/SidebarMenu.tsx +++ b/packages/ui/src/features/sidebar/components/SidebarMenu.tsx @@ -324,6 +324,7 @@ function SidebarMenuComponent() { runId, isInCommandCenter, hasEmptyCommandCenterCell, + currentLabel: taskData?.label ?? null, onTogglePin: () => handleTaskTogglePin(taskId), onStop: (stopTaskId, taskTitle, stopRunId) => setStopConfirm({ diff --git a/packages/ui/src/features/sidebar/components/TaskListView.tsx b/packages/ui/src/features/sidebar/components/TaskListView.tsx index f21d0b11fc..d86bc6bc8e 100644 --- a/packages/ui/src/features/sidebar/components/TaskListView.tsx +++ b/packages/ui/src/features/sidebar/components/TaskListView.tsx @@ -105,6 +105,7 @@ function TaskRow({ depth={depth} taskId={task.id} label={task.title} + taskLabel={task.label} isActive={isActive} isSelected={isSelected} isArchiving={isArchiving} @@ -191,8 +192,9 @@ export function TaskListView({ useSidebarStore.getState().reorderFolders(sourceIndex, targetIndex); }, []); + // Priority sort tie-breaks by activity, so its rows show that timestamp too. const timestampKey: "lastActivityAt" | "createdAt" = - sortMode === "updated" ? "lastActivityAt" : "createdAt"; + sortMode === "created" ? "createdAt" : "lastActivityAt"; const dateGroupedTasks = useMemo( () => groupTasksByRelativeDate(flatTasks, timestampKey), diff --git a/packages/ui/src/features/sidebar/components/TasksHeader.tsx b/packages/ui/src/features/sidebar/components/TasksHeader.tsx index edb49e4e8e..809499da0b 100644 --- a/packages/ui/src/features/sidebar/components/TasksHeader.tsx +++ b/packages/ui/src/features/sidebar/components/TasksHeader.tsx @@ -147,6 +147,9 @@ function TaskFilterMenu() { > Created Updated + + Priority + {import.meta.env.DEV && ( diff --git a/packages/ui/src/features/sidebar/components/items/TaskItem.test.tsx b/packages/ui/src/features/sidebar/components/items/TaskItem.test.tsx new file mode 100644 index 0000000000..d43d409e42 --- /dev/null +++ b/packages/ui/src/features/sidebar/components/items/TaskItem.test.tsx @@ -0,0 +1,44 @@ +import type { TaskLabel } from "@posthog/shared"; +import { Theme } from "@radix-ui/themes"; +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { TaskItem } from "./TaskItem"; + +if (typeof globalThis.ResizeObserver === "undefined") { + globalThis.ResizeObserver = class { + observe() {} + unobserve() {} + disconnect() {} + } as unknown as typeof ResizeObserver; +} + +function renderItem(taskLabel: TaskLabel | null) { + return render( + + {}} + onContextMenu={() => {}} + /> + , + ); +} + +describe("TaskItem label dot", () => { + it("shows a dot named after the label when the task is labeled", () => { + renderItem("high-priority"); + expect( + screen.getByRole("img", { name: "Label: High priority" }), + ).toBeInTheDocument(); + }); + + it("renders nothing label-related when the task is unlabeled", () => { + renderItem(null); + expect( + screen.queryByRole("img", { name: /^Label:/ }), + ).not.toBeInTheDocument(); + }); +}); diff --git a/packages/ui/src/features/sidebar/components/items/TaskItem.tsx b/packages/ui/src/features/sidebar/components/items/TaskItem.tsx index 3d68b86474..ed1a93df60 100644 --- a/packages/ui/src/features/sidebar/components/items/TaskItem.tsx +++ b/packages/ui/src/features/sidebar/components/items/TaskItem.tsx @@ -1,6 +1,6 @@ import { Archive, GitPullRequest, PushPin } from "@phosphor-icons/react"; import { parseGithubUrl } from "@posthog/git/utils"; -import type { WorkspaceMode } from "@posthog/shared"; +import type { TaskLabel, WorkspaceMode } from "@posthog/shared"; import { formatRelativeTimeShort } from "@posthog/shared"; import type { TaskRunStatus } from "@posthog/shared/domain-types"; import { navigateToPullRequestView } from "@posthog/ui/router/navigationBridge"; @@ -11,6 +11,7 @@ import { Tooltip } from "../../../../primitives/Tooltip"; import type { SidebarPrState } from "../../useTaskPrStatus"; import { SidebarItem } from "../SidebarItem"; import { ICON_SIZE, TaskIcon } from "./TaskIcon"; +import { TaskLabelDot } from "./TaskLabelDot"; function PrBadge({ url, number }: { url: string; number: number }) { return ( @@ -33,6 +34,8 @@ interface TaskItemProps { depth?: number; taskId: string; label: string; + /** User-set task label (distinct from `label`, which is the row title). */ + taskLabel?: TaskLabel | null; isActive: boolean; isSelected?: boolean; /** Archive request in flight: show a spinner and suppress hover actions. */ @@ -106,6 +109,7 @@ export function TaskItem({ depth = 0, taskId, label, + taskLabel = null, isActive, isSelected = false, isArchiving = false, @@ -173,9 +177,12 @@ export function TaskItem({ /> ) : null; + const labelDot = taskLabel ? : null; + const endContent = - prBadge || timestampNode || toolbar ? ( + labelDot || prBadge || timestampNode || toolbar ? ( <> + {labelDot} {prBadge} {timestampNode} {toolbar} diff --git a/packages/ui/src/features/sidebar/components/items/TaskLabelDot.tsx b/packages/ui/src/features/sidebar/components/items/TaskLabelDot.tsx new file mode 100644 index 0000000000..bdfb9e7e57 --- /dev/null +++ b/packages/ui/src/features/sidebar/components/items/TaskLabelDot.tsx @@ -0,0 +1,22 @@ +import { TASK_LABEL_META, type TaskLabel } from "@posthog/shared"; +import { Tooltip } from "../../../../primitives/Tooltip"; + +/** + * Subtle colored dot for a task's user-set label. Reused wherever a task row + * renders (sidebar today; channel feed cards can adopt it later). Renders + * nothing when the task is unlabeled. + */ +export function TaskLabelDot({ label }: { label: TaskLabel | null }) { + if (!label) return null; + const meta = TASK_LABEL_META[label]; + return ( + + + + ); +} diff --git a/packages/ui/src/features/sidebar/sidebarStore.ts b/packages/ui/src/features/sidebar/sidebarStore.ts index 2067513dd4..2ce3719f5c 100644 --- a/packages/ui/src/features/sidebar/sidebarStore.ts +++ b/packages/ui/src/features/sidebar/sidebarStore.ts @@ -1,4 +1,7 @@ -import { ALL_WORKSPACE_MODES } from "@posthog/core/sidebar/buildSidebarData"; +import { + ALL_WORKSPACE_MODES, + type SortMode, +} from "@posthog/core/sidebar/buildSidebarData"; import type { WorkspaceMode } from "@posthog/shared"; import { create } from "zustand"; import { persist } from "zustand/middleware"; @@ -19,7 +22,7 @@ interface SidebarStoreState { folderOrder: string[]; historyVisibleCount: number; organizeMode: "by-project" | "chronological"; - sortMode: "updated" | "created"; + sortMode: SortMode; showAllUsers: boolean; showInternal: boolean; taskTypeFilter: WorkspaceMode[]; diff --git a/packages/ui/src/features/sidebar/useTaskLabel.ts b/packages/ui/src/features/sidebar/useTaskLabel.ts new file mode 100644 index 0000000000..fbe428c6c5 --- /dev/null +++ b/packages/ui/src/features/sidebar/useTaskLabel.ts @@ -0,0 +1,64 @@ +import type { RawTaskTimestamp } from "@posthog/core/sidebar/taskMeta"; +import { useHostTRPC, useHostTRPCClient } from "@posthog/host-router/react"; +import type { TaskLabel } from "@posthog/shared"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useCallback, useRef } from "react"; + +/** + * Sets a task's user-set label. Rides the shared getAllTaskTimestamps record + * (the same per-task metadata the sidebar already subscribes to), so the row + * updates optimistically and rolls back on failure. + */ +export function useTaskLabel() { + const trpc = useHostTRPC(); + const hostClient = useHostTRPCClient(); + const queryClient = useQueryClient(); + const timestampsQueryKey = trpc.workspace.getAllTaskTimestamps.queryKey(); + + const setLabelMutation = useMutation({ + mutationFn: ({ + taskId, + label, + }: { + taskId: string; + label: TaskLabel | null; + }) => hostClient.workspace.setTaskLabel.mutate({ taskId, label }), + onMutate: async ({ taskId, label }) => { + await queryClient.cancelQueries({ queryKey: timestampsQueryKey }); + const previous = + queryClient.getQueryData>( + timestampsQueryKey, + ); + queryClient.setQueryData>( + timestampsQueryKey, + (old) => { + const base = old?.[taskId] ?? { + pinnedAt: null, + lastViewedAt: null, + lastActivityAt: null, + label: null, + }; + return { ...old, [taskId]: { ...base, label } }; + }, + ); + return { previous }; + }, + onError: (_, __, context) => { + if (context?.previous) { + queryClient.setQueryData(timestampsQueryKey, context.previous); + } + }, + }); + + const setLabelMutationRef = useRef(setLabelMutation); + setLabelMutationRef.current = setLabelMutation; + + const setLabel = useCallback( + async (taskId: string, label: TaskLabel | null) => { + await setLabelMutationRef.current.mutateAsync({ taskId, label }); + }, + [], + ); + + return { setLabel }; +} diff --git a/packages/ui/src/features/sidebar/useTaskPrStatus.test.ts b/packages/ui/src/features/sidebar/useTaskPrStatus.test.ts index 0e16f9904a..00960285ff 100644 --- a/packages/ui/src/features/sidebar/useTaskPrStatus.test.ts +++ b/packages/ui/src/features/sidebar/useTaskPrStatus.test.ts @@ -40,6 +40,7 @@ function makeTask(overrides: Partial = {}): TaskData { cloudPrUrl: null, branchName: "feat/test", linkedBranch: null, + label: null, ...overrides, }; } diff --git a/packages/ui/src/features/sidebar/useTaskViewed.ts b/packages/ui/src/features/sidebar/useTaskViewed.ts index 870885a186..7b76850483 100644 --- a/packages/ui/src/features/sidebar/useTaskViewed.ts +++ b/packages/ui/src/features/sidebar/useTaskViewed.ts @@ -42,6 +42,7 @@ export function useTaskViewed() { pinnedAt: null, lastViewedAt: now, lastActivityAt: null, + label: null, }, }; return { @@ -84,6 +85,7 @@ export function useTaskViewed() { pinnedAt: null, lastViewedAt: null, lastActivityAt: activityIso, + label: null, }, }; return { diff --git a/packages/ui/src/features/tasks/useTaskContextMenu.ts b/packages/ui/src/features/tasks/useTaskContextMenu.ts index 2aa6a32b4e..4e0330fe5a 100644 --- a/packages/ui/src/features/tasks/useTaskContextMenu.ts +++ b/packages/ui/src/features/tasks/useTaskContextMenu.ts @@ -3,13 +3,14 @@ import { resolveTaskContextMenuIntent, } from "@posthog/core/tasks/contextMenuActions"; import { useHostTRPCClient } from "@posthog/host-router/react"; -import { PROJECT_BLUEBIRD_FLAG } from "@posthog/shared"; +import { PROJECT_BLUEBIRD_FLAG, type TaskLabel } from "@posthog/shared"; import type { Task } from "@posthog/shared/domain-types"; import { useArchiveTask } from "@posthog/ui/features/archive/useArchiveTask"; import { useChannels } from "@posthog/ui/features/canvas/hooks/useChannels"; import { useChannelTaskMutations } from "@posthog/ui/features/canvas/hooks/useChannelTasks"; import { useExternalAppAction } from "@posthog/ui/features/external-apps/useExternalAppAction"; import { useFeatureFlag } from "@posthog/ui/features/feature-flags/useFeatureFlag"; +import { useTaskLabel } from "@posthog/ui/features/sidebar/useTaskLabel"; import { useRestoreTask } from "@posthog/ui/features/suspension/useRestoreTask"; import { useSuspendTask } from "@posthog/ui/features/suspension/useSuspendTask"; import { useDeleteTask } from "@posthog/ui/features/tasks/useTaskCrudMutations"; @@ -35,6 +36,7 @@ export function useTaskContextMenu() { ); const { channels } = useChannels({ enabled: bluebirdEnabled }); const { fileTask } = useChannelTaskMutations(); + const { setLabel } = useTaskLabel(); const showContextMenu = useCallback( async ( @@ -49,6 +51,7 @@ export function useTaskContextMenu() { runId?: string; isInCommandCenter?: boolean; hasEmptyCommandCenterCell?: boolean; + currentLabel?: TaskLabel | null; onTogglePin?: () => void; onStop?: (taskId: string, taskTitle: string, runId?: string) => void; onArchive?: (taskId: string) => void; @@ -68,6 +71,7 @@ export function useTaskContextMenu() { runId, isInCommandCenter, hasEmptyCommandCenterCell, + currentLabel, onTogglePin, onStop, onArchive, @@ -85,6 +89,7 @@ export function useTaskContextMenu() { canStop, isInCommandCenter, hasEmptyCommandCenterCell, + currentLabel, channels: channels.map(({ id, name }) => ({ id, name })), }); @@ -131,6 +136,16 @@ export function useTaskContextMenu() { case "add-to-command-center": onAddToCommandCenter?.(); break; + case "set-label": + try { + await setLabel(task.id, intent.label); + } catch (error) { + toast.error("Couldn't set task label", { + description: + error instanceof Error ? error.message : String(error), + }); + } + break; case "file-to-channel": try { await fileTask(intent.channelId, task.id, task.title); @@ -167,6 +182,7 @@ export function useTaskContextMenu() { deleteWithConfirm, fileTask, restoreTask, + setLabel, suspendTask, hostClient, openExternalApp, diff --git a/packages/workspace-server/src/db/migrations/0023_task_labels.sql b/packages/workspace-server/src/db/migrations/0023_task_labels.sql new file mode 100644 index 0000000000..5bff0f1d48 --- /dev/null +++ b/packages/workspace-server/src/db/migrations/0023_task_labels.sql @@ -0,0 +1,3 @@ +ALTER TABLE `workspaces` ADD `label` text; +--> statement-breakpoint +ALTER TABLE `task_metadata` ADD `label` text; diff --git a/packages/workspace-server/src/db/migrations/meta/_journal.json b/packages/workspace-server/src/db/migrations/meta/_journal.json index c36e3f08f0..83236a932a 100644 --- a/packages/workspace-server/src/db/migrations/meta/_journal.json +++ b/packages/workspace-server/src/db/migrations/meta/_journal.json @@ -162,6 +162,13 @@ "when": 1784804000000, "tag": "0022_archive_task_details", "breakpoints": true + }, + { + "idx": 23, + "version": "6", + "when": 1784950000000, + "tag": "0023_task_labels", + "breakpoints": true } ] } diff --git a/packages/workspace-server/src/db/repositories/task-label-persistence.test.ts b/packages/workspace-server/src/db/repositories/task-label-persistence.test.ts new file mode 100644 index 0000000000..9ad3a9f87f --- /dev/null +++ b/packages/workspace-server/src/db/repositories/task-label-persistence.test.ts @@ -0,0 +1,53 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import type { DatabaseService } from "../service"; +import { createTestDb, type TestDatabase } from "../test-helpers"; +import { TaskMetadataRepository } from "./task-metadata-repository"; +import { WorkspaceRepository } from "./workspace-repository"; + +// Round-trips labels through real SQLite (createTestDb applies the full +// migration chain), so a broken 0023_task_labels migration fails here rather +// than on first launch. + +let testDb: TestDatabase; +let workspaces: WorkspaceRepository; +let taskMetadata: TaskMetadataRepository; + +beforeEach(() => { + testDb = createTestDb(); + const databaseService = { db: testDb.db } as unknown as DatabaseService; + workspaces = new WorkspaceRepository(databaseService); + taskMetadata = new TaskMetadataRepository(databaseService); +}); + +afterEach(() => { + testDb.close(); +}); + +describe("workspace label persistence", () => { + it("stores, reads back, and clears a label on the workspace row", () => { + workspaces.create({ taskId: "t1", repositoryId: null, mode: "cloud" }); + expect(workspaces.findByTaskId("t1")?.label).toBeNull(); + + workspaces.updateLabel("t1", "high-priority"); + expect(workspaces.findByTaskId("t1")?.label).toBe("high-priority"); + + workspaces.updateLabel("t1", null); + expect(workspaces.findByTaskId("t1")?.label).toBeNull(); + }); +}); + +describe("task_metadata label persistence (rowless tasks)", () => { + it("stores, reads back, and clears a label", () => { + taskMetadata.upsert("t1", { label: "done" }); + expect(taskMetadata.findByTaskId("t1")?.label).toBe("done"); + + taskMetadata.upsert("t1", { label: null }); + expect(taskMetadata.findByTaskId("t1")?.label).toBeNull(); + }); + + it("keeps the label when other metadata fields are patched", () => { + taskMetadata.upsert("t1", { label: "active" }); + taskMetadata.upsert("t1", { lastViewedAt: "2026-01-01T00:00:00.000Z" }); + expect(taskMetadata.findByTaskId("t1")?.label).toBe("active"); + }); +}); diff --git a/packages/workspace-server/src/db/repositories/task-metadata-repository.mock.ts b/packages/workspace-server/src/db/repositories/task-metadata-repository.mock.ts index 0d20965e9e..1084c5c4c8 100644 --- a/packages/workspace-server/src/db/repositories/task-metadata-repository.mock.ts +++ b/packages/workspace-server/src/db/repositories/task-metadata-repository.mock.ts @@ -29,6 +29,8 @@ export function createMockTaskMetadataRepository(): MockTaskMetadataRepository { "lastActivityAt" in patch ? (patch.lastActivityAt ?? null) : (existing?.lastActivityAt ?? null), + label: + "label" in patch ? (patch.label ?? null) : (existing?.label ?? null), archivedAt: "archivedAt" in patch ? (patch.archivedAt ?? null) diff --git a/packages/workspace-server/src/db/repositories/task-metadata-repository.ts b/packages/workspace-server/src/db/repositories/task-metadata-repository.ts index 67ad562e9f..b4cdf2e332 100644 --- a/packages/workspace-server/src/db/repositories/task-metadata-repository.ts +++ b/packages/workspace-server/src/db/repositories/task-metadata-repository.ts @@ -1,3 +1,4 @@ +import type { TaskLabel } from "@posthog/shared"; import { eq, isNotNull } from "drizzle-orm"; import { inject, injectable } from "inversify"; import { DATABASE_SERVICE } from "../identifiers"; @@ -13,6 +14,7 @@ export interface TaskMetadataPatch { pinnedAt?: string | null; lastViewedAt?: string | null; lastActivityAt?: string | null; + label?: TaskLabel | null; archivedAt?: string | null; archivedTitle?: string | null; archivedTaskCreatedAt?: string | null; diff --git a/packages/workspace-server/src/db/repositories/workspace-repository.mock.ts b/packages/workspace-server/src/db/repositories/workspace-repository.mock.ts index 9d99bc3b2a..c96a11126d 100644 --- a/packages/workspace-server/src/db/repositories/workspace-repository.mock.ts +++ b/packages/workspace-server/src/db/repositories/workspace-repository.mock.ts @@ -63,6 +63,7 @@ export function createMockWorkspaceRepository(): MockWorkspaceRepository { pinnedAt: null, lastViewedAt: null, lastActivityAt: null, + label: null, linkedBranch: null, additionalDirectories: "[]", prUrl: null, @@ -86,6 +87,7 @@ export function createMockWorkspaceRepository(): MockWorkspaceRepository { pinnedAt: null, lastViewedAt: null, lastActivityAt: null, + label: null, linkedBranch: null, additionalDirectories: "[]", prUrl: null, @@ -124,6 +126,7 @@ export function createMockWorkspaceRepository(): MockWorkspaceRepository { updatePinnedAt: () => {}, updateLastViewedAt: () => {}, updateLastActivityAt: () => {}, + updateLabel: () => {}, updateMode: () => {}, setModeAndRepository: (taskId, mode, repositoryId) => { const id = taskIndex.get(taskId); diff --git a/packages/workspace-server/src/db/repositories/workspace-repository.ts b/packages/workspace-server/src/db/repositories/workspace-repository.ts index fd9190b74a..2897ec9b4a 100644 --- a/packages/workspace-server/src/db/repositories/workspace-repository.ts +++ b/packages/workspace-server/src/db/repositories/workspace-repository.ts @@ -1,4 +1,9 @@ -import { mergePrUrls, promotePrUrl, type WorkspaceMode } from "@posthog/shared"; +import { + mergePrUrls, + promotePrUrl, + type TaskLabel, + type WorkspaceMode, +} from "@posthog/shared"; import { eq, isNotNull } from "drizzle-orm"; import { inject, injectable } from "inversify"; import { DATABASE_SERVICE } from "../identifiers"; @@ -36,6 +41,7 @@ export interface IWorkspaceRepository { updatePinnedAt(taskId: string, pinnedAt: string | null): void; updateLastViewedAt(taskId: string, lastViewedAt: string): void; updateLastActivityAt(taskId: string, lastActivityAt: string): void; + updateLabel(taskId: string, label: TaskLabel | null): void; updateLinkedBranch(taskId: string, linkedBranch: string | null): void; updateMode(taskId: string, mode: WorkspaceMode): void; setModeAndRepository( @@ -172,6 +178,14 @@ export class WorkspaceRepository implements IWorkspaceRepository { .run(); } + updateLabel(taskId: string, label: TaskLabel | null): void { + this.db + .update(workspaces) + .set({ label, updatedAt: now() }) + .where(byTaskId(taskId)) + .run(); + } + updateLinkedBranch(taskId: string, linkedBranch: string | null): void { this.db .update(workspaces) diff --git a/packages/workspace-server/src/db/schema.ts b/packages/workspace-server/src/db/schema.ts index 6168b2990c..ed2728af95 100644 --- a/packages/workspace-server/src/db/schema.ts +++ b/packages/workspace-server/src/db/schema.ts @@ -31,6 +31,10 @@ export const workspaces = sqliteTable( pinnedAt: text(), lastViewedAt: text(), lastActivityAt: text(), + /** User-set task label (see TASK_LABELS in @posthog/shared); null = unlabeled. */ + label: text({ + enum: ["high-priority", "active", "deprioritized", "done"], + }), /** JSON-encoded array of absolute paths the agent can access for this task. */ additionalDirectories: text().notNull().default("[]"), /** Cached PR URL for this task so task switches render without waiting on `gh`. */ @@ -54,6 +58,10 @@ export const taskMetadata = sqliteTable("task_metadata", { pinnedAt: text(), lastViewedAt: text(), lastActivityAt: text(), + /** User-set task label (see TASK_LABELS in @posthog/shared); null = unlabeled. */ + label: text({ + enum: ["high-priority", "active", "deprioritized", "done"], + }), // Archive state for rowless tasks. Tasks WITH a `workspaces` row record their // archived state in the `archives` table; rowless channel tasks have no such // row, so this timestamp is their only home — without it, archiving them is a diff --git a/packages/workspace-server/src/services/workspace-metadata/workspace-metadata.test.ts b/packages/workspace-server/src/services/workspace-metadata/workspace-metadata.test.ts index ecdebc84a7..5e1c78905d 100644 --- a/packages/workspace-server/src/services/workspace-metadata/workspace-metadata.test.ts +++ b/packages/workspace-server/src/services/workspace-metadata/workspace-metadata.test.ts @@ -15,6 +15,7 @@ function createService() { updatePinnedAt: vi.fn(), updateLastViewedAt: vi.fn(), updateLastActivityAt: vi.fn(), + updateLabel: vi.fn(), }; const metadataRepo: MockTaskMetadataRepository = createMockTaskMetadataRepository(); @@ -137,6 +138,38 @@ describe("WorkspaceMetadataService.markActivity", () => { }); }); +describe("WorkspaceMetadataService.setTaskLabel", () => { + it("writes the label to the workspace row when one exists", () => { + const { service, repo } = createService(); + repo.findByTaskId.mockReturnValue({ taskId: "t1", label: null }); + + expect(service.setTaskLabel("t1", "high-priority")).toEqual({ + label: "high-priority", + }); + expect(repo.updateLabel).toHaveBeenCalledWith("t1", "high-priority"); + }); + + it("clears the label with null", () => { + const { service, repo } = createService(); + repo.findByTaskId.mockReturnValue({ taskId: "t1", label: "done" }); + + expect(service.setTaskLabel("t1", null)).toEqual({ label: null }); + expect(repo.updateLabel).toHaveBeenCalledWith("t1", null); + }); + + it("stores the label in task_metadata for a rowless task", () => { + const { service, repo, metadataRepo } = createService(); + repo.findByTaskId.mockReturnValue(undefined); + + expect(service.setTaskLabel("t1", "active")).toEqual({ label: "active" }); + expect(repo.updateLabel).not.toHaveBeenCalled(); + expect(metadataRepo.findByTaskId("t1")?.label).toBe("active"); + + expect(service.setTaskLabel("t1", null)).toEqual({ label: null }); + expect(metadataRepo.findByTaskId("t1")?.label).toBeNull(); + }); +}); + describe("WorkspaceMetadataService projections", () => { it("unions pinned task ids from workspaces and task_metadata", () => { const { service, repo, metadataRepo } = createService(); @@ -153,24 +186,27 @@ describe("WorkspaceMetadataService projections", () => { pinnedAt: "2025-01-01T00:00:00.000Z", lastViewedAt: null, lastActivityAt: null, + label: "high-priority", }); expect(service.getTaskTimestamps("t1")).toEqual({ pinnedAt: "2025-01-01T00:00:00.000Z", lastViewedAt: null, lastActivityAt: null, + label: "high-priority", }); }); it("falls back to task_metadata for a rowless task", () => { const { service, repo, metadataRepo } = createService(); repo.findByTaskId.mockReturnValue(undefined); - metadataRepo.upsert("t1", { lastViewedAt: NOW_ISO }); + metadataRepo.upsert("t1", { lastViewedAt: NOW_ISO, label: "done" }); expect(service.getTaskTimestamps("t1")).toEqual({ pinnedAt: null, lastViewedAt: NOW_ISO, lastActivityAt: null, + label: "done", }); }); @@ -182,19 +218,36 @@ describe("WorkspaceMetadataService projections", () => { pinnedAt: null, lastViewedAt: null, lastActivityAt: null, + label: null, }); }); it("merges all timestamps, with workspace rows winning on overlap", () => { const { service, repo, metadataRepo } = createService(); repo.findAll.mockReturnValue([ - { taskId: "a", pinnedAt: "p", lastViewedAt: "v", lastActivityAt: "x" }, + { + taskId: "a", + pinnedAt: "p", + lastViewedAt: "v", + lastActivityAt: "x", + label: "active", + }, ]); - metadataRepo.upsert("b", { lastViewedAt: "bv" }); + metadataRepo.upsert("b", { lastViewedAt: "bv", label: "deprioritized" }); expect(service.getAllTaskTimestamps()).toEqual({ - a: { pinnedAt: "p", lastViewedAt: "v", lastActivityAt: "x" }, - b: { pinnedAt: null, lastViewedAt: "bv", lastActivityAt: null }, + a: { + pinnedAt: "p", + lastViewedAt: "v", + lastActivityAt: "x", + label: "active", + }, + b: { + pinnedAt: null, + lastViewedAt: "bv", + lastActivityAt: null, + label: "deprioritized", + }, }); }); }); diff --git a/packages/workspace-server/src/services/workspace-metadata/workspace-metadata.ts b/packages/workspace-server/src/services/workspace-metadata/workspace-metadata.ts index e7eb2b3f9d..c69ed098a9 100644 --- a/packages/workspace-server/src/services/workspace-metadata/workspace-metadata.ts +++ b/packages/workspace-server/src/services/workspace-metadata/workspace-metadata.ts @@ -1,3 +1,4 @@ +import type { TaskLabel } from "@posthog/shared"; import { inject, injectable } from "inversify"; import { TASK_METADATA_REPOSITORY, @@ -10,6 +11,7 @@ export interface TaskTimestamps { pinnedAt: string | null; lastViewedAt: string | null; lastActivityAt: string | null; + label: TaskLabel | null; } /** @@ -73,6 +75,19 @@ export class WorkspaceMetadataService { this.taskMetadataRepo.upsert(taskId, { lastActivityAt }); } + setTaskLabel( + taskId: string, + label: TaskLabel | null, + ): { label: TaskLabel | null } { + if (this.workspaceRepo.findByTaskId(taskId)) { + this.workspaceRepo.updateLabel(taskId, label); + return { label }; + } + // Rowless task: fall back to the task_metadata table. + this.taskMetadataRepo.upsert(taskId, { label }); + return { label }; + } + getPinnedTaskIds(): string[] { return [ ...this.workspaceRepo.findAllPinned().map((w) => w.taskId), @@ -88,6 +103,7 @@ export class WorkspaceMetadataService { pinnedAt: row?.pinnedAt ?? null, lastViewedAt: row?.lastViewedAt ?? null, lastActivityAt: row?.lastActivityAt ?? null, + label: row?.label ?? null, }; } @@ -100,6 +116,7 @@ export class WorkspaceMetadataService { pinnedAt: m.pinnedAt, lastViewedAt: m.lastViewedAt, lastActivityAt: m.lastActivityAt, + label: m.label, }; } for (const w of this.workspaceRepo.findAll()) { @@ -107,6 +124,7 @@ export class WorkspaceMetadataService { pinnedAt: w.pinnedAt, lastViewedAt: w.lastViewedAt, lastActivityAt: w.lastActivityAt, + label: w.label, }; } return result; diff --git a/packages/workspace-server/src/services/workspace/schemas.ts b/packages/workspace-server/src/services/workspace/schemas.ts index 0de533573e..163fd57616 100644 --- a/packages/workspace-server/src/services/workspace/schemas.ts +++ b/packages/workspace-server/src/services/workspace/schemas.ts @@ -1,4 +1,5 @@ import { + taskLabelSchema, workspaceInfoSchema, workspaceModeSchema, workspaceSchema, @@ -270,6 +271,15 @@ export const markActivityInput = z.object({ export const getPinnedTaskIdsOutput = z.array(z.string()); +export const setTaskLabelInput = z.object({ + taskId: z.string(), + label: taskLabelSchema.nullable(), +}); + +export const setTaskLabelOutput = z.object({ + label: taskLabelSchema.nullable(), +}); + export const getTaskTimestampsInput = z.object({ taskId: z.string(), }); @@ -278,6 +288,7 @@ export const getTaskTimestampsOutput = z.object({ pinnedAt: z.string().nullable(), lastViewedAt: z.string().nullable(), lastActivityAt: z.string().nullable(), + label: taskLabelSchema.nullable(), }); export const getAllTaskTimestampsOutput = z.record( @@ -286,6 +297,7 @@ export const getAllTaskTimestampsOutput = z.record( pinnedAt: z.string().nullable(), lastViewedAt: z.string().nullable(), lastActivityAt: z.string().nullable(), + label: taskLabelSchema.nullable(), }), );