From 56c0bcad59d9931f0f1159aaf26aaedf045f148a Mon Sep 17 00:00:00 2001 From: Richard Solomou Date: Tue, 19 May 2026 03:24:54 +0000 Subject: [PATCH 1/5] feat(command-center): autofill empty cells with recent active tasks When the user opens the Command Center and no tasks are attached, populate empty cells with their tasks updated in the past 2 hours, ordered by most recent activity. Generated-By: PostHog Code Task-Id: 427c51ec-695b-4203-9ac2-bb79ea575422 --- .../components/CommandCenterView.tsx | 3 + .../hooks/useAutofillCommandCenter.ts | 61 +++++++++++++++ .../stores/commandCenterStore.test.ts | 74 +++++++++++++++++++ .../stores/commandCenterStore.ts | 13 ++++ 4 files changed, 151 insertions(+) create mode 100644 apps/code/src/renderer/features/command-center/hooks/useAutofillCommandCenter.ts create mode 100644 apps/code/src/renderer/features/command-center/stores/commandCenterStore.test.ts diff --git a/apps/code/src/renderer/features/command-center/components/CommandCenterView.tsx b/apps/code/src/renderer/features/command-center/components/CommandCenterView.tsx index 2c1853149..0e844a523 100644 --- a/apps/code/src/renderer/features/command-center/components/CommandCenterView.tsx +++ b/apps/code/src/renderer/features/command-center/components/CommandCenterView.tsx @@ -3,6 +3,7 @@ import { useSetHeaderContent } from "@hooks/useSetHeaderContent"; import { Lightning } from "@phosphor-icons/react"; import { Box, Flex, Text } from "@radix-ui/themes"; import { useEffect, useMemo } from "react"; +import { useAutofillCommandCenter } from "../hooks/useAutofillCommandCenter"; import { useCommandCenterData } from "../hooks/useCommandCenterData"; import { useCommandCenterStore } from "../stores/commandCenterStore"; import { CommandCenterGrid } from "./CommandCenterGrid"; @@ -13,6 +14,8 @@ export function CommandCenterView() { const { cells, summary } = useCommandCenterData(); const { markAsViewed } = useTaskViewed(); + useAutofillCommandCenter(); + const visibleTaskIdsKey = cells .map((c) => c.taskId) .filter(Boolean) diff --git a/apps/code/src/renderer/features/command-center/hooks/useAutofillCommandCenter.ts b/apps/code/src/renderer/features/command-center/hooks/useAutofillCommandCenter.ts new file mode 100644 index 000000000..5d1d92ea8 --- /dev/null +++ b/apps/code/src/renderer/features/command-center/hooks/useAutofillCommandCenter.ts @@ -0,0 +1,61 @@ +import { useArchivedTaskIds } from "@features/archive/hooks/useArchivedTaskIds"; +import { useTasks } from "@features/tasks/hooks/useTasks"; +import { useWorkspaces } from "@features/workspace/hooks/useWorkspace"; +import type { Task } from "@shared/types"; +import { useEffect, useRef } from "react"; +import { useCommandCenterStore } from "../stores/commandCenterStore"; + +const RECENT_WINDOW_MS = 2 * 60 * 60 * 1000; + +function getLastActivity(task: Task): number { + const taskTime = new Date(task.updated_at).getTime(); + const runTime = task.latest_run?.updated_at + ? new Date(task.latest_run.updated_at).getTime() + : 0; + return Math.max(taskTime, runTime); +} + +export function useAutofillCommandCenter(): void { + const { data: tasks = [] } = useTasks(); + const { data: workspaces, isFetched: workspacesFetched } = useWorkspaces(); + const archivedTaskIds = useArchivedTaskIds(); + + const cells = useCommandCenterStore((s) => s.cells); + const autofillCells = useCommandCenterStore((s) => s.autofillCells); + + const hasRunRef = useRef(false); + + useEffect(() => { + if (hasRunRef.current) return; + if (!workspacesFetched || !workspaces) return; + + if (!cells.every((id) => id == null)) { + hasRunRef.current = true; + return; + } + + const cutoff = Date.now() - RECENT_WINDOW_MS; + const candidates = tasks + .filter( + (task) => + !archivedTaskIds.has(task.id) && + !!workspaces[task.id] && + getLastActivity(task) >= cutoff, + ) + .sort((a, b) => getLastActivity(b) - getLastActivity(a)) + .slice(0, cells.length) + .map((task) => task.id); + + if (candidates.length > 0) { + autofillCells(candidates); + } + hasRunRef.current = true; + }, [ + cells, + workspaces, + workspacesFetched, + tasks, + archivedTaskIds, + autofillCells, + ]); +} diff --git a/apps/code/src/renderer/features/command-center/stores/commandCenterStore.test.ts b/apps/code/src/renderer/features/command-center/stores/commandCenterStore.test.ts new file mode 100644 index 000000000..02f6592b4 --- /dev/null +++ b/apps/code/src/renderer/features/command-center/stores/commandCenterStore.test.ts @@ -0,0 +1,74 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@utils/electronStorage", () => ({ + electronStorage: { + getItem: () => null, + setItem: () => {}, + removeItem: () => {}, + }, +})); + +import { useCommandCenterStore } from "./commandCenterStore"; + +describe("commandCenterStore", () => { + beforeEach(() => { + useCommandCenterStore.setState({ + layout: "2x2", + cells: [null, null, null, null], + activeTaskId: null, + activeCellIndex: null, + zoom: 1, + creatingCells: [], + }); + }); + + describe("autofillCells", () => { + it("fills empty cells from index 0", () => { + useCommandCenterStore.getState().autofillCells(["t1", "t2"]); + expect(useCommandCenterStore.getState().cells).toEqual([ + "t1", + "t2", + null, + null, + ]); + }); + + it("does nothing when any cell is already populated", () => { + useCommandCenterStore.setState({ cells: [null, "existing", null, null] }); + useCommandCenterStore.getState().autofillCells(["t1", "t2"]); + expect(useCommandCenterStore.getState().cells).toEqual([ + null, + "existing", + null, + null, + ]); + }); + + it("ignores empty task list", () => { + useCommandCenterStore.getState().autofillCells([]); + expect(useCommandCenterStore.getState().cells).toEqual([ + null, + null, + null, + null, + ]); + }); + + it("caps fill at the number of cells", () => { + useCommandCenterStore + .getState() + .autofillCells(["t1", "t2", "t3", "t4", "t5", "t6"]); + expect(useCommandCenterStore.getState().cells).toEqual([ + "t1", + "t2", + "t3", + "t4", + ]); + }); + + it("does not set activeTaskId", () => { + useCommandCenterStore.getState().autofillCells(["t1"]); + expect(useCommandCenterStore.getState().activeTaskId).toBeNull(); + }); + }); +}); diff --git a/apps/code/src/renderer/features/command-center/stores/commandCenterStore.ts b/apps/code/src/renderer/features/command-center/stores/commandCenterStore.ts index 77c36fc61..890cc3d6a 100644 --- a/apps/code/src/renderer/features/command-center/stores/commandCenterStore.ts +++ b/apps/code/src/renderer/features/command-center/stores/commandCenterStore.ts @@ -33,6 +33,7 @@ interface CommandCenterStoreActions { setActiveTask: (taskId: string | null) => void; setActiveCell: (cellIndex: number | null) => void; assignTask: (cellIndex: number, taskId: string) => void; + autofillCells: (taskIds: string[]) => void; removeTask: (cellIndex: number) => void; removeTaskById: (taskId: string) => void; clearAll: () => void; @@ -115,6 +116,18 @@ export const useCommandCenterStore = create()( }; }), + autofillCells: (taskIds) => + set((state) => { + if (!state.cells.every((id) => id == null)) return state; + if (taskIds.length === 0) return state; + const cells: (string | null)[] = [...state.cells]; + const limit = Math.min(cells.length, taskIds.length); + for (let i = 0; i < limit; i++) { + cells[i] = taskIds[i]; + } + return { cells }; + }), + removeTask: (cellIndex) => set((state) => { const cells = [...state.cells]; From eef7334a5fbc05b2ab3b1ec727654d8091a4dc34 Mon Sep 17 00:00:00 2001 From: Richard Solomou Date: Tue, 19 May 2026 03:44:57 +0000 Subject: [PATCH 2/5] fix(command-center): guard autofill on tasks fetch and parameterize tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address Greptile review on PR #2212: - The hook only waited on workspaces. useTasks() defaults data to [], so if workspaces were cached and resolved first the effect ran with an empty tasks array, found zero candidates, set hasRunRef true and bailed — the later tasks response could never trigger the autofill. Add a tasksFetched guard so both sources must be loaded. - Collapse the autofillCells test trio into an it.each table per repo convention. Generated-By: PostHog Code Task-Id: 427c51ec-695b-4203-9ac2-bb79ea575422 --- .../hooks/useAutofillCommandCenter.ts | 4 +- .../stores/commandCenterStore.test.ts | 55 +++++++------------ 2 files changed, 23 insertions(+), 36 deletions(-) diff --git a/apps/code/src/renderer/features/command-center/hooks/useAutofillCommandCenter.ts b/apps/code/src/renderer/features/command-center/hooks/useAutofillCommandCenter.ts index 5d1d92ea8..18538f5db 100644 --- a/apps/code/src/renderer/features/command-center/hooks/useAutofillCommandCenter.ts +++ b/apps/code/src/renderer/features/command-center/hooks/useAutofillCommandCenter.ts @@ -16,7 +16,7 @@ function getLastActivity(task: Task): number { } export function useAutofillCommandCenter(): void { - const { data: tasks = [] } = useTasks(); + const { data: tasks = [], isFetched: tasksFetched } = useTasks(); const { data: workspaces, isFetched: workspacesFetched } = useWorkspaces(); const archivedTaskIds = useArchivedTaskIds(); @@ -28,6 +28,7 @@ export function useAutofillCommandCenter(): void { useEffect(() => { if (hasRunRef.current) return; if (!workspacesFetched || !workspaces) return; + if (!tasksFetched) return; if (!cells.every((id) => id == null)) { hasRunRef.current = true; @@ -55,6 +56,7 @@ export function useAutofillCommandCenter(): void { workspaces, workspacesFetched, tasks, + tasksFetched, archivedTaskIds, autofillCells, ]); diff --git a/apps/code/src/renderer/features/command-center/stores/commandCenterStore.test.ts b/apps/code/src/renderer/features/command-center/stores/commandCenterStore.test.ts index 02f6592b4..c9fa694eb 100644 --- a/apps/code/src/renderer/features/command-center/stores/commandCenterStore.test.ts +++ b/apps/code/src/renderer/features/command-center/stores/commandCenterStore.test.ts @@ -23,14 +23,26 @@ describe("commandCenterStore", () => { }); describe("autofillCells", () => { - it("fills empty cells from index 0", () => { - useCommandCenterStore.getState().autofillCells(["t1", "t2"]); - expect(useCommandCenterStore.getState().cells).toEqual([ - "t1", - "t2", - null, - null, - ]); + it.each([ + { + name: "fills empty cells from index 0", + input: ["t1", "t2"], + expectedCells: ["t1", "t2", null, null], + }, + { + name: "ignores empty task list", + input: [], + expectedCells: [null, null, null, null], + }, + { + name: "caps fill at the number of cells", + input: ["t1", "t2", "t3", "t4", "t5", "t6"], + expectedCells: ["t1", "t2", "t3", "t4"], + }, + ])("$name and leaves activeTaskId null", ({ input, expectedCells }) => { + useCommandCenterStore.getState().autofillCells(input); + expect(useCommandCenterStore.getState().cells).toEqual(expectedCells); + expect(useCommandCenterStore.getState().activeTaskId).toBeNull(); }); it("does nothing when any cell is already populated", () => { @@ -43,32 +55,5 @@ describe("commandCenterStore", () => { null, ]); }); - - it("ignores empty task list", () => { - useCommandCenterStore.getState().autofillCells([]); - expect(useCommandCenterStore.getState().cells).toEqual([ - null, - null, - null, - null, - ]); - }); - - it("caps fill at the number of cells", () => { - useCommandCenterStore - .getState() - .autofillCells(["t1", "t2", "t3", "t4", "t5", "t6"]); - expect(useCommandCenterStore.getState().cells).toEqual([ - "t1", - "t2", - "t3", - "t4", - ]); - }); - - it("does not set activeTaskId", () => { - useCommandCenterStore.getState().autofillCells(["t1"]); - expect(useCommandCenterStore.getState().activeTaskId).toBeNull(); - }); }); }); From 97bd9315820e63e7cd9c7b66ed5430583b0b1d58 Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Wed, 20 May 2026 19:52:56 -0700 Subject: [PATCH 3/5] persist autofill flag and add hook tests --- .../hooks/useAutofillCommandCenter.test.ts | 311 ++++++++++++++++++ .../hooks/useAutofillCommandCenter.ts | 19 +- .../stores/commandCenterStore.test.ts | 63 ++-- .../stores/commandCenterStore.ts | 24 +- 4 files changed, 384 insertions(+), 33 deletions(-) create mode 100644 apps/code/src/renderer/features/command-center/hooks/useAutofillCommandCenter.test.ts diff --git a/apps/code/src/renderer/features/command-center/hooks/useAutofillCommandCenter.test.ts b/apps/code/src/renderer/features/command-center/hooks/useAutofillCommandCenter.test.ts new file mode 100644 index 000000000..23a79ab43 --- /dev/null +++ b/apps/code/src/renderer/features/command-center/hooks/useAutofillCommandCenter.test.ts @@ -0,0 +1,311 @@ +import type { Workspace } from "@main/services/workspace/schemas"; +import type { Task } from "@shared/types"; +import { renderHook } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@utils/electronStorage", () => ({ + electronStorage: { + getItem: () => null, + setItem: () => {}, + removeItem: () => {}, + }, +})); + +const mockUseTasks = vi.hoisted(() => vi.fn()); +const mockUseWorkspaces = vi.hoisted(() => vi.fn()); +const mockUseArchivedTaskIds = vi.hoisted(() => vi.fn()); + +vi.mock("@features/tasks/hooks/useTasks", () => ({ + useTasks: mockUseTasks, +})); + +vi.mock("@features/workspace/hooks/useWorkspace", () => ({ + useWorkspaces: mockUseWorkspaces, +})); + +vi.mock("@features/archive/hooks/useArchivedTaskIds", () => ({ + useArchivedTaskIds: mockUseArchivedTaskIds, +})); + +import { + COMMAND_CENTER_INITIAL_STATE, + useCommandCenterStore, +} from "../stores/commandCenterStore"; +import { useAutofillCommandCenter } from "./useAutofillCommandCenter"; + +const NOW = new Date("2026-02-27T12:00:00Z").getTime(); +const ONE_HOUR_MS = 60 * 60 * 1000; + +function makeTask(overrides: Partial = {}): Task { + return { + id: "task-1", + task_number: 1, + slug: "task-1", + title: "Task 1", + description: "", + created_at: new Date(NOW).toISOString(), + updated_at: new Date(NOW).toISOString(), + origin_product: "code", + ...overrides, + }; +} + +function makeWorkspace(taskId: string): Workspace { + return { + taskId, + folderId: "folder-1", + folderPath: "/repo", + mode: "worktree", + worktreePath: `/repo/${taskId}`, + worktreeName: taskId, + branchName: `feat/${taskId}`, + baseBranch: "main", + linkedBranch: null, + createdAt: new Date(NOW).toISOString(), + }; +} + +function setQueries(opts: { + tasks?: Task[]; + workspaces?: Record; + archived?: string[]; + tasksFetched?: boolean; + workspacesFetched?: boolean; +}) { + mockUseTasks.mockReturnValue({ + data: opts.tasks ?? [], + isFetched: opts.tasksFetched ?? true, + }); + mockUseWorkspaces.mockReturnValue({ + data: opts.workspaces, + isFetched: opts.workspacesFetched ?? true, + }); + mockUseArchivedTaskIds.mockReturnValue(new Set(opts.archived ?? [])); +} + +describe("useAutofillCommandCenter", () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(NOW); + useCommandCenterStore.setState(COMMAND_CENTER_INITIAL_STATE); + mockUseTasks.mockReset(); + mockUseWorkspaces.mockReset(); + mockUseArchivedTaskIds.mockReset(); + }); + + it("does nothing when tasks are not fetched", () => { + setQueries({ tasksFetched: false, workspaces: {} }); + renderHook(() => useAutofillCommandCenter()); + expect(useCommandCenterStore.getState().cells).toEqual([ + null, + null, + null, + null, + ]); + expect(useCommandCenterStore.getState().hasAutofilled).toBe(false); + }); + + it("does nothing when workspaces are not fetched", () => { + setQueries({ workspacesFetched: false }); + renderHook(() => useAutofillCommandCenter()); + expect(useCommandCenterStore.getState().cells).toEqual([ + null, + null, + null, + null, + ]); + expect(useCommandCenterStore.getState().hasAutofilled).toBe(false); + }); + + it("marks autofilled without changing cells when cells are already populated", () => { + useCommandCenterStore.setState({ cells: ["existing", null, null, null] }); + setQueries({ + tasks: [makeTask({ id: "t1" })], + workspaces: { t1: makeWorkspace("t1") }, + }); + renderHook(() => useAutofillCommandCenter()); + expect(useCommandCenterStore.getState().cells).toEqual([ + "existing", + null, + null, + null, + ]); + expect(useCommandCenterStore.getState().hasAutofilled).toBe(true); + }); + + it("fills empty cells with recent tasks that have workspaces", () => { + setQueries({ + tasks: [ + makeTask({ id: "t1", updated_at: new Date(NOW - 100).toISOString() }), + makeTask({ id: "t2", updated_at: new Date(NOW - 200).toISOString() }), + ], + workspaces: { t1: makeWorkspace("t1"), t2: makeWorkspace("t2") }, + }); + renderHook(() => useAutofillCommandCenter()); + expect(useCommandCenterStore.getState().cells).toEqual([ + "t1", + "t2", + null, + null, + ]); + expect(useCommandCenterStore.getState().hasAutofilled).toBe(true); + }); + + it("skips archived tasks", () => { + setQueries({ + tasks: [makeTask({ id: "t1" }), makeTask({ id: "t2" })], + workspaces: { t1: makeWorkspace("t1"), t2: makeWorkspace("t2") }, + archived: ["t1"], + }); + renderHook(() => useAutofillCommandCenter()); + expect(useCommandCenterStore.getState().cells).toEqual([ + "t2", + null, + null, + null, + ]); + }); + + it("skips tasks without a workspace", () => { + setQueries({ + tasks: [makeTask({ id: "t1" }), makeTask({ id: "t2" })], + workspaces: { t2: makeWorkspace("t2") }, + }); + renderHook(() => useAutofillCommandCenter()); + expect(useCommandCenterStore.getState().cells).toEqual([ + "t2", + null, + null, + null, + ]); + }); + + it("skips tasks older than the 2 hour window", () => { + setQueries({ + tasks: [ + makeTask({ + id: "fresh", + updated_at: new Date(NOW - 100).toISOString(), + }), + makeTask({ + id: "stale", + updated_at: new Date(NOW - 3 * ONE_HOUR_MS).toISOString(), + }), + ], + workspaces: { + fresh: makeWorkspace("fresh"), + stale: makeWorkspace("stale"), + }, + }); + renderHook(() => useAutofillCommandCenter()); + expect(useCommandCenterStore.getState().cells).toEqual([ + "fresh", + null, + null, + null, + ]); + }); + + it("uses latest_run.updated_at when it is newer than task.updated_at", () => { + setQueries({ + tasks: [ + makeTask({ + id: "stale", + updated_at: new Date(NOW - 3 * ONE_HOUR_MS).toISOString(), + latest_run: { + id: "run-1", + task: "stale", + team: 1, + branch: null, + status: "in_progress", + log_url: "", + error_message: null, + output: null, + state: {}, + created_at: new Date(NOW - 3 * ONE_HOUR_MS).toISOString(), + updated_at: new Date(NOW - 100).toISOString(), + completed_at: null, + }, + }), + ], + workspaces: { stale: makeWorkspace("stale") }, + }); + renderHook(() => useAutofillCommandCenter()); + expect(useCommandCenterStore.getState().cells).toEqual([ + "stale", + null, + null, + null, + ]); + }); + + it("sorts candidates by most recent activity descending", () => { + setQueries({ + tasks: [ + makeTask({ id: "old", updated_at: new Date(NOW - 1000).toISOString() }), + makeTask({ id: "new", updated_at: new Date(NOW - 100).toISOString() }), + makeTask({ id: "mid", updated_at: new Date(NOW - 500).toISOString() }), + ], + workspaces: { + old: makeWorkspace("old"), + new: makeWorkspace("new"), + mid: makeWorkspace("mid"), + }, + }); + renderHook(() => useAutofillCommandCenter()); + expect(useCommandCenterStore.getState().cells).toEqual([ + "new", + "mid", + "old", + null, + ]); + }); + + it("caps candidates at cells.length", () => { + setQueries({ + tasks: Array.from({ length: 10 }, (_, i) => + makeTask({ + id: `t${i}`, + updated_at: new Date(NOW - i).toISOString(), + }), + ), + workspaces: Object.fromEntries( + Array.from({ length: 10 }, (_, i) => [`t${i}`, makeWorkspace(`t${i}`)]), + ), + }); + renderHook(() => useAutofillCommandCenter()); + expect(useCommandCenterStore.getState().cells).toEqual([ + "t0", + "t1", + "t2", + "t3", + ]); + }); + + it("leaves hasAutofilled false when no candidates are available", () => { + setQueries({ tasks: [], workspaces: {} }); + renderHook(() => useAutofillCommandCenter()); + expect(useCommandCenterStore.getState().cells).toEqual([ + null, + null, + null, + null, + ]); + expect(useCommandCenterStore.getState().hasAutofilled).toBe(false); + }); + + it("does nothing when hasAutofilled is already true", () => { + useCommandCenterStore.setState({ hasAutofilled: true }); + setQueries({ + tasks: [makeTask({ id: "t1" })], + workspaces: { t1: makeWorkspace("t1") }, + }); + renderHook(() => useAutofillCommandCenter()); + expect(useCommandCenterStore.getState().cells).toEqual([ + null, + null, + null, + null, + ]); + }); +}); diff --git a/apps/code/src/renderer/features/command-center/hooks/useAutofillCommandCenter.ts b/apps/code/src/renderer/features/command-center/hooks/useAutofillCommandCenter.ts index 18538f5db..8ff1358cf 100644 --- a/apps/code/src/renderer/features/command-center/hooks/useAutofillCommandCenter.ts +++ b/apps/code/src/renderer/features/command-center/hooks/useAutofillCommandCenter.ts @@ -2,9 +2,11 @@ import { useArchivedTaskIds } from "@features/archive/hooks/useArchivedTaskIds"; import { useTasks } from "@features/tasks/hooks/useTasks"; import { useWorkspaces } from "@features/workspace/hooks/useWorkspace"; import type { Task } from "@shared/types"; -import { useEffect, useRef } from "react"; +import { useEffect } from "react"; import { useCommandCenterStore } from "../stores/commandCenterStore"; +// Window for "still in the current working session". Tasks last touched +// within this window are eligible to autofill empty cells on first view. const RECENT_WINDOW_MS = 2 * 60 * 60 * 1000; function getLastActivity(task: Task): number { @@ -21,17 +23,19 @@ export function useAutofillCommandCenter(): void { const archivedTaskIds = useArchivedTaskIds(); const cells = useCommandCenterStore((s) => s.cells); + const hasAutofilled = useCommandCenterStore((s) => s.hasAutofilled); const autofillCells = useCommandCenterStore((s) => s.autofillCells); - - const hasRunRef = useRef(false); + const markAutofilled = useCommandCenterStore((s) => s.markAutofilled); useEffect(() => { - if (hasRunRef.current) return; + if (hasAutofilled) return; if (!workspacesFetched || !workspaces) return; if (!tasksFetched) return; + // User already has cells assigned (manual or persisted from a prior session). + // Treat them as in control and don't autofill in the future. if (!cells.every((id) => id == null)) { - hasRunRef.current = true; + markAutofilled(); return; } @@ -47,11 +51,13 @@ export function useAutofillCommandCenter(): void { .slice(0, cells.length) .map((task) => task.id); + // Leave the flag false when there are no candidates so a future + // mount can pick up tasks that become recent later. if (candidates.length > 0) { autofillCells(candidates); } - hasRunRef.current = true; }, [ + hasAutofilled, cells, workspaces, workspacesFetched, @@ -59,5 +65,6 @@ export function useAutofillCommandCenter(): void { tasksFetched, archivedTaskIds, autofillCells, + markAutofilled, ]); } diff --git a/apps/code/src/renderer/features/command-center/stores/commandCenterStore.test.ts b/apps/code/src/renderer/features/command-center/stores/commandCenterStore.test.ts index c9fa694eb..014c34950 100644 --- a/apps/code/src/renderer/features/command-center/stores/commandCenterStore.test.ts +++ b/apps/code/src/renderer/features/command-center/stores/commandCenterStore.test.ts @@ -8,19 +8,17 @@ vi.mock("@utils/electronStorage", () => ({ }, })); -import { useCommandCenterStore } from "./commandCenterStore"; +import { + COMMAND_CENTER_INITIAL_STATE, + useCommandCenterStore, +} from "./commandCenterStore"; + +function resetStore() { + useCommandCenterStore.setState(COMMAND_CENTER_INITIAL_STATE); +} describe("commandCenterStore", () => { - beforeEach(() => { - useCommandCenterStore.setState({ - layout: "2x2", - cells: [null, null, null, null], - activeTaskId: null, - activeCellIndex: null, - zoom: 1, - creatingCells: [], - }); - }); + beforeEach(resetStore); describe("autofillCells", () => { it.each([ @@ -29,20 +27,30 @@ describe("commandCenterStore", () => { input: ["t1", "t2"], expectedCells: ["t1", "t2", null, null], }, - { - name: "ignores empty task list", - input: [], - expectedCells: [null, null, null, null], - }, { name: "caps fill at the number of cells", input: ["t1", "t2", "t3", "t4", "t5", "t6"], expectedCells: ["t1", "t2", "t3", "t4"], }, - ])("$name and leaves activeTaskId null", ({ input, expectedCells }) => { - useCommandCenterStore.getState().autofillCells(input); - expect(useCommandCenterStore.getState().cells).toEqual(expectedCells); - expect(useCommandCenterStore.getState().activeTaskId).toBeNull(); + ])( + "$name, marks autofilled, leaves activeTaskId null", + ({ input, expectedCells }) => { + useCommandCenterStore.getState().autofillCells(input); + expect(useCommandCenterStore.getState().cells).toEqual(expectedCells); + expect(useCommandCenterStore.getState().activeTaskId).toBeNull(); + expect(useCommandCenterStore.getState().hasAutofilled).toBe(true); + }, + ); + + it("ignores empty task list and leaves hasAutofilled false so we can retry later", () => { + useCommandCenterStore.getState().autofillCells([]); + expect(useCommandCenterStore.getState().cells).toEqual([ + null, + null, + null, + null, + ]); + expect(useCommandCenterStore.getState().hasAutofilled).toBe(false); }); it("does nothing when any cell is already populated", () => { @@ -54,6 +62,21 @@ describe("commandCenterStore", () => { null, null, ]); + expect(useCommandCenterStore.getState().hasAutofilled).toBe(false); + }); + }); + + describe("markAutofilled", () => { + it("sets hasAutofilled to true without touching cells", () => { + useCommandCenterStore.setState({ cells: ["x", null, null, null] }); + useCommandCenterStore.getState().markAutofilled(); + expect(useCommandCenterStore.getState().hasAutofilled).toBe(true); + expect(useCommandCenterStore.getState().cells).toEqual([ + "x", + null, + null, + null, + ]); }); }); }); diff --git a/apps/code/src/renderer/features/command-center/stores/commandCenterStore.ts b/apps/code/src/renderer/features/command-center/stores/commandCenterStore.ts index 890cc3d6a..49e95aa73 100644 --- a/apps/code/src/renderer/features/command-center/stores/commandCenterStore.ts +++ b/apps/code/src/renderer/features/command-center/stores/commandCenterStore.ts @@ -26,6 +26,7 @@ interface CommandCenterStoreState { activeCellIndex: number | null; zoom: number; creatingCells: number[]; + hasAutofilled: boolean; } interface CommandCenterStoreActions { @@ -34,6 +35,7 @@ interface CommandCenterStoreActions { setActiveCell: (cellIndex: number | null) => void; assignTask: (cellIndex: number, taskId: string) => void; autofillCells: (taskIds: string[]) => void; + markAutofilled: () => void; removeTask: (cellIndex: number) => void; removeTaskById: (taskId: string) => void; clearAll: () => void; @@ -44,6 +46,16 @@ interface CommandCenterStoreActions { stopCreating: (cellIndex: number) => void; } +export const COMMAND_CENTER_INITIAL_STATE: CommandCenterStoreState = { + layout: "2x2", + cells: [null, null, null, null], + activeTaskId: null, + activeCellIndex: null, + zoom: 1, + creatingCells: [], + hasAutofilled: false, +}; + type CommandCenterStore = CommandCenterStoreState & CommandCenterStoreActions; function resizeCells( @@ -70,12 +82,7 @@ export function getCellSessionId(cellIndex: number): string { export const useCommandCenterStore = create()( persist( (set) => ({ - layout: "2x2", - cells: [null, null, null, null], - activeTaskId: null, - activeCellIndex: null, - zoom: 1, - creatingCells: [], + ...COMMAND_CENTER_INITIAL_STATE, setLayout: (preset) => set((state) => { @@ -125,9 +132,11 @@ export const useCommandCenterStore = create()( for (let i = 0; i < limit; i++) { cells[i] = taskIds[i]; } - return { cells }; + return { cells, hasAutofilled: true }; }), + markAutofilled: () => set({ hasAutofilled: true }), + removeTask: (cellIndex) => set((state) => { const cells = [...state.cells]; @@ -191,6 +200,7 @@ export const useCommandCenterStore = create()( activeCellIndex: state.activeCellIndex, zoom: state.zoom, creatingCells: state.creatingCells, + hasAutofilled: state.hasAutofilled, }), }, ), From 12f6e8185bbc0283db6f0fc03444728554a89205 Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Wed, 20 May 2026 19:58:12 -0700 Subject: [PATCH 4/5] autofill on each mount instead of once per session --- .../hooks/useAutofillCommandCenter.test.ts | 24 +---------- .../hooks/useAutofillCommandCenter.ts | 23 +++++----- .../stores/commandCenterStore.test.ts | 43 ++++--------------- .../stores/commandCenterStore.ts | 8 +--- 4 files changed, 23 insertions(+), 75 deletions(-) diff --git a/apps/code/src/renderer/features/command-center/hooks/useAutofillCommandCenter.test.ts b/apps/code/src/renderer/features/command-center/hooks/useAutofillCommandCenter.test.ts index 23a79ab43..0ca0f42d8 100644 --- a/apps/code/src/renderer/features/command-center/hooks/useAutofillCommandCenter.test.ts +++ b/apps/code/src/renderer/features/command-center/hooks/useAutofillCommandCenter.test.ts @@ -102,7 +102,6 @@ describe("useAutofillCommandCenter", () => { null, null, ]); - expect(useCommandCenterStore.getState().hasAutofilled).toBe(false); }); it("does nothing when workspaces are not fetched", () => { @@ -114,10 +113,9 @@ describe("useAutofillCommandCenter", () => { null, null, ]); - expect(useCommandCenterStore.getState().hasAutofilled).toBe(false); }); - it("marks autofilled without changing cells when cells are already populated", () => { + it("does not change cells when they are already populated", () => { useCommandCenterStore.setState({ cells: ["existing", null, null, null] }); setQueries({ tasks: [makeTask({ id: "t1" })], @@ -130,7 +128,6 @@ describe("useAutofillCommandCenter", () => { null, null, ]); - expect(useCommandCenterStore.getState().hasAutofilled).toBe(true); }); it("fills empty cells with recent tasks that have workspaces", () => { @@ -148,7 +145,6 @@ describe("useAutofillCommandCenter", () => { null, null, ]); - expect(useCommandCenterStore.getState().hasAutofilled).toBe(true); }); it("skips archived tasks", () => { @@ -282,7 +278,7 @@ describe("useAutofillCommandCenter", () => { ]); }); - it("leaves hasAutofilled false when no candidates are available", () => { + it("does not change cells when no candidates are available", () => { setQueries({ tasks: [], workspaces: {} }); renderHook(() => useAutofillCommandCenter()); expect(useCommandCenterStore.getState().cells).toEqual([ @@ -291,21 +287,5 @@ describe("useAutofillCommandCenter", () => { null, null, ]); - expect(useCommandCenterStore.getState().hasAutofilled).toBe(false); - }); - - it("does nothing when hasAutofilled is already true", () => { - useCommandCenterStore.setState({ hasAutofilled: true }); - setQueries({ - tasks: [makeTask({ id: "t1" })], - workspaces: { t1: makeWorkspace("t1") }, - }); - renderHook(() => useAutofillCommandCenter()); - expect(useCommandCenterStore.getState().cells).toEqual([ - null, - null, - null, - null, - ]); }); }); diff --git a/apps/code/src/renderer/features/command-center/hooks/useAutofillCommandCenter.ts b/apps/code/src/renderer/features/command-center/hooks/useAutofillCommandCenter.ts index 8ff1358cf..709e5d119 100644 --- a/apps/code/src/renderer/features/command-center/hooks/useAutofillCommandCenter.ts +++ b/apps/code/src/renderer/features/command-center/hooks/useAutofillCommandCenter.ts @@ -2,11 +2,12 @@ import { useArchivedTaskIds } from "@features/archive/hooks/useArchivedTaskIds"; import { useTasks } from "@features/tasks/hooks/useTasks"; import { useWorkspaces } from "@features/workspace/hooks/useWorkspace"; import type { Task } from "@shared/types"; -import { useEffect } from "react"; +import { useEffect, useRef } from "react"; import { useCommandCenterStore } from "../stores/commandCenterStore"; // Window for "still in the current working session". Tasks last touched -// within this window are eligible to autofill empty cells on first view. +// within this window are eligible to autofill empty cells when the +// Command Center mounts. const RECENT_WINDOW_MS = 2 * 60 * 60 * 1000; function getLastActivity(task: Task): number { @@ -23,19 +24,20 @@ export function useAutofillCommandCenter(): void { const archivedTaskIds = useArchivedTaskIds(); const cells = useCommandCenterStore((s) => s.cells); - const hasAutofilled = useCommandCenterStore((s) => s.hasAutofilled); const autofillCells = useCommandCenterStore((s) => s.autofillCells); - const markAutofilled = useCommandCenterStore((s) => s.markAutofilled); + + // Fires at most once per mount so clearing cells in-place doesn't + // immediately re-populate them. Navigating away and back remounts the + // view and lets autofill run again with the latest recent tasks. + const hasRunRef = useRef(false); useEffect(() => { - if (hasAutofilled) return; + if (hasRunRef.current) return; if (!workspacesFetched || !workspaces) return; if (!tasksFetched) return; - // User already has cells assigned (manual or persisted from a prior session). - // Treat them as in control and don't autofill in the future. if (!cells.every((id) => id == null)) { - markAutofilled(); + hasRunRef.current = true; return; } @@ -51,13 +53,11 @@ export function useAutofillCommandCenter(): void { .slice(0, cells.length) .map((task) => task.id); - // Leave the flag false when there are no candidates so a future - // mount can pick up tasks that become recent later. if (candidates.length > 0) { autofillCells(candidates); } + hasRunRef.current = true; }, [ - hasAutofilled, cells, workspaces, workspacesFetched, @@ -65,6 +65,5 @@ export function useAutofillCommandCenter(): void { tasksFetched, archivedTaskIds, autofillCells, - markAutofilled, ]); } diff --git a/apps/code/src/renderer/features/command-center/stores/commandCenterStore.test.ts b/apps/code/src/renderer/features/command-center/stores/commandCenterStore.test.ts index 014c34950..4f15eb607 100644 --- a/apps/code/src/renderer/features/command-center/stores/commandCenterStore.test.ts +++ b/apps/code/src/renderer/features/command-center/stores/commandCenterStore.test.ts @@ -27,30 +27,20 @@ describe("commandCenterStore", () => { input: ["t1", "t2"], expectedCells: ["t1", "t2", null, null], }, + { + name: "ignores empty task list", + input: [], + expectedCells: [null, null, null, null], + }, { name: "caps fill at the number of cells", input: ["t1", "t2", "t3", "t4", "t5", "t6"], expectedCells: ["t1", "t2", "t3", "t4"], }, - ])( - "$name, marks autofilled, leaves activeTaskId null", - ({ input, expectedCells }) => { - useCommandCenterStore.getState().autofillCells(input); - expect(useCommandCenterStore.getState().cells).toEqual(expectedCells); - expect(useCommandCenterStore.getState().activeTaskId).toBeNull(); - expect(useCommandCenterStore.getState().hasAutofilled).toBe(true); - }, - ); - - it("ignores empty task list and leaves hasAutofilled false so we can retry later", () => { - useCommandCenterStore.getState().autofillCells([]); - expect(useCommandCenterStore.getState().cells).toEqual([ - null, - null, - null, - null, - ]); - expect(useCommandCenterStore.getState().hasAutofilled).toBe(false); + ])("$name and leaves activeTaskId null", ({ input, expectedCells }) => { + useCommandCenterStore.getState().autofillCells(input); + expect(useCommandCenterStore.getState().cells).toEqual(expectedCells); + expect(useCommandCenterStore.getState().activeTaskId).toBeNull(); }); it("does nothing when any cell is already populated", () => { @@ -62,21 +52,6 @@ describe("commandCenterStore", () => { null, null, ]); - expect(useCommandCenterStore.getState().hasAutofilled).toBe(false); - }); - }); - - describe("markAutofilled", () => { - it("sets hasAutofilled to true without touching cells", () => { - useCommandCenterStore.setState({ cells: ["x", null, null, null] }); - useCommandCenterStore.getState().markAutofilled(); - expect(useCommandCenterStore.getState().hasAutofilled).toBe(true); - expect(useCommandCenterStore.getState().cells).toEqual([ - "x", - null, - null, - null, - ]); }); }); }); diff --git a/apps/code/src/renderer/features/command-center/stores/commandCenterStore.ts b/apps/code/src/renderer/features/command-center/stores/commandCenterStore.ts index 49e95aa73..8af0c076b 100644 --- a/apps/code/src/renderer/features/command-center/stores/commandCenterStore.ts +++ b/apps/code/src/renderer/features/command-center/stores/commandCenterStore.ts @@ -26,7 +26,6 @@ interface CommandCenterStoreState { activeCellIndex: number | null; zoom: number; creatingCells: number[]; - hasAutofilled: boolean; } interface CommandCenterStoreActions { @@ -35,7 +34,6 @@ interface CommandCenterStoreActions { setActiveCell: (cellIndex: number | null) => void; assignTask: (cellIndex: number, taskId: string) => void; autofillCells: (taskIds: string[]) => void; - markAutofilled: () => void; removeTask: (cellIndex: number) => void; removeTaskById: (taskId: string) => void; clearAll: () => void; @@ -53,7 +51,6 @@ export const COMMAND_CENTER_INITIAL_STATE: CommandCenterStoreState = { activeCellIndex: null, zoom: 1, creatingCells: [], - hasAutofilled: false, }; type CommandCenterStore = CommandCenterStoreState & CommandCenterStoreActions; @@ -132,11 +129,9 @@ export const useCommandCenterStore = create()( for (let i = 0; i < limit; i++) { cells[i] = taskIds[i]; } - return { cells, hasAutofilled: true }; + return { cells }; }), - markAutofilled: () => set({ hasAutofilled: true }), - removeTask: (cellIndex) => set((state) => { const cells = [...state.cells]; @@ -200,7 +195,6 @@ export const useCommandCenterStore = create()( activeCellIndex: state.activeCellIndex, zoom: state.zoom, creatingCells: state.creatingCells, - hasAutofilled: state.hasAutofilled, }), }, ), From 0d7a63f782f77681a587a35da14a7730f7b7a97b Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Wed, 20 May 2026 20:13:48 -0700 Subject: [PATCH 5/5] top up empty cells instead of requiring all empty --- .../hooks/useAutofillCommandCenter.test.ts | 40 ++++++++++++++++++- .../hooks/useAutofillCommandCenter.ts | 7 +++- .../stores/commandCenterStore.test.ts | 28 +++++++++++-- .../stores/commandCenterStore.ts | 10 +++-- 4 files changed, 74 insertions(+), 11 deletions(-) diff --git a/apps/code/src/renderer/features/command-center/hooks/useAutofillCommandCenter.test.ts b/apps/code/src/renderer/features/command-center/hooks/useAutofillCommandCenter.test.ts index 0ca0f42d8..d399bf098 100644 --- a/apps/code/src/renderer/features/command-center/hooks/useAutofillCommandCenter.test.ts +++ b/apps/code/src/renderer/features/command-center/hooks/useAutofillCommandCenter.test.ts @@ -115,16 +115,52 @@ describe("useAutofillCommandCenter", () => { ]); }); - it("does not change cells when they are already populated", () => { - useCommandCenterStore.setState({ cells: ["existing", null, null, null] }); + it("does not touch cells when every cell is already populated", () => { + useCommandCenterStore.setState({ cells: ["a", "b", "c", "d"] }); setQueries({ tasks: [makeTask({ id: "t1" })], workspaces: { t1: makeWorkspace("t1") }, }); renderHook(() => useAutofillCommandCenter()); + expect(useCommandCenterStore.getState().cells).toEqual([ + "a", + "b", + "c", + "d", + ]); + }); + + it("tops up empty slots and leaves populated ones alone", () => { + useCommandCenterStore.setState({ cells: ["existing", null, null, null] }); + setQueries({ + tasks: [ + makeTask({ id: "t1", updated_at: new Date(NOW - 100).toISOString() }), + makeTask({ id: "t2", updated_at: new Date(NOW - 200).toISOString() }), + ], + workspaces: { t1: makeWorkspace("t1"), t2: makeWorkspace("t2") }, + }); + renderHook(() => useAutofillCommandCenter()); expect(useCommandCenterStore.getState().cells).toEqual([ "existing", + "t1", + "t2", null, + ]); + }); + + it("does not fill a task that is already assigned to another cell", () => { + useCommandCenterStore.setState({ cells: ["t1", null, null, null] }); + setQueries({ + tasks: [ + makeTask({ id: "t1", updated_at: new Date(NOW - 100).toISOString() }), + makeTask({ id: "t2", updated_at: new Date(NOW - 200).toISOString() }), + ], + workspaces: { t1: makeWorkspace("t1"), t2: makeWorkspace("t2") }, + }); + renderHook(() => useAutofillCommandCenter()); + expect(useCommandCenterStore.getState().cells).toEqual([ + "t1", + "t2", null, null, ]); diff --git a/apps/code/src/renderer/features/command-center/hooks/useAutofillCommandCenter.ts b/apps/code/src/renderer/features/command-center/hooks/useAutofillCommandCenter.ts index 709e5d119..83cb67a3d 100644 --- a/apps/code/src/renderer/features/command-center/hooks/useAutofillCommandCenter.ts +++ b/apps/code/src/renderer/features/command-center/hooks/useAutofillCommandCenter.ts @@ -36,21 +36,24 @@ export function useAutofillCommandCenter(): void { if (!workspacesFetched || !workspaces) return; if (!tasksFetched) return; - if (!cells.every((id) => id == null)) { + const emptySlots = cells.filter((id) => id == null).length; + if (emptySlots === 0) { hasRunRef.current = true; return; } + const assignedIds = new Set(cells.filter((id): id is string => id != null)); const cutoff = Date.now() - RECENT_WINDOW_MS; const candidates = tasks .filter( (task) => + !assignedIds.has(task.id) && !archivedTaskIds.has(task.id) && !!workspaces[task.id] && getLastActivity(task) >= cutoff, ) .sort((a, b) => getLastActivity(b) - getLastActivity(a)) - .slice(0, cells.length) + .slice(0, emptySlots) .map((task) => task.id); if (candidates.length > 0) { diff --git a/apps/code/src/renderer/features/command-center/stores/commandCenterStore.test.ts b/apps/code/src/renderer/features/command-center/stores/commandCenterStore.test.ts index 4f15eb607..7d5bc4e14 100644 --- a/apps/code/src/renderer/features/command-center/stores/commandCenterStore.test.ts +++ b/apps/code/src/renderer/features/command-center/stores/commandCenterStore.test.ts @@ -43,13 +43,35 @@ describe("commandCenterStore", () => { expect(useCommandCenterStore.getState().activeTaskId).toBeNull(); }); - it("does nothing when any cell is already populated", () => { + it("fills only the empty slots when some cells are already populated", () => { useCommandCenterStore.setState({ cells: [null, "existing", null, null] }); - useCommandCenterStore.getState().autofillCells(["t1", "t2"]); + useCommandCenterStore.getState().autofillCells(["t1", "t2", "t3"]); expect(useCommandCenterStore.getState().cells).toEqual([ - null, + "t1", "existing", + "t2", + "t3", + ]); + }); + + it("does nothing when every cell is already populated", () => { + useCommandCenterStore.setState({ cells: ["a", "b", "c", "d"] }); + useCommandCenterStore.getState().autofillCells(["t1", "t2"]); + expect(useCommandCenterStore.getState().cells).toEqual([ + "a", + "b", + "c", + "d", + ]); + }); + + it("stops filling when task list runs out before empty slots do", () => { + useCommandCenterStore.setState({ cells: [null, null, "x", null] }); + useCommandCenterStore.getState().autofillCells(["t1"]); + expect(useCommandCenterStore.getState().cells).toEqual([ + "t1", null, + "x", null, ]); }); diff --git a/apps/code/src/renderer/features/command-center/stores/commandCenterStore.ts b/apps/code/src/renderer/features/command-center/stores/commandCenterStore.ts index 8af0c076b..a60074547 100644 --- a/apps/code/src/renderer/features/command-center/stores/commandCenterStore.ts +++ b/apps/code/src/renderer/features/command-center/stores/commandCenterStore.ts @@ -122,12 +122,14 @@ export const useCommandCenterStore = create()( autofillCells: (taskIds) => set((state) => { - if (!state.cells.every((id) => id == null)) return state; if (taskIds.length === 0) return state; + if (state.cells.every((id) => id != null)) return state; const cells: (string | null)[] = [...state.cells]; - const limit = Math.min(cells.length, taskIds.length); - for (let i = 0; i < limit; i++) { - cells[i] = taskIds[i]; + const queue = [...taskIds]; + for (let i = 0; i < cells.length && queue.length > 0; i++) { + if (cells[i] == null) { + cells[i] = queue.shift() as string; + } } return { cells }; }),