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 4a38374c4..df4852d82 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,10 @@ function toElectronItem(item: ContextMenuItem): MenuItemConstructorOptions { enabled: action.enabled ?? true, accelerator: action.accelerator, }; + if (action.checked !== undefined) { + options.type = "checkbox"; + options.checked = action.checked; + } if (action.icon) { options.icon = resizeIcon(action.icon); } diff --git a/apps/code/src/main/services/context-menu/schemas.ts b/apps/code/src/main/services/context-menu/schemas.ts index 2db37c6e9..ba0275599 100644 --- a/apps/code/src/main/services/context-menu/schemas.ts +++ b/apps/code/src/main/services/context-menu/schemas.ts @@ -8,6 +8,7 @@ export const taskContextMenuInput = z.object({ isSuspended: z.boolean().optional(), isInCommandCenter: z.boolean().optional(), hasEmptyCommandCenterCell: z.boolean().optional(), + isRevisit: z.boolean().optional(), }); export const archivedTaskContextMenuInput = z.object({ @@ -42,6 +43,7 @@ const taskAction = z.discriminatedUnion("type", [ z.object({ type: z.literal("archive-prior") }), z.object({ type: z.literal("delete") }), z.object({ type: z.literal("add-to-command-center") }), + z.object({ type: z.literal("toggle-revisit") }), z.object({ type: z.literal("external-app"), action: externalAppAction }), ]); diff --git a/apps/code/src/main/services/context-menu/service.ts b/apps/code/src/main/services/context-menu/service.ts index 24d3dbc62..fbc243822 100644 --- a/apps/code/src/main/services/context-menu/service.ts +++ b/apps/code/src/main/services/context-menu/service.ts @@ -111,12 +111,18 @@ export class ContextMenuService { isSuspended, isInCommandCenter, hasEmptyCommandCenterCell, + isRevisit, } = input; const { apps, lastUsedAppId } = await this.getExternalAppsData(); const hasPath = worktreePath || folderPath; return this.showMenu([ this.item(isPinned ? "Unpin" : "Pin", { type: "pin" }), + this.item( + "Revisit", + { type: "toggle-revisit" }, + { checked: isRevisit ?? false }, + ), this.item("Rename", { type: "rename" }), ...(worktreePath ? [ @@ -336,6 +342,7 @@ export class ContextMenuService { enabled: def.enabled, accelerator: def.accelerator, icon: def.icon, + checked: def.checked, click, }; } diff --git a/apps/code/src/main/services/context-menu/types.ts b/apps/code/src/main/services/context-menu/types.ts index c76c93d9d..86b3fc99b 100644 --- a/apps/code/src/main/services/context-menu/types.ts +++ b/apps/code/src/main/services/context-menu/types.ts @@ -13,6 +13,8 @@ export interface ActionItemDef { enabled?: boolean; icon?: string; confirm?: ConfirmOptions; + /** Renders the item as a native checkbox menu entry that shows a check mark when true. */ + checked?: boolean; } export interface SubmenuItemDef { diff --git a/apps/code/src/renderer/constants/keyboard-shortcuts.ts b/apps/code/src/renderer/constants/keyboard-shortcuts.ts index a209ed436..650341d2c 100644 --- a/apps/code/src/renderer/constants/keyboard-shortcuts.ts +++ b/apps/code/src/renderer/constants/keyboard-shortcuts.ts @@ -24,6 +24,7 @@ export const SHORTCUTS = { FIND_IN_CONVERSATION: "mod+f", BLUR: "escape", SUBMIT_BLUR: "mod+enter", + TOGGLE_REVISIT: "mod+shift+m", } as const; export type ShortcutCategory = "general" | "navigation" | "panels" | "editor"; @@ -160,6 +161,13 @@ export const KEYBOARD_SHORTCUTS: KeyboardShortcut[] = [ category: "panels", context: "Task detail", }, + { + id: "toggle-revisit", + keys: SHORTCUTS.TOGGLE_REVISIT, + description: "Toggle revisit task", + category: "panels", + context: "Task detail", + }, { id: "paste-as-file", keys: SHORTCUTS.PASTE_AS_FILE, diff --git a/apps/code/src/renderer/features/sessions/components/SessionView.tsx b/apps/code/src/renderer/features/sessions/components/SessionView.tsx index c542a1fcc..54e09cc75 100644 --- a/apps/code/src/renderer/features/sessions/components/SessionView.tsx +++ b/apps/code/src/renderer/features/sessions/components/SessionView.tsx @@ -16,18 +16,23 @@ import { } from "@features/sessions/stores/sessionStore"; import type { Plan } from "@features/sessions/types"; import { useSettingsStore } from "@features/settings/stores/settingsStore"; +import { useRevisitStore } from "@features/task-detail/stores/revisitStore"; import { useIsWorkspaceCloudRun } from "@features/workspace/hooks/useWorkspace"; import { useAutoFocusOnTyping } from "@hooks/useAutoFocusOnTyping"; import { Pause, Spinner, Warning } from "@phosphor-icons/react"; import { Box, Button, ContextMenu, Flex, Text } from "@radix-ui/themes"; +import { SHORTCUTS } from "@renderer/constants/keyboard-shortcuts"; import { toast } from "@renderer/utils/toast"; import type { Task, TaskRunStatus } from "@shared/types"; +import { ANALYTICS_EVENTS } from "@shared/types/analytics"; import { type AcpMessage, isJsonRpcNotification, isJsonRpcResponse, } from "@shared/types/session-events"; +import { track } from "@utils/analytics"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useHotkeys } from "react-hotkeys-hook"; import { getSessionService } from "../service/service"; import { flattenSelectOptions } from "../stores/sessionStore"; import { @@ -95,6 +100,25 @@ function resolveAllowAlwaysUpgradeMode( return undefined; } +function useRevisitShortcut(taskId: string | undefined) { + useHotkeys( + SHORTCUTS.TOGGLE_REVISIT, + (e) => { + if (!taskId) return; + e.preventDefault(); + const store = useRevisitStore.getState(); + const wasMarked = store.revisitTaskIds.has(taskId); + store.toggle(taskId); + track(ANALYTICS_EVENTS.TASK_REVISIT_TOGGLED, { + task_id: taskId, + enabled: !wasMarked, + }); + }, + { enableOnFormTags: true, enableOnContentEditable: true }, + [taskId], + ); +} + export function SessionView({ events, taskId, @@ -124,6 +148,7 @@ export function SessionView({ isActiveSession = true, hideInput = false, }: SessionViewProps) { + useRevisitShortcut(taskId); const showRawLogs = useShowRawLogs(); const { setShowRawLogs } = useSessionViewActions(); const pendingPermissions = usePendingPermissionsForTask(taskId); diff --git a/apps/code/src/renderer/features/sidebar/components/SidebarMenu.tsx b/apps/code/src/renderer/features/sidebar/components/SidebarMenu.tsx index 05fb194af..06079de44 100644 --- a/apps/code/src/renderer/features/sidebar/components/SidebarMenu.tsx +++ b/apps/code/src/renderer/features/sidebar/components/SidebarMenu.tsx @@ -9,6 +9,7 @@ import { import { useOnboardingStore } from "@features/onboarding/stores/onboardingStore"; import { getSessionService } from "@features/sessions/service/service"; import { useSetupStore } from "@features/setup/stores/setupStore"; +import { useRevisitStore } from "@features/task-detail/stores/revisitStore"; import { archiveTaskImperative, useArchiveTask, @@ -19,9 +20,11 @@ import { useTaskContextMenu } from "@hooks/useTaskContextMenu"; import { ScrollArea, Separator } from "@posthog/quill"; import { Box, Flex } from "@radix-ui/themes"; import type { Task } from "@shared/types"; +import { ANALYTICS_EVENTS } from "@shared/types/analytics"; import { useNavigationStore } from "@stores/navigationStore"; import { useRendererWindowFocusStore } from "@stores/rendererWindowFocusStore"; import { useQueryClient } from "@tanstack/react-query"; +import { track } from "@utils/analytics"; import { logger } from "@utils/logger"; import { toast } from "@utils/toast"; import { memo, useCallback, useEffect, useRef } from "react"; @@ -62,6 +65,7 @@ function SidebarMenuComponent() { useTaskContextMenu(); const { archiveTask } = useArchiveTask(); const { togglePin } = usePinnedTasks(); + const toggleRevisit = useRevisitStore((s) => s.toggle); const hasCompletedSetup = useOnboardingStore( (state) => state.hasCompletedSetup, @@ -172,6 +176,8 @@ function SidebarMenuComponent() { (id) => id == null || !taskMap.has(id), ); + const isRevisit = useRevisitStore.getState().revisitTaskIds.has(taskId); + showContextMenu(task, e, { worktreePath: workspace?.worktreePath ?? undefined, folderPath: workspace?.folderPath ?? undefined, @@ -179,8 +185,19 @@ function SidebarMenuComponent() { isSuspended: taskData?.isSuspended, isInCommandCenter, hasEmptyCommandCenterCell, + isRevisit, onTogglePin: () => togglePin(taskId), onArchivePrior: handleArchivePrior, + onToggleRevisit: () => { + const wasMarked = useRevisitStore + .getState() + .revisitTaskIds.has(taskId); + toggleRevisit(taskId); + track(ANALYTICS_EVENTS.TASK_REVISIT_TOGGLED, { + task_id: taskId, + enabled: !wasMarked, + }); + }, onAddToCommandCenter: () => { const cells = useCommandCenterStore.getState().cells; const idx = cells.findIndex((id) => id == null || !taskMap.has(id)); diff --git a/apps/code/src/renderer/features/sidebar/components/TaskListView.tsx b/apps/code/src/renderer/features/sidebar/components/TaskListView.tsx index 07960d98f..d89d8b015 100644 --- a/apps/code/src/renderer/features/sidebar/components/TaskListView.tsx +++ b/apps/code/src/renderer/features/sidebar/components/TaskListView.tsx @@ -2,6 +2,7 @@ import { PointerSensor } from "@dnd-kit/dom"; import type { DragDropEvents } from "@dnd-kit/react"; import { DragDropProvider } from "@dnd-kit/react"; import { useFolders } from "@features/folders/hooks/useFolders"; +import { useRevisitStore } from "@features/task-detail/stores/revisitStore"; import { useMeQuery } from "@hooks/useMeQuery"; import { FunnelSimple as FunnelSimpleIcon, @@ -20,8 +21,10 @@ import { import { Flex, Text } from "@radix-ui/themes"; import builderHog from "@renderer/assets/images/hedgehogs/builder-hog-03.png"; import { useWorkspace } from "@renderer/features/workspace/hooks/useWorkspace"; +import { ANALYTICS_EVENTS } from "@shared/types/analytics"; import { normalizeRepoKey } from "@shared/utils/repo"; import { useNavigationStore } from "@stores/navigationStore"; +import { track } from "@utils/analytics"; import { getRelativeDateGroup } from "@utils/time"; import { motion } from "framer-motion"; import { Fragment, useCallback, useEffect, useMemo } from "react"; @@ -102,6 +105,7 @@ function TaskRow({ workspace?.mode ?? (task.taskRunEnvironment === "cloud" ? "cloud" : undefined); const { prState, hasDiff } = useTaskPrStatus(task); + const isRevisit = useRevisitStore((s) => s.revisitTaskIds.has(task.id)); return ( state.sortMode); const showAllUsers = useSidebarStore((state) => state.showAllUsers); const showInternal = useSidebarStore((state) => state.showInternal); + const showRevisitOnly = useSidebarStore((state) => state.showRevisitOnly); const setOrganizeMode = useSidebarStore((state) => state.setOrganizeMode); const setSortMode = useSidebarStore((state) => state.setSortMode); const setShowAllUsers = useSidebarStore((state) => state.setShowAllUsers); const setShowInternal = useSidebarStore((state) => state.setShowInternal); + const setShowRevisitOnly = useSidebarStore( + (state) => state.setShowRevisitOnly, + ); const { data: currentUser } = useMeQuery(); const isStaff = currentUser?.is_staff === true; @@ -185,6 +194,29 @@ function TaskFilterMenu() { Updated + + + Filter + { + const next = value === "revisit"; + const previous = showRevisitOnly ? "revisit" : "all"; + if (previous === value) return; + setShowRevisitOnly(next); + track(ANALYTICS_EVENTS.TASK_REVISIT_LIST_FILTER_CHANGED, { + filter_name: "revisit_only", + value, + previous_value: previous, + }); + }} + > + All tasks + + Revisit only + + + {import.meta.env.DEV && ( <> diff --git a/apps/code/src/renderer/features/sidebar/components/items/TaskItem.tsx b/apps/code/src/renderer/features/sidebar/components/items/TaskItem.tsx index eb604baeb..ad802e018 100644 --- a/apps/code/src/renderer/features/sidebar/components/items/TaskItem.tsx +++ b/apps/code/src/renderer/features/sidebar/components/items/TaskItem.tsx @@ -29,6 +29,7 @@ interface TaskItemProps { isGenerating?: boolean; isUnread?: boolean; isPinned?: boolean; + isRevisit?: boolean; isSuspended?: boolean; needsPermission?: boolean; taskRunStatus?: TaskRunStatus; @@ -233,6 +234,7 @@ export function TaskItem({ isGenerating, isUnread, isPinned = false, + isRevisit = false, needsPermission = false, taskRunStatus, prState, @@ -276,6 +278,12 @@ export function TaskItem({ ) : isPinned ? ( + ) : isRevisit ? ( + + + + + ) : ( ); diff --git a/apps/code/src/renderer/features/sidebar/hooks/useSidebarData.ts b/apps/code/src/renderer/features/sidebar/hooks/useSidebarData.ts index c0f166b02..f6d0fa318 100644 --- a/apps/code/src/renderer/features/sidebar/hooks/useSidebarData.ts +++ b/apps/code/src/renderer/features/sidebar/hooks/useSidebarData.ts @@ -2,6 +2,7 @@ import { useArchivedTaskIds } from "@features/archive/hooks/useArchivedTaskIds"; import { useProvisioningStore } from "@features/provisioning/stores/provisioningStore"; import { useSessions } from "@features/sessions/stores/sessionStore"; import { useSuspendedTaskIds } from "@features/suspension/hooks/useSuspendedTaskIds"; +import { useRevisitStore } from "@features/task-detail/stores/revisitStore"; import { useTaskSummaries, useTasks } from "@features/tasks/hooks/useTasks"; import { useWorkspaces } from "@features/workspace/hooks/useWorkspace"; import type { Schemas } from "@renderer/api/generated"; @@ -9,6 +10,7 @@ import type { Task, TaskRunStatus } from "@shared/types"; import { useEffect, useMemo, useRef } from "react"; import { useSidebarStore } from "../stores/sidebarStore"; import type { SortMode } from "../types"; +import { applyRevisitFilter } from "../utils/applyRevisitFilter"; import { type TaskGroup as GenericTaskGroup, getRepositoryInfo, @@ -91,6 +93,8 @@ export function useSidebarData({ }: UseSidebarDataProps): SidebarData { const showAllUsers = useSidebarStore((state) => state.showAllUsers); const showInternal = useSidebarStore((state) => state.showInternal); + const showRevisitOnly = useSidebarStore((state) => state.showRevisitOnly); + const revisitTaskIds = useRevisitStore((state) => state.revisitTaskIds); const { data: workspaces, isFetched: isWorkspacesFetched } = useWorkspaces(); const archivedTaskIds = useArchivedTaskIds(); const suspendedTaskIds = useSuspendedTaskIds(); @@ -256,14 +260,19 @@ export function useSidebarData({ workspaces, ]); + const filteredTaskData = useMemo( + () => applyRevisitFilter(taskData, showRevisitOnly, revisitTaskIds), + [taskData, showRevisitOnly, revisitTaskIds], + ); + const pinnedTasks = useMemo(() => { - const pinned = taskData.filter((task) => task.isPinned); + const pinned = filteredTaskData.filter((task) => task.isPinned); return sortTasks(pinned, sortMode); - }, [taskData, sortMode]); + }, [filteredTaskData, sortMode]); const unpinnedTasks = useMemo( - () => taskData.filter((task) => !task.isPinned), - [taskData], + () => filteredTaskData.filter((task) => !task.isPinned), + [filteredTaskData], ); const sortedUnpinnedTasks = useMemo( diff --git a/apps/code/src/renderer/features/sidebar/stores/sidebarStore.ts b/apps/code/src/renderer/features/sidebar/stores/sidebarStore.ts index b87d80c2f..97fed84a8 100644 --- a/apps/code/src/renderer/features/sidebar/stores/sidebarStore.ts +++ b/apps/code/src/renderer/features/sidebar/stores/sidebarStore.ts @@ -14,6 +14,7 @@ interface SidebarStoreState { sortMode: "updated" | "created"; showAllUsers: boolean; showInternal: boolean; + showRevisitOnly: boolean; } interface SidebarStoreActions { @@ -32,6 +33,7 @@ interface SidebarStoreActions { setSortMode: (mode: SidebarStoreState["sortMode"]) => void; setShowAllUsers: (showAllUsers: boolean) => void; setShowInternal: (showInternal: boolean) => void; + setShowRevisitOnly: (showRevisitOnly: boolean) => void; } type SidebarStore = SidebarStoreState & SidebarStoreActions; @@ -50,6 +52,7 @@ export const useSidebarStore = create()( sortMode: "updated", showAllUsers: false, showInternal: false, + showRevisitOnly: false, setOpen: (open) => set({ open, hasUserSetOpen: true }), setOpenAuto: (open) => set((state) => (state.hasUserSetOpen ? state : { open })), @@ -100,6 +103,7 @@ export const useSidebarStore = create()( setSortMode: (sortMode) => set({ sortMode }), setShowAllUsers: (showAllUsers) => set({ showAllUsers }), setShowInternal: (showInternal) => set({ showInternal }), + setShowRevisitOnly: (showRevisitOnly) => set({ showRevisitOnly }), }), { name: "sidebar-storage", @@ -114,6 +118,7 @@ export const useSidebarStore = create()( sortMode: state.sortMode, showAllUsers: state.showAllUsers, showInternal: state.showInternal, + showRevisitOnly: state.showRevisitOnly, }), merge: (persisted, current) => { const persistedState = persisted as { @@ -127,6 +132,7 @@ export const useSidebarStore = create()( sortMode?: SidebarStoreState["sortMode"]; showAllUsers?: boolean; showInternal?: boolean; + showRevisitOnly?: boolean; }; return { ...current, @@ -145,6 +151,8 @@ export const useSidebarStore = create()( sortMode: persistedState.sortMode ?? current.sortMode, showAllUsers: persistedState.showAllUsers ?? current.showAllUsers, showInternal: persistedState.showInternal ?? current.showInternal, + showRevisitOnly: + persistedState.showRevisitOnly ?? current.showRevisitOnly, }; }, }, diff --git a/apps/code/src/renderer/features/sidebar/utils/applyRevisitFilter.test.ts b/apps/code/src/renderer/features/sidebar/utils/applyRevisitFilter.test.ts new file mode 100644 index 000000000..b6b02a3b4 --- /dev/null +++ b/apps/code/src/renderer/features/sidebar/utils/applyRevisitFilter.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, it } from "vitest"; +import type { TaskData } from "../hooks/useSidebarData"; +import { applyRevisitFilter } from "./applyRevisitFilter"; + +function makeTask(overrides: Partial = {}): TaskData { + return { + id: "task-1", + title: "Test task", + createdAt: 1, + lastActivityAt: 1, + isGenerating: false, + isUnread: false, + isPinned: false, + needsPermission: false, + repository: null, + isSuspended: false, + folderPath: null, + cloudPrUrl: null, + branchName: null, + linkedBranch: null, + ...overrides, + }; +} + +describe("applyRevisitFilter", () => { + it.each<{ + name: string; + tasks: TaskData[]; + showRevisitOnly: boolean; + revisitIds: string[]; + expectedIds: string[]; + /** When true, the filter is expected to short-circuit and return the input by reference. */ + expectSameRef?: boolean; + }>([ + { + name: "returns input unchanged when showRevisitOnly is false", + tasks: [makeTask({ id: "a" }), makeTask({ id: "b" })], + showRevisitOnly: false, + revisitIds: ["a"], + expectedIds: ["a", "b"], + expectSameRef: true, + }, + { + name: "filters to only tasks marked for revisit when showRevisitOnly is true", + tasks: [ + makeTask({ id: "a" }), + makeTask({ id: "b" }), + makeTask({ id: "c" }), + ], + showRevisitOnly: true, + revisitIds: ["a", "c"], + expectedIds: ["a", "c"], + }, + { + name: "returns empty array when showRevisitOnly is true and no tasks are marked", + tasks: [makeTask({ id: "a" }), makeTask({ id: "b" })], + showRevisitOnly: true, + revisitIds: [], + expectedIds: [], + }, + { + name: "preserves pinned tasks that are also marked for revisit", + tasks: [ + makeTask({ id: "a", isPinned: true }), + makeTask({ id: "b", isPinned: true }), + makeTask({ id: "c", isPinned: false }), + ], + showRevisitOnly: true, + revisitIds: ["a", "c"], + expectedIds: ["a", "c"], + }, + { + name: "empty input with filter on returns empty", + tasks: [], + showRevisitOnly: true, + revisitIds: ["a"], + expectedIds: [], + }, + { + name: "empty input with filter off returns empty", + tasks: [], + showRevisitOnly: false, + revisitIds: ["a"], + expectedIds: [], + expectSameRef: true, + }, + ])( + "$name", + ({ tasks, showRevisitOnly, revisitIds, expectedIds, expectSameRef }) => { + const snapshot = [...tasks]; + const result = applyRevisitFilter( + tasks, + showRevisitOnly, + new Set(revisitIds), + ); + expect(result.map((t) => t.id)).toEqual(expectedIds); + if (expectSameRef) expect(result).toBe(tasks); + // Filter must never mutate the input array. + expect(tasks).toEqual(snapshot); + }, + ); +}); diff --git a/apps/code/src/renderer/features/sidebar/utils/applyRevisitFilter.ts b/apps/code/src/renderer/features/sidebar/utils/applyRevisitFilter.ts new file mode 100644 index 000000000..6e9646e54 --- /dev/null +++ b/apps/code/src/renderer/features/sidebar/utils/applyRevisitFilter.ts @@ -0,0 +1,10 @@ +import type { TaskData } from "../hooks/useSidebarData"; + +export function applyRevisitFilter( + tasks: TaskData[], + showRevisitOnly: boolean, + revisitTaskIds: Set, +): TaskData[] { + if (!showRevisitOnly) return tasks; + return tasks.filter((task) => revisitTaskIds.has(task.id)); +} diff --git a/apps/code/src/renderer/features/task-detail/stores/revisitStore.test.ts b/apps/code/src/renderer/features/task-detail/stores/revisitStore.test.ts new file mode 100644 index 000000000..679862c5c --- /dev/null +++ b/apps/code/src/renderer/features/task-detail/stores/revisitStore.test.ts @@ -0,0 +1,138 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { useRevisitStore } from "./revisitStore"; + +describe("revisitStore", () => { + beforeEach(() => { + localStorage.clear(); + useRevisitStore.setState({ revisitTaskIds: new Set() }); + }); + + it("starts with an empty set", () => { + expect(useRevisitStore.getState().revisitTaskIds.size).toBe(0); + expect(useRevisitStore.getState().isRevisit("task-1")).toBe(false); + }); + + describe("setRevisit", () => { + it.each<{ + name: string; + ops: Array<["setRevisit", string, boolean]>; + expected: { id: string; revisit: boolean }[]; + expectedSize: number; + }>([ + { + name: "setRevisit(true) marks a task", + ops: [["setRevisit", "task-1", true]], + expected: [{ id: "task-1", revisit: true }], + expectedSize: 1, + }, + { + name: "setRevisit(false) on unmarked task is a no-op", + ops: [["setRevisit", "task-1", false]], + expected: [{ id: "task-1", revisit: false }], + expectedSize: 0, + }, + { + name: "setRevisit(false) removes a previously marked task", + ops: [ + ["setRevisit", "task-1", true], + ["setRevisit", "task-1", false], + ], + expected: [{ id: "task-1", revisit: false }], + expectedSize: 0, + }, + { + name: "setRevisit(true) is idempotent", + ops: [ + ["setRevisit", "task-1", true], + ["setRevisit", "task-1", true], + ], + expected: [{ id: "task-1", revisit: true }], + expectedSize: 1, + }, + { + name: "tracks multiple tasks independently", + ops: [ + ["setRevisit", "task-1", true], + ["setRevisit", "task-2", true], + ["setRevisit", "task-1", false], + ], + expected: [ + { id: "task-1", revisit: false }, + { id: "task-2", revisit: true }, + ], + expectedSize: 1, + }, + ])("$name", ({ ops, expected, expectedSize }) => { + for (const [, taskId, on] of ops) { + useRevisitStore.getState().setRevisit(taskId, on); + } + const state = useRevisitStore.getState(); + expect(state.revisitTaskIds.size).toBe(expectedSize); + for (const { id, revisit } of expected) { + expect(state.isRevisit(id)).toBe(revisit); + } + }); + }); + + it("toggle flips state on and off", () => { + useRevisitStore.getState().toggle("task-1"); + expect(useRevisitStore.getState().isRevisit("task-1")).toBe(true); + useRevisitStore.getState().toggle("task-1"); + expect(useRevisitStore.getState().isRevisit("task-1")).toBe(false); + }); + + it("persists marked tasks to localStorage as an array", () => { + useRevisitStore.getState().setRevisit("task-1", true); + useRevisitStore.getState().setRevisit("task-2", true); + const raw = localStorage.getItem("revisit-tasks-storage"); + expect(raw).toBeTruthy(); + const persisted = JSON.parse(raw as string); + expect(persisted.state.revisitTaskIds).toEqual( + expect.arrayContaining(["task-1", "task-2"]), + ); + expect(persisted.state.revisitTaskIds).toHaveLength(2); + }); + + describe("rehydrate", () => { + it.each<{ + name: string; + seed: string[] | null; + expectedSize: number; + checks: Array<[string, boolean]>; + }>([ + { + name: "from persisted ids restores a Set", + seed: ["task-1", "task-2"], + expectedSize: 2, + checks: [ + ["task-1", true], + ["task-2", true], + ["task-3", false], + ], + }, + { + name: "with no persisted state yields an empty Set", + seed: null, + expectedSize: 0, + checks: [["task-1", false]], + }, + ])("$name", async ({ seed, expectedSize, checks }) => { + if (seed) { + localStorage.setItem( + "revisit-tasks-storage", + JSON.stringify({ + state: { revisitTaskIds: seed }, + version: 0, + }), + ); + } + await useRevisitStore.persist.rehydrate(); + const state = useRevisitStore.getState(); + expect(state.revisitTaskIds).toBeInstanceOf(Set); + expect(state.revisitTaskIds.size).toBe(expectedSize); + for (const [id, revisit] of checks) { + expect(state.isRevisit(id)).toBe(revisit); + } + }); + }); +}); diff --git a/apps/code/src/renderer/features/task-detail/stores/revisitStore.ts b/apps/code/src/renderer/features/task-detail/stores/revisitStore.ts new file mode 100644 index 000000000..919cba544 --- /dev/null +++ b/apps/code/src/renderer/features/task-detail/stores/revisitStore.ts @@ -0,0 +1,47 @@ +import { create } from "zustand"; +import { persist } from "zustand/middleware"; + +interface RevisitStoreState { + revisitTaskIds: Set; +} + +interface RevisitStoreActions { + isRevisit: (taskId: string) => boolean; + setRevisit: (taskId: string, on: boolean) => void; + toggle: (taskId: string) => void; +} + +type RevisitStore = RevisitStoreState & RevisitStoreActions; + +export const useRevisitStore = create()( + persist( + (set, get) => ({ + revisitTaskIds: new Set(), + isRevisit: (taskId) => get().revisitTaskIds.has(taskId), + setRevisit: (taskId, on) => + set((state) => { + const next = new Set(state.revisitTaskIds); + if (on) { + next.add(taskId); + } else { + next.delete(taskId); + } + return { revisitTaskIds: next }; + }), + toggle: (taskId) => get().setRevisit(taskId, !get().revisitTaskIds.has(taskId)), + }), + { + name: "revisit-tasks-storage", + partialize: (state) => ({ + revisitTaskIds: Array.from(state.revisitTaskIds), + }), + merge: (persisted, current) => { + const persistedState = persisted as { revisitTaskIds?: string[] }; + return { + ...current, + revisitTaskIds: new Set(persistedState.revisitTaskIds ?? []), + }; + }, + }, + ), +); diff --git a/apps/code/src/renderer/features/tasks/hooks/useArchiveTask.ts b/apps/code/src/renderer/features/tasks/hooks/useArchiveTask.ts index 6552a87b2..805309640 100644 --- a/apps/code/src/renderer/features/tasks/hooks/useArchiveTask.ts +++ b/apps/code/src/renderer/features/tasks/hooks/useArchiveTask.ts @@ -1,6 +1,7 @@ import { useCommandCenterStore } from "@features/command-center/stores/commandCenterStore"; import { getSessionService } from "@features/sessions/service/service"; import { pinnedTasksApi } from "@features/sidebar/hooks/usePinnedTasks"; +import { useRevisitStore } from "@features/task-detail/stores/revisitStore"; import { useTerminalStore } from "@features/terminal/stores/terminalStore"; import { workspaceApi } from "@features/workspace/hooks/useWorkspace"; import { trpc, trpcClient } from "@renderer/trpc"; @@ -37,6 +38,7 @@ export async function archiveTaskImperative( pinnedTasksApi.unpin(taskId); useTerminalStore.getState().clearTerminalStatesForTask(taskId); useCommandCenterStore.getState().removeTaskById(taskId); + useRevisitStore.getState().setRevisit(taskId, false); queryClient.setQueryData( trpc.archive.archivedTaskIds.queryKey(), diff --git a/apps/code/src/renderer/features/tasks/hooks/useTasks.ts b/apps/code/src/renderer/features/tasks/hooks/useTasks.ts index dfd203e3c..e6b21520d 100644 --- a/apps/code/src/renderer/features/tasks/hooks/useTasks.ts +++ b/apps/code/src/renderer/features/tasks/hooks/useTasks.ts @@ -1,4 +1,5 @@ import { pinnedTasksApi } from "@features/sidebar/hooks/usePinnedTasks"; +import { useRevisitStore } from "@features/task-detail/stores/revisitStore"; import { workspaceApi } from "@features/workspace/hooks/useWorkspace"; import { useAuthenticatedMutation } from "@hooks/useAuthenticatedMutation"; import { useAuthenticatedQuery } from "@hooks/useAuthenticatedQuery"; @@ -249,6 +250,7 @@ export function useDeleteTask() { } pinnedTasksApi.unpin(taskId); + useRevisitStore.getState().setRevisit(taskId, false); await mutation.mutateAsync(taskId); diff --git a/apps/code/src/renderer/hooks/useTaskContextMenu.ts b/apps/code/src/renderer/hooks/useTaskContextMenu.ts index c57a2725d..6f731ee61 100644 --- a/apps/code/src/renderer/hooks/useTaskContextMenu.ts +++ b/apps/code/src/renderer/hooks/useTaskContextMenu.ts @@ -29,9 +29,11 @@ export function useTaskContextMenu() { isSuspended?: boolean; isInCommandCenter?: boolean; hasEmptyCommandCenterCell?: boolean; + isRevisit?: boolean; onTogglePin?: () => void; onArchivePrior?: (taskId: string) => void; onAddToCommandCenter?: () => void; + onToggleRevisit?: () => void; }, ) => { event.preventDefault(); @@ -44,9 +46,11 @@ export function useTaskContextMenu() { isSuspended, isInCommandCenter, hasEmptyCommandCenterCell, + isRevisit, onTogglePin, onArchivePrior, onAddToCommandCenter, + onToggleRevisit, } = options ?? {}; try { @@ -58,6 +62,7 @@ export function useTaskContextMenu() { isSuspended, isInCommandCenter, hasEmptyCommandCenterCell, + isRevisit, }); if (!result.action) return; @@ -92,6 +97,9 @@ export function useTaskContextMenu() { case "add-to-command-center": onAddToCommandCenter?.(); break; + case "toggle-revisit": + onToggleRevisit?.(); + break; case "external-app": { const effectivePath = worktreePath ?? folderPath; if (effectivePath) { diff --git a/apps/code/src/shared/types/analytics.ts b/apps/code/src/shared/types/analytics.ts index 8679f18ad..f1e1cce4d 100644 --- a/apps/code/src/shared/types/analytics.ts +++ b/apps/code/src/shared/types/analytics.ts @@ -331,6 +331,18 @@ export interface SetupSkippedProperties { entry_point: "during_scan" | "after_done"; } +// Task revisit + sidebar filter events +export interface TaskRevisitToggledProperties { + task_id: string; + enabled: boolean; +} + +export interface TaskRevisitListFilterChangedProperties { + filter_name: string; + value: string; + previous_value?: string; +} + // Subscription / billing events export interface SubscriptionStartedProperties { plan_key: string; @@ -433,6 +445,10 @@ export const ANALYTICS_EVENTS = { // Subscription events SUBSCRIPTION_STARTED: "Subscription started", SUBSCRIPTION_CANCELLED: "Subscription cancelled", + + // Task revisit + sidebar filter events + TASK_REVISIT_TOGGLED: "Task revisit toggled", + TASK_REVISIT_LIST_FILTER_CHANGED: "Task revisit list filter changed", } as const; // Event property mapping @@ -520,4 +536,8 @@ export type EventPropertyMap = { // Subscription events [ANALYTICS_EVENTS.SUBSCRIPTION_STARTED]: SubscriptionStartedProperties; [ANALYTICS_EVENTS.SUBSCRIPTION_CANCELLED]: SubscriptionCancelledProperties; + + // Task revisit + sidebar filter events + [ANALYTICS_EVENTS.TASK_REVISIT_TOGGLED]: TaskRevisitToggledProperties; + [ANALYTICS_EVENTS.TASK_REVISIT_LIST_FILTER_CHANGED]: TaskRevisitListFilterChangedProperties; }; diff --git a/packages/platform/src/context-menu.ts b/packages/platform/src/context-menu.ts index 1bb9f3909..deb1f93e0 100644 --- a/packages/platform/src/context-menu.ts +++ b/packages/platform/src/context-menu.ts @@ -4,6 +4,8 @@ export interface ContextMenuAction { enabled?: boolean; accelerator?: string; submenu?: ContextMenuItem[]; + /** When defined, renders as a native checkbox item with a tick when `checked` is true. */ + checked?: boolean; click: () => void | Promise; }