TaskListRow.tsx11.9 KBView on GitHub
/**
 * TaskListRow — one task as a single horizontal row (the List layout), modelled on the CRM
 * ConversationItem: a checkbox, the task, the conversation column, its next-step date, and a row of
 * actions (delete · snooze · execute/open).
 *
 *   + │ ☐  Send pricing follow-up [Acme Corp] — before the 4:30   Fri    🗑 ⏰  [Execute]
 *   └ add-below handle (past container)  └ the task line           └ next step  └ actions
 *
 * Execute spawns the task's own chat thread (like the agenda's invoke); once it's produced a draft
 * the primary action becomes **Open**, and while it streams it shows the executing bot — the
 * per-task result attaches right here on the row.
 *
 * A row also reads its deal's `updatingTasks` flag, the same one the conversation's Next Steps
 * card reads: one agent pass rewrites the prose and the task rows together, so both surfaces
 * show "updating" and sweep on the same edge rather than a beat apart on the next refetch.
 */
import { memo } from 'react';
import { AlarmClock, Bot, Calendar, Loader2, Plus, Trash2 } from 'lucide-react';
import { SlackLogo } from '@/modules/inbox/components/channel-icons';
import { RelativeDateBadge } from '@/components/ui/relative-date-badge';
import { ShimmerText } from '@/modules/cedar-os/src/cedar-os-components/text/ShimmerText';
import { GmailColor } from '@/components/icons/icons';
import { TabBotIcon } from '@/components/icons/animated/bot-thinking';
import { AgendaCheckbox } from '@/modules/agentCanvas/components/AgendaCheckbox';
import { useUpdatingTasksShimmer } from '@/modules/conversations/hooks/use-updating-tasks-shimmer';
import { deriveAgendaRightSlot } from '@/modules/agentCanvas/utils/agenda-right-slot-state';
import { TaskLine } from '@/modules/userTasks/components/TaskLine';
import type { ConversationTaskActionData } from '@/modules/crm/types';
import { useIsTaskSelected } from '@/modules/store';
import { useTaskIsExecuting } from '@/modules/userTasks/hooks/use-task-is-executing';
import { usePrefetchTaskThreads } from '@/modules/userTasks/hooks/use-prefetch-task-threads';
import { cn } from '@/lib/utils';
import type { TaskDisplayProp } from '@/modules/userTasks/hooks/use-task-list-view-options';
import { ALL_DISPLAY_PROPS } from '@/modules/userTasks/hooks/use-task-list-view-options';
import type { TaskCardTask } from './TaskKanbanCard';

interface TaskListRowProps {
  task: TaskCardTask;
  isActive?: boolean;
  /** Which per-row columns to render. Defaults to all. */
  visibleProps?: TaskDisplayProp[];
  onOpen?: (task: TaskCardTask) => void;
  onOpenConversation?: (conversationId: string) => void;
  onOpenChat?: (chatThreadId: string) => void;
  onExecute?: (task: TaskCardTask) => void;
  onToggleComplete?: (taskId: string) => void;
  onSnooze?: (taskId: string) => void;
  onDelete?: (taskId: string) => void;
  onAddAfter?: () => void;
  /** ⌘/Ctrl-click — toggle this task in the bulk selection. */
  onToggleSelect?: (taskId: string) => void;
  /** Shift-click — extend the selection from the anchor to this task. */
  onRangeSelect?: (taskId: string) => void;
  /** Hover tracking so keyboard shortcuts (e/s/w/x) can target the row under the cursor. */
  onHover?: (taskId: string | null) => void;
}

/** A hover-only icon button, sized to line up in the action cluster. */
function IconAction({
  label,
  onClick,
  className,
  children,
}: {
  label: string;
  onClick: () => void;
  className?: string;
  children: React.ReactNode;
}) {
  return (
    <button
      type="button"
      aria-label={label}
      title={label}
      onClick={(e) => {
        e.stopPropagation();
        onClick();
      }}
      className={cn(
        'text-muted-foreground hover:bg-sunken hover:text-foreground flex size-7 shrink-0 cursor-pointer items-center justify-center rounded-md',
        className,
      )}
    >
      {children}
    </button>
  );
}

export const TaskListRow = memo(function TaskListRow({
  task,
  isActive = false,
  visibleProps = ALL_DISPLAY_PROPS,
  onOpen,
  onOpenConversation,
  onOpenChat,
  onExecute,
  onToggleComplete,
  onSnooze,
  onDelete,
  onAddAfter,
  onToggleSelect,
  onRangeSelect,
  onHover,
}: TaskListRowProps) {
  const showDueDate = visibleProps.includes('dueDate');
  const showConversation = visibleProps.includes('conversation');
  const showAction = visibleProps.includes('action');
  const isSelected = useIsTaskSelected(task.id);
  // The deal-level "an agent is rewriting this deal's commitments" flag — shared with the
  // conversation's Next Steps card, so the two land together. Sweeps the row on settle.
  const { ref: rowRef, isUpdating } = useUpdatingTasksShimmer<HTMLDivElement>(task.conversationId);
  const chatThreadId = task.chatThreadId ?? null;
  // Shared definition — see useTaskIsExecuting. A run started on any surface shows on all of them.
  const isExecuting = useTaskIsExecuting(task.id, chatThreadId);
  // Hovering a row pulls down what clicking it would open — the draft's thread, or the ticket and
  // the thread it renders. Same treatment a thread row in /inbox gets. See usePrefetchTaskThreads.
  const prefetchTaskThreads = usePrefetchTaskThreads();

  const conv = task.conversation ?? null;
  const done = task.status === 'done';

  // The agenda's right-slot state machine, reused: what to show for the primary action.
  const slot = deriveAgendaRightSlot({
    chatThreadId,
    taskActionData: task.taskActionData as ConversationTaskActionData | null | undefined,
    isProcessing: isExecuting,
  });

  return (
    <div
      ref={rowRef}
      onClick={(e) => {
        // Shift/⌘-click build a bulk selection (like mail/conversation rows); a plain click opens.
        if (e.shiftKey && onRangeSelect) {
          e.preventDefault();
          onRangeSelect(task.id);
          return;
        }
        if ((e.metaKey || e.ctrlKey) && onToggleSelect) {
          e.preventDefault();
          onToggleSelect(task.id);
          return;
        }
        onOpen?.(task);
      }}
      onMouseEnter={() => {
        onHover?.(task.id);
        prefetchTaskThreads(task);
      }}
      onMouseLeave={() => onHover?.(null)}
      className={cn(
        'group/row relative flex min-w-0 cursor-pointer items-center gap-2 py-1.5 pl-2 pr-3 transition-colors',
        isActive ? 'bg-action/10' : 'hover:bg-subtleWhite dark:hover:bg-[#202020]',
        isSelected && 'bg-action/5 ring-action/60 ring-1 ring-inset',
      )}
    >
      {/* Hover affordance — add a task below this one, in the gutter to the LEFT of the row. */}
      {onAddAfter && (
        <div
          className="absolute right-full top-1/2 flex -translate-y-1/2 items-center pr-1 opacity-0 transition-opacity group-hover/row:opacity-100"
          onClick={(e) => e.stopPropagation()}
        >
          <button
            type="button"
            aria-label="Add task below"
            title="Add task below"
            onClick={onAddAfter}
            className="text-muted-foreground/60 hover:text-foreground flex size-5 cursor-pointer items-center justify-center rounded"
          >
            <Plus className="size-4" />
          </button>
        </div>
      )}

      {/* Checkbox — square AgendaCheckbox, matching the agenda task node. */}
      <span className="flex shrink-0 items-center" onClick={(e) => e.stopPropagation()}>
        <AgendaCheckbox
          checked={done}
          onChange={() => onToggleComplete?.(task.id)}
          ariaLabel={done ? 'Mark task not done' : 'Mark task done'}
        />
      </span>

      {/* The task — the primary line, takes the slack. The deal rides inside it as a badge
          rather than in a column of its own: the row reads as one sentence. */}
      <TaskLine
        description={task.description}
        conversationId={showConversation ? task.conversationId : null}
        conversation={conv}
        onOpenConversation={onOpenConversation}
        done={done}
        truncate
        className="flex-1"
      />

      {/* The agent is rewriting this deal's commitments right now — same cue, same wording, as
          the conversation's Next Steps card. */}
      {isUpdating && (
        <span className="shrink-0">
          <ShimmerText text="updating" state="in_progress" />
        </span>
      )}

      {/* Actions — a FIXED-WIDTH column to the LEFT of the date: the primary execute/open pins to
          the right of this column, and delete · snooze reveal on hover to its left. */}
      {showAction && (
      <div className="flex w-44 shrink-0 items-center justify-end gap-0.5">
        <div className="flex items-center gap-0.5 opacity-0 transition-opacity group-hover/row:opacity-100">
          {onDelete && (
            <IconAction label="Delete (w)" onClick={() => onDelete(task.id)} className="hover:text-red-500">
              <Trash2 className="size-4" />
            </IconAction>
          )}
          {onSnooze && (
            <IconAction label="Snooze (s)" onClick={() => onSnooze(task.id)}>
              <AlarmClock className="size-4" />
            </IconAction>
          )}
        </div>

        {/* Primary: the task's own chat + output, attached right here (agenda-style). Blue = the
            action, both before it runs (Execute) and while it does (Executing); green (--output) =
            an output exists (Open); gray = a neutral follow-up (Chat). */}
        {slot.bot === 'processing' || isExecuting ? (
          <span className="bg-action-muted text-action-muted-foreground inline-flex h-6 shrink-0 items-center gap-1 rounded-full px-3 text-xs font-medium">
            <Loader2 className="h-3 w-3 animate-spin" />
            Executing
          </span>
        ) : slot.artifact ? (
          <button
            type="button"
            onClick={(e) => {
              e.stopPropagation();
              onOpen?.(task);
            }}
            className="bg-output hover:bg-output-hover text-output-foreground inline-flex h-6 shrink-0 cursor-pointer items-center gap-1 rounded-full px-3 text-xs font-medium transition-colors"
          >
            {slot.artifact === 'open-draft' ? (
              <GmailColor className="h-3 w-3" />
            ) : slot.artifact === 'open-message' ? (
              <SlackLogo className="h-3 w-3" />
            ) : (
              <Calendar className="h-3 w-3" />
            )}
            {slot.artifact === 'open-invite' ? 'Open invite' : 'Open draft'}
          </button>
        ) : slot.bot === 'finished' && chatThreadId ? (
          <button
            type="button"
            onClick={(e) => {
              e.stopPropagation();
              onOpenChat?.(chatThreadId);
            }}
            className="bg-sunken text-muted-foreground hover:bg-muted hover:text-foreground inline-flex h-6 shrink-0 cursor-pointer items-center gap-1 rounded-full px-3 text-xs font-medium transition-colors"
          >
            <Bot className="h-3 w-3" />
            Chat
          </button>
        ) : onExecute ? (
          <button
            type="button"
            onClick={(e) => {
              e.stopPropagation();
              onExecute(task);
            }}
            className="bg-action text-action-foreground inline-flex h-6 shrink-0 cursor-pointer items-center gap-1 rounded-full px-3 text-xs font-medium opacity-0 transition-opacity group-focus-within/row:opacity-100 group-hover/row:opacity-100"
          >
            <TabBotIcon isProcessing={false} isFinished={false} className="h-3.5 w-3.5" />
            Execute
          </button>
        ) : null}
      </div>
      )}

      {/* Next step date — the last (right-most) FIXED-WIDTH column; wide enough for the longest
          relative label ("Next Thursday") without clipping. */}
      {showDueDate && (
        <div className="hidden w-[104px] shrink-0 items-center justify-end sm:flex">
          {task.dueDate && (
            <RelativeDateBadge
              date={task.dueDate}
              colorType="scheduled"
              className="shrink-0 whitespace-nowrap"
            />
          )}
        </div>
      )}
    </div>
  );
});