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