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"
      />
    </>
  );
}