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