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.test.ts b/apps/code/src/renderer/features/command-center/hooks/useAutofillCommandCenter.test.ts new file mode 100644 index 000000000..d399bf098 --- /dev/null +++ b/apps/code/src/renderer/features/command-center/hooks/useAutofillCommandCenter.test.ts @@ -0,0 +1,327 @@ +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, + ]); + }); + + it("does nothing when workspaces are not fetched", () => { + setQueries({ workspacesFetched: false }); + renderHook(() => useAutofillCommandCenter()); + expect(useCommandCenterStore.getState().cells).toEqual([ + null, + 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, + ]); + }); + + 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, + ]); + }); + + 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("does not change cells when no candidates are available", () => { + setQueries({ tasks: [], workspaces: {} }); + 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 new file mode 100644 index 000000000..83cb67a3d --- /dev/null +++ b/apps/code/src/renderer/features/command-center/hooks/useAutofillCommandCenter.ts @@ -0,0 +1,72 @@ +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"; + +// Window for "still in the current working session". Tasks last touched +// 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 { + 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); + + // 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 (hasRunRef.current) return; + if (!workspacesFetched || !workspaces) return; + if (!tasksFetched) return; + + 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, emptySlots) + .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..7d5bc4e14 --- /dev/null +++ b/apps/code/src/renderer/features/command-center/stores/commandCenterStore.test.ts @@ -0,0 +1,79 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@utils/electronStorage", () => ({ + electronStorage: { + getItem: () => null, + setItem: () => {}, + removeItem: () => {}, + }, +})); + +import { + COMMAND_CENTER_INITIAL_STATE, + useCommandCenterStore, +} from "./commandCenterStore"; + +function resetStore() { + useCommandCenterStore.setState(COMMAND_CENTER_INITIAL_STATE); +} + +describe("commandCenterStore", () => { + beforeEach(resetStore); + + 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("fills only the empty slots when some cells are already populated", () => { + useCommandCenterStore.setState({ cells: [null, "existing", null, null] }); + useCommandCenterStore.getState().autofillCells(["t1", "t2", "t3"]); + expect(useCommandCenterStore.getState().cells).toEqual([ + "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 77c36fc61..a60074547 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; @@ -43,6 +44,15 @@ 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: [], +}; + type CommandCenterStore = CommandCenterStoreState & CommandCenterStoreActions; function resizeCells( @@ -69,12 +79,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) => { @@ -115,6 +120,20 @@ export const useCommandCenterStore = create()( }; }), + autofillCells: (taskIds) => + set((state) => { + if (taskIds.length === 0) return state; + if (state.cells.every((id) => id != null)) return state; + const cells: (string | null)[] = [...state.cells]; + 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 }; + }), + removeTask: (cellIndex) => set((state) => { const cells = [...state.cells];