TaskExecutionList.tsx11.9 KBView on GitHub /**
* TaskExecutionList — the selected task group's open tasks, one flat scrolling column.
*
* The group is chosen through `?group=` (written when a task is opened);
* this renders only that lane, so the column is an uninterrupted list of cards rather than a stack
* of sticky group headers you have to read past.
*
* The lane is split on the same due-now boundary the board's Upcoming column uses: what's due sits
* at the top and is the only thing any count on this surface includes, and what isn't due yet sits
* below an "Upcoming" heading. Both halves stay in this lane — the sidebar is a group drill-in, so
* pulling a group's future work into a global Upcoming (as the board does) would mean rendering
* other groups' tasks here.
*
* It also owns the hydration of the todo slice both halves render from, and the j/k navigation
* across the visible lane.
*/
import {
useOpenTaskInExecutionMode,
type ExecutableTask,
} from '@/modules/userTasks/hooks/use-open-task-in-execution-mode';
import { useTaskGroupBuckets, MISC_KEY } from '@/modules/userTasks/hooks/use-task-group-buckets';
import { useOptimisticTaskActions } from '@/modules/userTasks/hooks/use-optimistic-task-actions';
import { openConversationFromAgenda } from '@/modules/agentCanvas/utils/open-conversation';
import { useHydrateTasksSlice } from '@/modules/userTasks/hooks/use-hydrate-tasks-slice';
import { useTaskExecutionNav } from '@/modules/userTasks/hooks/use-task-execution-nav';
import { useExecuteTaskNow } from '@/modules/userTasks/hooks/use-execute-task-now';
import { hasTaskOutput } from '@/modules/agentCanvas/utils/agenda-right-slot-state';
import type { ConversationTaskActionData } from '@/modules/crm/types';
import { DatePickerDialog } from '@/components/ui/date-picker-dialog';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTRPC } from '@/providers/query-provider';
import { TaskKanbanCard, type TaskCardTask } from '@/modules/userTasks/components/TaskKanbanCard';
import { useQuery } from '@tanstack/react-query';
import { useQueryState } from 'nuqs';
export function TaskExecutionList() {
const trpc = useTRPC();
const openTaskInExecutionMode = useOpenTaskInExecutionMode();
const executeTaskNow = useExecuteTaskNow();
// Active-task highlight, shared with the board via the same `?task` param.
const [selectedTaskId] = useQueryState('task', { history: 'push' });
const scrollRef = useRef<HTMLDivElement>(null);
const {
tasksByGroup,
upcomingByGroup,
filteredTasks,
activeKey,
activeTasks,
activeUpcomingTasks,
setUrlGroup,
} = useTaskGroupBuckets();
const [urlGroup] = useQueryState('group');
// Same scope as TaskKanbanBoard so both surfaces hydrate the *same* authoritative todo set —
// `hydrateTodoTasks` reconciles removals against it, so a narrower scope here would drop the tail.
const { data: tasksData } = useQuery(
trpc.userTasks.listUserTasks.queryOptions({
status: 'todo',
withConversation: true,
limit: 500,
sortDueDate: 'asc',
}),
);
// The ordered task list j/k execution-mode navigation walks — the visible group's tasks in the
// order they're rendered (due ones first, then the Upcoming section), so navigation never jumps
// to a task you can't see and never stops short of one you can.
const orderedTasks = useMemo<ExecutableTask[]>(
() =>
[...activeTasks, ...activeUpcomingTasks].map((t) => ({
id: t.id,
conversationId: t.conversationId,
taskActionData: t.taskActionData ?? null,
taskGroupId: (t as { taskGroupId?: string | null }).taskGroupId ?? null,
chatThreadId: t.chatThreadId ?? null,
})),
[activeTasks, activeUpcomingTasks],
);
useTaskExecutionNav({ orderedTasks, selectedTaskId, openTask: openTaskInExecutionMode });
// Mirror fetched tasks into the slice so complete/snooze/delete can resolve them.
useHydrateTasksSlice(tasksData?.tasks);
// Selecting a group opens (selects) its first task — the soonest-due one, since buckets are
// sorted. A lane whose work is all still upcoming falls through to that half rather than opening
// nothing: the group isn't empty, its work is just scheduled ahead.
const openFirstTaskInGroup = (key=[redacted] => {
const first = (tasksByGroup.get(key) ?? [])[0] ?? (upcomingByGroup.get(key) ?? [])[0];
if (!first) return;
openTaskInExecutionMode({
id: first.id,
conversationId: first.conversationId,
taskActionData: first.taskActionData ?? null,
// Carried so a DONE Slack task does not re-seed its already-sent message — see `openTask`.
status: first.status ?? null,
chatThreadId: first.chatThreadId ?? null,
});
};
// A) Opening a group (from a pill or from the sidebar) auto-selects its first task (instead of the
// agenda). Fires once per group-open (guarded on the group value, so clearing ?task with Escape
// doesn't re-open), but keeps a `?task` that already belongs to the group — a deep link / refresh
// is respected.
//
// The auto-select is for the LANE: you picked a lane, so its first task opens. It is never for a
// task — an arriving `?task=` is someone naming the one task they want, and opening a different
// one over it is a hijack whatever the lane says. The two are told apart by which params moved:
// a pill click writes `?group=` alone, opening a task writes `?group=` AND `?task=` together.
//
// So a lane selection ARMS the auto-select and a task cancels it, rather than either being read
// off the current render. The arming has to persist because the two moments are separated: the
// lane is picked now, its tasks hydrate several renders later, and this effect re-runs in between
// on every `tasksByGroup` identity change. A one-shot "did the task just change" test goes quiet
// in exactly that gap and the hijack lands on the next re-run.
//
// Keyed on the params rather than on the task being findable here: a task this rail can't see (a
// teammate's, one the toolbar filter excludes, one still hydrating) is exactly the one effect B
// can't file either, so a membership test reads it as "no task open" and opens over it.
const autoSelectedGroupRef = useRef<string | null>(null);
const seenParamsRef = useRef<{ group: string | null; task: string | null }>({
group: null,
task: null,
});
const armedGroupRef = useRef<string | null>(null);
useEffect(() => {
const seen = seenParamsRef.current;
seenParamsRef.current = { group: urlGroup, task: selectedTaskId };
if (seen.task !== selectedTaskId) armedGroupRef.current = null;
else if (seen.group !== urlGroup) armedGroupRef.current = urlGroup;
if (!urlGroup || autoSelectedGroupRef.current === urlGroup) return;
if (armedGroupRef.current !== urlGroup) return;
const groupTasks = [
...(tasksByGroup.get(urlGroup) ?? []),
...(upcomingByGroup.get(urlGroup) ?? []),
];
if (!groupTasks.length) return; // wait until this group's tasks have hydrated
const openTaskIsInGroup = !!selectedTaskId && groupTasks.some((t) => t.id === selectedTaskId);
autoSelectedGroupRef.current = urlGroup;
if (!openTaskIsInGroup) openFirstTaskInGroup(urlGroup);
// openFirstTaskInGroup is stable for this one-shot; deps intentionally minimal.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [urlGroup, selectedTaskId, tasksByGroup, upcomingByGroup]);
// B) Opening a task from another surface (agenda / list / board) switches the pill to its group,
// so the open task is always one of the rendered ones. Effect A then sees a `?task` that already
// belongs to the new group and leaves it alone. Fires once per newly-opened task, never on a pill
// click — otherwise selecting an empty group would bounce straight back to the open task's group.
const syncedTaskRef = useRef<string | null>(null);
useEffect(() => {
if (!selectedTaskId || syncedTaskRef.current === selectedTaskId) return;
const task = filteredTasks.find((t) => t.id === selectedTaskId);
if (!task) return; // not hydrated (or filtered out) — reconsider when the task list changes
syncedTaskRef.current = selectedTaskId;
const key=[redacted] ?? MISC_KEY;
if (key !== activeKey) void setUrlGroup(key);
}, [selectedTaskId, filteredTasks, activeKey, setUrlGroup]);
// C) …and scrolls its row into view here.
useEffect(() => {
if (!selectedTaskId) return;
const el = scrollRef.current?.querySelector(`[data-acc-task-id="${selectedTaskId}"]`);
el?.scrollIntoView({ block: 'nearest' });
}, [selectedTaskId, activeKey]);
const { optimisticCompleteTask, optimisticSnoozeTask, optimisticDeleteTask } =
useOptimisticTaskActions();
// Snooze opens the same natural-language date picker as email snooze.
const [snoozeTaskId, setSnoozeTaskId] = useState<string | null>(null);
// One function for every card, rather than a closure per card: `TaskKanbanCard` is memoised on
// its props, and a fresh callback per render is what stops that memo from ever bailing out.
const handleExecute = useCallback(
(task: TaskCardTask) =>
void executeTaskNow({
taskId: task.id,
conversationId: task.conversationId,
description: task.description || 'Untitled task',
}),
[executeTaskNow],
);
const renderCard = (task: (typeof activeTasks)[number]) => (
// Scroll anchor so opening a task elsewhere can bring its row into view here.
<div key=[redacted] data-acc-task-id={task.id}>
<TaskKanbanCard
task={task}
isActive={task.id === selectedTaskId}
draggable={false}
clampDescription
onToggleComplete={(id) => void optimisticCompleteTask(id, 'checked-off')}
onSnooze={(id) => setSnoozeTaskId(id)}
onDelete={(id) => void optimisticDeleteTask(id)}
onOpen={(t) => openTaskInExecutionMode(t)}
onOpenConversation={(id) => openConversationFromAgenda(id)}
// The same in-card Execute the board's cards carry, gated the same way: a task
// with an output opens it, a task with nothing yet offers to run. The sidebar
// renders the same component, so it gets the same affordance.
onExecute={
hasTaskOutput(task.taskActionData as ConversationTaskActionData | null | undefined)
? undefined
: handleExecute
}
/>
</div>
);
return (
<>
<div
ref={scrollRef}
className="flex min-h-0 w-full flex-1 flex-col gap-1.5 overflow-y-auto pb-2"
>
{activeTasks.length === 0 ? (
<p className="text-muted-foreground px-1 py-1 text-xs">
{activeUpcomingTasks.length > 0 ? 'Nothing due.' : 'No open tasks.'}
</p>
) : (
activeTasks.map(renderCard)
)}
{/* Upcoming — this lane's not-yet-due work, below a rule so it reads as a separate block
rather than the tail of the due list. Nothing above counts it. */}
{activeUpcomingTasks.length > 0 && (
<>
<div className="mt-2 flex items-center gap-2 px-1 pb-0.5">
<span className="text-muted-foreground/70 shrink-0 text-xs font-medium uppercase tracking-wide">
Upcoming
</span>
<span className="h-px min-w-0 flex-1 bg-border" />
<span className="text-muted-foreground/70 shrink-0 text-xs tabular-nums">
{activeUpcomingTasks.length}
</span>
</div>
{activeUpcomingTasks.map(renderCard)}
</>
)}
</div>
{/* Natural-language snooze picker — same modal email snooze uses. */}
<DatePickerDialog
open={snoozeTaskId !== null}
onOpenChange={(open) => !open && setSnoozeTaskId(null)}
onSelect={(date) => {
if (snoozeTaskId) void optimisticSnoozeTask(snoozeTaskId, date);
setSnoozeTaskId(null);
}}
title="Snooze until"
placeholder="Try: tomorrow, next week, or aug 7"
/>
</>
);
}