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..18538f5db --- /dev/null +++ b/apps/code/src/renderer/features/command-center/hooks/useAutofillCommandCenter.ts @@ -0,0 +1,63 @@ +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 = [], isFetched: tasksFetched } = 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 (!tasksFetched) 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, + 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 new file mode 100644 index 000000000..c9fa694eb --- /dev/null +++ b/apps/code/src/renderer/features/command-center/stores/commandCenterStore.test.ts @@ -0,0 +1,59 @@ +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.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", () => { + useCommandCenterStore.setState({ cells: [null, "existing", null, null] }); + useCommandCenterStore.getState().autofillCells(["t1", "t2"]); + expect(useCommandCenterStore.getState().cells).toEqual([ + null, + "existing", + 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 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];