diff --git a/apps/web/src/app/(authenticated)/tasks/Tasks.tsx b/apps/web/src/app/(authenticated)/tasks/Tasks.tsx index c2fd6ab87..bbcb9222a 100644 --- a/apps/web/src/app/(authenticated)/tasks/Tasks.tsx +++ b/apps/web/src/app/(authenticated)/tasks/Tasks.tsx @@ -345,23 +345,92 @@ export const Tasks = () => { * Tasks (Graceful Loading + Polling) */ - const infiniteTasks = useInfiniteTasks({ + const listTasksQuery = useInfiniteTasks({ filters: effectiveFilters, timePeriod, pageSize: 50, + enabled: !isBoardView, + }); + const activeTasksQuery = useInfiniteTasks({ + filters: effectiveFilters, + timePeriod, + boardColumn: 'active', + pageSize: 6, + enabled: isBoardView, + }); + const needsInputTasksQuery = useInfiniteTasks({ + filters: effectiveFilters, + timePeriod, + boardColumn: 'needs-input', + pageSize: 6, + enabled: isBoardView, + }); + const blockedTasksQuery = useInfiniteTasks({ + filters: effectiveFilters, + timePeriod, + boardColumn: 'blocked', + pageSize: 6, + enabled: isBoardView, + }); + const doneTasksQuery = useInfiniteTasks({ + filters: effectiveFilters, + timePeriod, + boardColumn: 'done', + pageSize: 6, + enabled: isBoardView, }); + const listTasks = useMemo( + () => listTasksQuery.data?.pages.flatMap((page) => page.tasks) ?? [], + [listTasksQuery.data], + ); + const activeTasks = useMemo( + () => activeTasksQuery.data?.pages.flatMap((page) => page.tasks) ?? [], + [activeTasksQuery.data], + ); + const needsInputTasks = useMemo( + () => needsInputTasksQuery.data?.pages.flatMap((page) => page.tasks) ?? [], + [needsInputTasksQuery.data], + ); + const blockedTasks = useMemo( + () => blockedTasksQuery.data?.pages.flatMap((page) => page.tasks) ?? [], + [blockedTasksQuery.data], + ); + const doneTasks = useMemo( + () => doneTasksQuery.data?.pages.flatMap((page) => page.tasks) ?? [], + [doneTasksQuery.data], + ); const tasks = useMemo( - () => infiniteTasks.data?.pages.flatMap((page) => page.tasks) ?? [], - [infiniteTasks.data], + () => + isBoardView + ? [...activeTasks, ...needsInputTasks, ...blockedTasks, ...doneTasks] + : listTasks, + [ + activeTasks, + blockedTasks, + doneTasks, + isBoardView, + listTasks, + needsInputTasks, + ], ); - const isPending = infiniteTasks.isPending; - const isError = infiniteTasks.isError && !infiniteTasks.data; + const isPending = isBoardView + ? activeTasksQuery.isPending || + needsInputTasksQuery.isPending || + blockedTasksQuery.isPending || + doneTasksQuery.isPending + : listTasksQuery.isPending; + const isError = isBoardView + ? (activeTasksQuery.isError && !activeTasksQuery.data) || + (needsInputTasksQuery.isError && !needsInputTasksQuery.data) || + (blockedTasksQuery.isError && !blockedTasksQuery.data) || + (doneTasksQuery.isError && !doneTasksQuery.data) + : listTasksQuery.isError && !listTasksQuery.data; const { showContent } = useGracefulLoading({ isPending, data: tasks, - dependencies: [effectiveFilters, timePeriod], + dependencies: [effectiveFilters, timePeriod, isBoardView], }); const tasksListRef = useRef(null); @@ -694,7 +763,36 @@ export const Tasks = () => { className="min-h-0 flex-1 overflow-y-auto overflow-x-hidden" > {isBoardView ? ( - + activeTasksQuery.fetchNextPage(), + }, + 'needs-input': { + tasks: needsInputTasks, + hasNextPage: needsInputTasksQuery.hasNextPage, + isFetchingNextPage: + needsInputTasksQuery.isFetchingNextPage, + onShowMore: () => needsInputTasksQuery.fetchNextPage(), + }, + blocked: { + tasks: blockedTasks, + hasNextPage: blockedTasksQuery.hasNextPage, + isFetchingNextPage: blockedTasksQuery.isFetchingNextPage, + onShowMore: () => blockedTasksQuery.fetchNextPage(), + }, + done: { + tasks: doneTasks, + hasNextPage: doneTasksQuery.hasNextPage, + isFetchingNextPage: doneTasksQuery.isFetchingNextPage, + onShowMore: () => doneTasksQuery.fetchNextPage(), + }, + }} + /> ) : (
{tasks.map((task) => ( @@ -711,14 +809,14 @@ export const Tasks = () => { ))}
)} - {infiniteTasks.hasNextPage && ( + {!isBoardView && listTasksQuery.hasNextPage && (
)} ); diff --git a/apps/web/src/components/tasks/task-board.ts b/apps/web/src/components/tasks/task-board.ts index 23474cac2..7ad26ec6e 100644 --- a/apps/web/src/components/tasks/task-board.ts +++ b/apps/web/src/components/tasks/task-board.ts @@ -1,13 +1,5 @@ import type { Task } from '@/lib/server'; - -export const TASK_BOARD_COLUMNS = [ - 'active', - 'needs-input', - 'blocked', - 'done', -] as const; - -export type TaskBoardColumn = (typeof TASK_BOARD_COLUMNS)[number]; +import type { TaskBoardColumn } from '@/types'; type BoardTask = Pick< Task, diff --git a/apps/web/src/hooks/tasks/useInfiniteTasks.client.test.tsx b/apps/web/src/hooks/tasks/useInfiniteTasks.client.test.tsx index 953f64205..309190f2f 100644 --- a/apps/web/src/hooks/tasks/useInfiniteTasks.client.test.tsx +++ b/apps/web/src/hooks/tasks/useInfiniteTasks.client.test.tsx @@ -55,4 +55,25 @@ describe('useInfiniteTasks', () => { ); expect(options.getNextPageParam({})).toBeUndefined(); }); + + it('includes the board column in the query input', () => { + renderHook(() => + useInfiniteTasks({ + filters: [], + timePeriod: 'all', + boardColumn: 'needs-input', + pageSize: 6, + }), + ); + + expect(infiniteQueryOptionsMock).toHaveBeenCalledWith( + { + limit: 6, + filters: [], + timePeriod: 'all', + boardColumn: 'needs-input', + }, + expect.objectContaining({ enabled: true }), + ); + }); }); diff --git a/apps/web/src/hooks/tasks/useInfiniteTasks.ts b/apps/web/src/hooks/tasks/useInfiniteTasks.ts index 0289888a3..289c31af7 100644 --- a/apps/web/src/hooks/tasks/useInfiniteTasks.ts +++ b/apps/web/src/hooks/tasks/useInfiniteTasks.ts @@ -2,7 +2,7 @@ import { useInfiniteQuery } from '@tanstack/react-query'; -import type { Filter, TimePeriodFilter } from '@/types'; +import type { Filter, TaskBoardColumn, TimePeriodFilter } from '@/types'; import { useTRPC } from '@/trpc/client'; @@ -14,6 +14,7 @@ import { interface UseInfiniteTasksOptions { filters: Filter[]; timePeriod: TimePeriodFilter; + boardColumn?: TaskBoardColumn; pageSize?: number; pollingOptions?: UseRealtimePollingOptions; enabled?: boolean; @@ -22,6 +23,7 @@ interface UseInfiniteTasksOptions { export function useInfiniteTasks({ filters, timePeriod, + boardColumn, pageSize = 50, pollingOptions = { enabled: true, interval: 5000 }, enabled = true, @@ -31,7 +33,12 @@ export function useInfiniteTasks({ return useInfiniteQuery( trpc.tasks.list.infiniteQueryOptions( - { limit: pageSize, filters, timePeriod }, + { + limit: pageSize, + filters, + timePeriod, + ...(boardColumn ? { boardColumn } : {}), + }, { ...polling, enabled, diff --git a/apps/web/src/lib/server/tasks.board.test.ts b/apps/web/src/lib/server/tasks.board.test.ts new file mode 100644 index 000000000..3ba5d0549 --- /dev/null +++ b/apps/web/src/lib/server/tasks.board.test.ts @@ -0,0 +1,117 @@ +import { runFactory, taskFactory, userFactory } from '@roomote/db/server'; +import { TaskPayloadKind } from '@roomote/types'; + +import type { TaskBoardColumn } from '@/types'; + +import { getTasks } from './tasks'; + +describe('getTasks board columns', () => { + async function createRun(taskId: string, taskPhase: string | null = null) { + await runFactory.create({ + taskId, + taskPhase, + payloadKind: TaskPayloadKind.StandardTask, + }); + } + + it('filters columns before pagination using the latest run phase and board precedence', async () => { + const user = await userFactory.create({}); + const needsInput = await taskFactory.create({ + initiatorUserId: user.id, + state: 'active', + goalStatus: 'blocked', + activityAt: 4_000, + }); + await createRun(needsInput.id, 'waiting_for_user_input'); + + const blocked = await taskFactory.create({ + initiatorUserId: user.id, + state: 'completed', + goalStatus: 'blocked', + activityAt: 3_000, + }); + await createRun(blocked.id); + + const done = await taskFactory.create({ + initiatorUserId: user.id, + state: 'active', + goalStatus: 'complete', + activityAt: 2_000, + }); + await createRun(done.id); + + const active = await taskFactory.create({ + initiatorUserId: user.id, + state: 'active', + goalStatus: null, + activityAt: 1_000, + }); + await createRun(active.id, 'waiting_for_user_input'); + await createRun(active.id); + + const expectedIds: Record = { + active: active.id, + 'needs-input': needsInput.id, + blocked: blocked.id, + done: done.id, + }; + + for (const boardColumn of Object.keys(expectedIds) as TaskBoardColumn[]) { + const result = await getTasks({ + userId: user.id, + filters: [{ type: 'userId', value: user.id, label: user.id }], + boardColumn, + }); + + expect(result.tasks.map((task) => task.id)).toEqual([ + expectedIds[boardColumn], + ]); + } + }); + + it('keeps an independent cursor within one board column', async () => { + const user = await userFactory.create({}); + const activeTasks = await Promise.all( + [3_000, 2_000, 1_000].map(async (activityAt) => { + const task = await taskFactory.create({ + initiatorUserId: user.id, + state: 'active', + goalStatus: null, + activityAt, + }); + await createRun(task.id); + return task; + }), + ); + const blocked = await taskFactory.create({ + initiatorUserId: user.id, + state: 'failed', + activityAt: 4_000, + }); + await createRun(blocked.id); + + const firstPage = await getTasks({ + userId: user.id, + filters: [{ type: 'userId', value: user.id, label: user.id }], + boardColumn: 'active', + limit: 2, + }); + const secondPage = await getTasks({ + userId: user.id, + filters: [{ type: 'userId', value: user.id, label: user.id }], + boardColumn: 'active', + limit: 2, + cursor: firstPage.nextCursor, + }); + + expect(firstPage.tasks.map((task) => task.id)).toEqual([ + activeTasks[0]!.id, + activeTasks[1]!.id, + ]); + expect(firstPage.hasMore).toBe(true); + expect(secondPage.tasks.map((task) => task.id)).toEqual([ + activeTasks[2]!.id, + ]); + expect(secondPage.hasMore).toBe(false); + }); +}); diff --git a/apps/web/src/lib/server/tasks.ts b/apps/web/src/lib/server/tasks.ts index 1ce978d6b..c8fb7d7b0 100644 --- a/apps/web/src/lib/server/tasks.ts +++ b/apps/web/src/lib/server/tasks.ts @@ -23,6 +23,7 @@ import { lt, isNull, isNotNull, + not, sql, } from '@roomote/db/server'; @@ -31,6 +32,7 @@ import { type TaskCreatorKind, type TaskInferenceUsageSummary, type Filter, + type TaskBoardColumn, type TimePeriodFilter, HAS_PULL_REQUEST_FILTER_VALUE, } from '@/types'; @@ -199,6 +201,44 @@ const getTaskFilterConditions = ({ filters }: { filters: Filter[] }) => { return conditions; }; +function getTaskBoardColumnCondition( + boardColumn: TaskBoardColumn, +): TaskFilterCondition { + const latestTaskPhase = sql`( + SELECT ${taskRuns.taskPhase} + FROM ${taskRuns} + WHERE ${taskRuns.taskId} = ${tasks.id} + ORDER BY ${taskRuns.id} DESC + LIMIT 1 + )`; + const needsInput = sql`COALESCE( + ${tasks.state} = 'active' + AND ${latestTaskPhase} = 'waiting_for_user_input', + FALSE + )`; + const blocked = sql`COALESCE( + ${tasks.state} = 'failed' + OR ${tasks.goalStatus} IN ('blocked', 'budget_limited'), + FALSE + )`; + const done = sql`COALESCE( + ${tasks.state} IN ('completed', 'canceled') + OR ${tasks.goalStatus} = 'complete', + FALSE + )`; + + switch (boardColumn) { + case 'needs-input': + return needsInput; + case 'blocked': + return and(not(needsInput), blocked)!; + case 'done': + return and(not(needsInput), not(blocked), done)!; + case 'active': + return and(not(needsInput), not(blocked), not(done))!; + } +} + function parseTaskActivityCursor( cursor?: string | number, ): { activityAt: number; id?: string } | undefined { @@ -365,6 +405,7 @@ export const getTasks = async ({ cursor, filters = [], timePeriod = 'all', + boardColumn, allowTaskTypeFilter = false, }: { userId: string; @@ -373,6 +414,7 @@ export const getTasks = async ({ cursor?: string | number; filters?: Filter[]; timePeriod?: TimePeriodFilter; + boardColumn?: TaskBoardColumn; allowTaskTypeFilter?: boolean; }): Promise => { const effectiveFilters = getEffectiveFilters(filters, { @@ -390,6 +432,10 @@ export const getTasks = async ({ conditions.push(...getTaskFilterConditions({ filters: effectiveFilters })); + if (boardColumn) { + conditions.push(getTaskBoardColumnCondition(boardColumn)); + } + if (timePeriod !== 'all') { const cutoffTimestamp = Math.floor(Date.now() / 1000) - timePeriod * 24 * 60 * 60; diff --git a/apps/web/src/trpc/commands/tasks/__tests__/list.test.ts b/apps/web/src/trpc/commands/tasks/__tests__/list.test.ts index d33c86621..582502c4d 100644 --- a/apps/web/src/trpc/commands/tasks/__tests__/list.test.ts +++ b/apps/web/src/trpc/commands/tasks/__tests__/list.test.ts @@ -148,4 +148,17 @@ describe('getTasksCommand', () => { }), ); }); + + it('passes a board column through to the task query', async () => { + await getTasksCommand(auth, { + boardColumn: 'blocked', + filters: [{ type: 'userId', value: auth.userId, label: auth.userId }], + }); + + expect(mockGetTasks).toHaveBeenCalledWith( + expect.objectContaining({ + boardColumn: 'blocked', + }), + ); + }); }); diff --git a/apps/web/src/trpc/commands/tasks/list.ts b/apps/web/src/trpc/commands/tasks/list.ts index ebdde7c41..2cbd2aa96 100644 --- a/apps/web/src/trpc/commands/tasks/list.ts +++ b/apps/web/src/trpc/commands/tasks/list.ts @@ -8,6 +8,7 @@ export async function getTasksCommand( cursor?: string | number; filters?: Parameters[0]['filters']; timePeriod?: Parameters[0]['timePeriod']; + boardColumn?: Parameters[0]['boardColumn']; }, ) { const allowTaskTypeFilter = false; diff --git a/apps/web/src/trpc/routers/_app.ts b/apps/web/src/trpc/routers/_app.ts index 9726b8d63..5abbb758a 100644 --- a/apps/web/src/trpc/routers/_app.ts +++ b/apps/web/src/trpc/routers/_app.ts @@ -50,6 +50,7 @@ import { saveVercelConnectionSchema, saveXConnectionSchema, timePeriodFilterSchema, + taskBoardColumnSchema, PERSONAL_COLOR_THEMES, } from '@/types'; @@ -924,6 +925,7 @@ export const appRouter = createRouter({ cursor: z.union([z.string(), z.number()]).optional(), filters: z.array(filterSchema).optional(), timePeriod: timePeriodFilterSchema.optional(), + boardColumn: taskBoardColumnSchema.optional(), }), ) .query(({ ctx: { auth }, input }) => getTasksCommand(auth, input)), diff --git a/apps/web/src/types/task.ts b/apps/web/src/types/task.ts index fc39dfab4..e94f65027 100644 --- a/apps/web/src/types/task.ts +++ b/apps/web/src/types/task.ts @@ -20,6 +20,15 @@ import type { User, TaskRun } from '@roomote/db'; /** How a task's creator should be displayed, derived from initiator columns. */ export type TaskCreatorKind = 'user' | 'automation' | 'external'; +export const TASK_BOARD_COLUMNS = [ + 'active', + 'needs-input', + 'blocked', + 'done', +] as const; +export const taskBoardColumnSchema = z.enum(TASK_BOARD_COLUMNS); +export type TaskBoardColumn = z.infer; + export const taskSchema = z.object({ id: z.string(), harnessSessionId: z.string().nullable().optional(),