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

/**
 * TaskLine — a task's text, rendered the one way it is rendered everywhere.
 *
 *     Bolded concise headline [deal] — the rest of the sentence
 *
 * The split comes from `parseTaskLine`; this component only paints it. The deal badge
 * sits INSIDE the line, between the headline and the dash, because that is where it
 * reads as part of the sentence ("Agenda for the 4:30 with Simon [Acme] — you told him
 * you'd send the write-up") rather than as a column of chrome bolted onto the left of
 * the list.
 *
 * Every non-editor task surface renders this: the task list, the kanban card, the
 * conversation's own task rows, the timeline card. The agenda's editor row cannot — its
 * text is contenteditable and ProseMirror owns it — and paints the same split with
 * inline decorations instead (see TaskLineDecorations), with the badge at the head of
 * the row, since a widget in the middle of editable text would fight the caret.
 */

import { Building2 } from 'lucide-react';

import { cn } from '@/lib/utils';
import { ConversationChipContent } from '@/modules/agentCanvas/extensions/ConversationChip';
import { openConversationFromAgenda } from '@/modules/agentCanvas/utils/open-conversation';
import { REF_CHIP_CLASS } from '@/modules/documents/chip-styles';
import { parseTaskLine } from '@/modules/userTasks/utils/task-line';

/** Just enough of a conversation to paint a badge without resolving one. */
export interface TaskLineConversation {
  name?: string | null;
  companyName?: string | null;
  logoUrl?: string | null;
}

/** The badge's geometry inside a line of prose — smaller than a standalone chip. */
const INLINE_CHIP_CLASS = 'mb-[-2px] py-0 text-xs';

interface TaskLineProps {
  description: string | null | undefined;
  /** When set, the deal badge renders inline after the headline. */
  conversationId?: string | null;
  /**
   * The deal, when the surface already has it. Surfaces that list many tasks pass this
   * so the badge paints from data they already fetched instead of resolving one lookup
   * per row.
   */
  conversation?: TaskLineConversation | null;
  /** Overrides the badge's default open behaviour. */
  onOpenConversation?: (conversationId: string) => void;
  done?: boolean;
  /** Single-line surfaces truncate; cards and headers wrap. */
  truncate?: boolean;
  /** Clamp to two lines when wrapping (the narrow kanban / sidebar cards). */
  clamp?: boolean;
  /** Surfaces that edit the description in place swap themselves in on click. */
  onClick?: () => void;
  className?: string;
}

function InlineDealBadge({
  conversationId,
  conversation,
  onOpen,
}: {
  conversationId: string;
  conversation?: TaskLineConversation | null;
  onOpen?: (conversationId: string) => void;
}) {
  // No pre-resolved deal — fall back to the shared chip, which looks it up (and shows a
  // skeleton, then a missing state, while it does).
  if (!conversation) {
    return (
      <ConversationChipContent
        conversationId={conversationId}
        onOpen={onOpen}
        className={INLINE_CHIP_CLASS}
      />
    );
  }

  const label = conversation.companyName || conversation.name || 'Conversation';
  return (
    <button
      type="button"
      data-conversation-badge={conversationId}
      title={label}
      onClick={(event) => {
        event.preventDefault();
        event.stopPropagation();
        if (onOpen) onOpen(conversationId);
        else openConversationFromAgenda(conversationId);
      }}
      className={cn(REF_CHIP_CLASS, INLINE_CHIP_CLASS, 'cursor-pointer')}
    >
      {conversation.logoUrl ? (
        <img src={conversation.logoUrl} alt="" className="h-3 w-3 shrink-0 rounded-full object-contain" />
      ) : (
        <Building2 className="text-muted-foreground h-3 w-3 shrink-0" />
      )}
      <span className="truncate">{label}</span>
    </button>
  );
}

export function TaskLine({
  description,
  conversationId,
  conversation,
  onOpenConversation,
  done = false,
  truncate = false,
  clamp = false,
  onClick,
  className,
}: TaskLineProps) {
  const { headline, detail, emphasized } = parseTaskLine(description);

  return (
    <span
      onClick={onClick}
      className={cn(
        'min-w-0 text-sm',
        truncate ? 'block truncate' : 'block break-words',
        clamp && 'line-clamp-2',
        done && 'text-muted-foreground line-through decoration-muted-foreground/40',
        className,
      )}
    >
      <span className={cn(!done && emphasized && 'font-semibold text-foreground')}>
        {headline || 'Untitled task'}
      </span>
      {conversationId && (
        <InlineDealBadge
          conversationId={conversationId}
          conversation={conversation}
          onOpen={onOpenConversation}
        />
      )}
      {detail && (
        <span className={cn(!done && 'text-muted-foreground')}>
          {' — '}
          {detail}
        </span>
      )}
    </span>
  );
}