OpenTaskExecutionCard.tsx4.8 KBView on GitHub
'use client';

/**
 * OpenTaskExecutionCard — the open task, sitting at the top of its conversation's Inbox as if it
 * were the newest thread on it.
 *
 * A task that has produced something opens that output (its draft's thread, its Slack message). A
 * task with NOTHING produced yet has no output to open, so execution mode lands it on its
 * conversation's Inbox instead — the mail behind the task, which is what you need to read to do it.
 * That view is about the deal, though, not the task: without this the task disappeared the moment
 * you opened it, taking its Execute button with it.
 *
 * It is the same `TaskKanbanCard` the board and the execution rail render — not a lookalike — so a
 * task reads identically wherever you meet it. `bg-raised` is the one difference: the inbox rows it
 * sits among are flat on the surface, and the card has to lift off them to read as the task rather
 * than another thread.
 *
 * Sticky, because the row list opens scrolled to its far end — pinned, the task stays put while you
 * read down the mail behind it, which is the whole reason you are on this tab.
 *
 * Self-gating, so the row list can mount it unconditionally: it renders only for the task named by
 * `?task=`, only while that task belongs to the open conversation, and only while it has no output.
 * Everything else returns null.
 */

import { useQuery } from '@tanstack/react-query';
import { useQueryState } from 'nuqs';

import { hasTaskOutput } from '@/modules/agentCanvas/utils/agenda-right-slot-state';
// Straight from the store, not the `@/modules/conversations` barrel that re-exports them: the
// inbox row list this renders inside is itself reachable from that barrel, and importing it back
// would close a module cycle.
import { useActiveConversationId, useConversationById } from '@/modules/store';
import type { ConversationTaskActionData } from '@/modules/crm/types';
import { useTRPC } from '@/providers/query-provider';

import { useExecuteTaskNow } from '../hooks/use-execute-task-now';
import { TaskKanbanCard, type TaskCardTask } from './TaskKanbanCard';
import { useForeignTaskOwner } from './TaskOwnerBadge';

export function OpenTaskExecutionCard() {
  const trpc = useTRPC();
  const [taskId] = useQueryState('task');
  const conversationId = useActiveConversationId();
  const conversationData = useConversationById(conversationId);
  const executeTaskNow = useExecuteTaskNow();

  const { data } = useQuery({
    ...trpc.userTasks.getTaskById.queryOptions({ taskId: taskId ?? '' }),
    enabled: !!taskId,
  });
  const task = data?.task ?? null;

  // Tolerates a null task — it must run before the gates below, which are early returns.
  const foreignOwner = useForeignTaskOwner(task?.user);

  if (!task || !conversationId) return null;
  // A `?task=` left over from another surface (or a task whose conversation you navigated away
  // from) must not put someone else's work at the top of THIS conversation.
  if (task.conversationId !== conversationId) return null;
  if (task.status === 'done') return null;
  // A task with an output never lands here — it opens that output. Guarded anyway so the card can't
  // start duplicating the draft's own row if `openTask`'s routing ever changes.
  if (hasTaskOutput(task.taskActionData as ConversationTaskActionData | null)) return null;

  const company = conversationData?.data.company ?? null;
  const conversation = conversationData?.data.conversation ?? null;

  const cardTask: TaskCardTask = {
    id: task.id,
    description: task.description,
    conversationId: task.conversationId,
    taskOutput: task.taskOutput,
    status: task.status,
    dueDate: task.dueDate,
    taskActionData: task.taskActionData as TaskCardTask['taskActionData'],
    chatThreadId: task.chatThreadId,
    conversation: {
      name: conversation?.name ?? null,
      companyName: company?.name ?? null,
      logoUrl: company?.logoUrl ?? null,
      lastContactedAt: null,
      nextStepDate: null,
      nextSteps: null,
    },
  };

  return (
    // z-20, not z-10: a thread row is itself `relative z-10` (it needs a stacking context for its
    // hover/selection overlays), so at equal z the rows — later siblings — painted straight over
    // the pinned card as they scrolled under it.
    <div className="bg-surface sticky top-0 z-20 py-2">
      <TaskKanbanCard
        task={cardTask}
        draggable={false}
        className="bg-raised"
        // A teammate's task is readable here but not runnable — only they can execute it, the same
        // rule the task-output panel states.
        onExecute={
          foreignOwner
            ? undefined
            : (t) =>
                void executeTaskNow({
                  taskId: t.id,
                  conversationId: t.conversationId,
                  description: t.description || 'Untitled task',
                })
        }
      />
    </div>
  );
}