TaskTicketView.tsx23.4 KBView on GitHub
/**
 * TaskTicketView — a task as its own full-screen ticket, the way a Linear issue reads.
 *
 * Replaces TaskOutputPanel, which had the right mount points and the wrong shape: it owned the
 * entire centre column and spent it on one `max-w-sm` card floated dead centre, so roughly 85%
 * of the surface was empty while `notes`, `taskType`, `taskCreatedBy`, `taskOutput`,
 * `agentExecutionEnabled`, `createdByExecution` and the execution history were all fetched and
 * rendered nowhere. A task that Cedar cannot run — a reminder, a nudge — had nothing to look at.
 *
 * A header row, then TWO columns centred together as a pair. Nothing inside the ticket is
 * divided by a rule — no border under the header, none between the columns, none around the
 * blocks in the body.
 *
 *   Tasks › Post-meeting followups                              3/17 ↑ ↓
 *
 *          Send HubSpot App Marketplace install…    PROPERTIES
 *          description / notes                      Status        Todo
 *          <source thread>                          Due           24 Aug 2026
 *          (Create draft)      done   delete       Lane          ▣ Post-meeting
 *                                                   Output        Slack
 *                                                   Created       by Cedar agent
 *                                                   Conversation  ⌐ Ashkon, Amanda…
 *
 * The breadcrumb is Tasks › lane › this task — the surface you came from, then the lane the
 * task is filed in. It deliberately does NOT lead with the deal: the deal is one property among
 * the others, not the thing that names this page.
 *
 * The header carries only position + prev/next through THIS LANE's open tasks, at the same
 * 28px scale `thread-display.tsx` uses. Marking a task done belongs in the properties column,
 * not in a second place that has to be kept in sync with the first.
 *
 * Rendered by `ActiveViewDisplay` (the /tasks · /mail · /conversations overlay) and by
 * `DisplayArtifactPanel` (the /agent column) whenever `selectedArtifact.kind === 'task'`. It
 * reads its task by id rather than taking it as a prop so both mounts stay trivial.
 */
'use client';

import { ArrowLeft, ChevronDown, ChevronUp, Loader2, Trash2 } from 'lucide-react';
import { Breadcrumb } from '@/components/ui/breadcrumb';
import { TaskOwnerBadge, useForeignTaskOwner } from '@/modules/userTasks/components/TaskOwnerBadge';
import { useOptimisticTaskActions } from '@/modules/userTasks/hooks/use-optimistic-task-actions';
import { TaskTicketProperties } from '@/modules/userTasks/components/TaskTicketProperties';
import { ConversationBadge } from '@/modules/threads/thread/components/ConversationBadge';
import { ThreadDataSync } from '@/modules/threads/thread/components/thread-data-sync';
import { useTaskIsExecuting } from '@/modules/userTasks/hooks/use-task-is-executing';
import { hasTaskOutput } from '@/modules/agentCanvas/utils/agenda-right-slot-state';
import type { ParsedMessage } from '@/modules/threads/threadList/store/threadSlice';
import { Thread } from '@/modules/threads/threadList/threadItem/components/thread';
import { lastTasksLayout } from '@/modules/userTasks/components/TasksLayoutToggle';
import { useExecuteTaskNow } from '@/modules/userTasks/hooks/use-execute-task-now';
import { EditableText } from '@/modules/conversations/components/EditableText';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { TabBotIcon } from '@/components/icons/animated/bot-thinking';
import type { ConversationTaskActionData } from '@/modules/crm/types';
import { SlackLogo } from '@/modules/inbox/components/channel-icons';
import { useCallback, useEffect, useState } from 'react';
import { useTRPC } from '@/providers/query-provider';
import { Button } from '@/components/ui/button';
import { useCedarStore } from '@/modules/store';
import { useQueryState } from 'nuqs';
import { cn } from '@/lib/utils';
import { toast } from 'sonner';

export function TaskTicketView({ taskId }: { taskId: string }) {
  const trpc = useTRPC();
  const clearArtifact = useCedarStore((s) => s.clearArtifact);
  const [, setSelectedTaskId] = useQueryState('task');
  const queryClient = useQueryClient();
  const { sendSlackDraftFromTask, optimisticDeleteTask, optimisticUpdateTaskDescription } =
    useOptimisticTaskActions();
  const executeTaskNow = useExecuteTaskNow();
  const saveNotes = useMutation({
    ...trpc.userTasks.updateTask.mutationOptions(),
    onSuccess: () =>
      void queryClient.invalidateQueries({
        queryKey=[redacted] taskId }),
      }),
    onError: () => toast.error('Could not save the notes'),
  });
  const openThread = useCedarStore((s) => s.openThread);
  const openConversationContext = useCedarStore((s) => s.openConversationContext);
  const [isSendingSlack, setIsSendingSlack] = useState(false);
  const [isGutterHovered, setIsGutterHovered] = useState(false);

  // Closing clears the task artifact (→ back to the list) and drops the highlight, matching how
  // the conversation/thread overlays dismiss.
  const close = useCallback(() => {
    clearArtifact();
    void setSelectedTaskId(null);
  }, [clearArtifact, setSelectedTaskId]);

  // Escape does the same, as it does in those overlays.
  useEffect(() => {
    const handler = (e: KeyboardEvent) => {
      if (e.key !== 'Escape') return;
      close();
    };
    window.addEventListener('keydown', handler);
    return () => window.removeEventListener('keydown', handler);
  }, [close]);

  const { data, isLoading, isError } = useQuery(
    trpc.userTasks.getTaskById.queryOptions({ taskId }),
  );
  const task = data?.task ?? null;
  // Whose task this is when it isn't yours, else null. A hook, so it sits above the early
  // returns below rather than beside the render that consumes it.
  const foreignOwner = useForeignTaskOwner(task?.user);
  // Shared definition of "this task is running", so the button reads the same here as on the
  // board — driven first by the slice's instant flag, then by the run thread's own state.
  const isExecuting = useTaskIsExecuting(
    taskId,
    (task as { chatThreadId?: string | null } | null)?.chatThreadId ?? null,
  );

  // Prev/next over the OPEN tasks in this task's own lane. Unscoped it read "22/839" — a
  // number about the whole account rather than about the queue you are working, and one that
  // makes the arrows meaningless.
  const laneId = (task as { taskGroupId?: string | null } | null)?.taskGroupId ?? null;
  const { data: listData } = useQuery({
    ...trpc.userTasks.listUserTasks.queryOptions({
      status: 'todo',
      ...(laneId ? { taskGroupId: laneId } : {}),
    }),
    enabled: !!task,
  });
  const siblings = ((listData as { tasks?: { id: string }[] } | undefined)?.tasks ?? []).filter(
    (t) => t.id,
  );
  const position = siblings.findIndex((t) => t.id === taskId);
  const goTo = (index: number) => {
    const next = siblings[index];
    if (!next) return;
    void setSelectedTaskId(next.id);
    useCedarStore.getState().setSelectedArtifact({ kind: 'task', id: next.id });
  };

  if (isLoading) {
    return (
      <div className="flex h-full w-full items-center justify-center bg-surface">
        <p className="text-sm text-muted-foreground">Loading task…</p>
      </div>
    );
  }

  // A task can go missing (hard-deleted, or on a conversation this session can't see). The ticket
  // is the whole centre column, so without an explicit way out this state is a dead end for
  // anyone who doesn't know Escape closes it.
  if (isError || !task) {
    return (
      <div className="flex h-full w-full flex-col items-center justify-center gap-3 bg-surface">
        <p className="text-sm text-muted-foreground">Could not load this task.</p>
        <Button variant="outline" size="sm" onClick={close} className="cursor-pointer">
          Go back
        </Button>
      </div>
    );
  }

  const conversationId = task.conversationId ?? null;
  const title = task.description || 'Untitled task';

  // Tasks on a shared org conversation are readable by the whole team; a teammate's task is
  // labelled as theirs below. Sending a draft it already produced is not owner-only, so the Slack
  // draft stays actionable either way.
  const isMine = !foreignOwner;

  // The Slack draft the agent produced, if this task carries one. Shown in full: the whole point
  // of opening a task is to read what would go out before it goes out.
  const slackDraft = task.taskActionData?.channel === 'slack' ? task.taskActionData : null;
  const canSendSlack = !!slackDraft?.message && !!slackDraft.channelId && !!slackDraft.workspaceId;

  // Execute is the task's action only while it has produced nothing — a task with an output is
  // opened, not re-run — and only on your own task.
  const canExecute =
    isMine && !hasTaskOutput(task.taskActionData as ConversationTaskActionData | null);

  const handleSendSlack = async () => {
    if (isSendingSlack || !slackDraft) return;
    setIsSendingSlack(true);
    try {
      await sendSlackDraftFromTask(taskId, slackDraft);
      await Promise.all([
        queryClient.invalidateQueries({
          queryKey=[redacted] taskId }),
        }),
        queryClient.invalidateQueries({ queryKey=[redacted] }),
      ]);
    } catch {
      // sendSlackDraftFromTask already toasted the failure.
    } finally {
      setIsSendingSlack(false);
    }
  };

  // The event whose processing produced this task, flattened out of the creation run. Only an
  // email event can be opened precisely (it carries a thread); anything else lands on the
  // conversation timeline the event lives on, which is the closest thing to a deep link that
  // exists today.
  const execEvent = (
    task as {
      createdByExecution?: {
        event?: {
          id: string;
          title: string | null;
          eventType: string | null;
          emailEvent?: { threadId?: string | null } | null;
        } | null;
      } | null;
    } | null
  )?.createdByExecution?.event;
  const triggeringEvent = execEvent
    ? {
        id: execEvent.id,
        title: execEvent.title,
        eventType: execEvent.eventType,
        threadId: execEvent.emailEvent?.threadId ?? null,
      }
    : null;
  const openTriggeringEvent = triggeringEvent?.threadId
    ? () => openThread(triggeringEvent.threadId!)
    : conversationId
      ? () => openConversationContext(conversationId)
      : undefined;

  const laneName = task.taskGroup?.name ?? 'Misc';
  const laneHref = `/tasks/agenda?group=${task.taskGroup?.id ?? 'misc'}`;

  return (
    <div className="@container flex h-full w-full flex-col overflow-hidden bg-surface">
      {/* Back — the same affordance thread-display and the conversation view carry: the whole
          left margin is the target, lighting up under the pointer, and it collapses to a small
          button in the header on a narrow window where there is no margin to spare. Closing
          returns you to whatever you opened the task from rather than a fixed destination. */}
      <div className="pointer-events-none sticky top-0 z-10 h-0 w-full @max-3xl:hidden">
        <button
          type="button"
          onClick={close}
          onMouseEnter={() => setIsGutterHovered(true)}
          onMouseLeave={() => setIsGutterHovered(false)}
          aria-label="Back"
          className="pointer-events-auto absolute left-0 top-0 h-screen w-[max(2rem,calc((100%-1020px)/2))] cursor-pointer"
        >
          <div
            className={cn(
              'absolute inset-0 bg-gradient-to-r from-[#15803d]/[0.10] to-transparent transition-opacity duration-300 dark:from-[#15803d]/[0.14]',
              isGutterHovered ? 'opacity-100' : 'opacity-0',
            )}
          />
          <ArrowLeft
            className={cn(
              'absolute left-4 top-3 h-4 w-4 transition-opacity duration-200',
              isGutterHovered ? 'opacity-80' : 'opacity-40',
            )}
          />
        </button>
      </div>

      {/* Header — constrained to the same width as the columns below, so the breadcrumb starts
          where the title does and the actions end where the properties do. No bottom border:
          nothing inside a single task is divided by a rule. */}
      <div className="shrink-0">
        <div className="mx-auto flex h-11 w-full max-w-[1020px] items-center gap-1.5 px-8 text-sm">
          {/* Back — the same affordance thread-display and the conversation view carry. It
              closes the ticket rather than navigating, so it returns you to whatever you opened
              the task FROM, not to a fixed destination. */}
          <button
            type="button"
            onClick={close}
            aria-label="Back"
            className="text-muted-foreground hover:bg-muted hover:text-foreground mr-0.5 hidden h-6 w-6 shrink-0 items-center justify-center rounded transition-colors @max-3xl:flex"
          >
            <ArrowLeft className="h-4 w-4" />
          </button>
          {/* Lane only — the task's own title is the h1 directly below, so repeating it here is
              noise that also squeezes the actions on a narrow window. */}
          <Breadcrumb
            className="py-0"
            items={[
              { label: 'Tasks', href: `/tasks/${lastTasksLayout()}` },
              { label: laneName, href: laneHref },
            ]}
          />

          <div className="ml-auto flex shrink-0 items-center gap-1">
            <TaskOwnerBadge owner={foreignOwner} />

            {/* Position + prev/next through this lane's open tasks. Status and the rest of the
                actions live in the properties column — a second place to mark a task done is a
                second place to keep in sync. */}
            {position >= 0 && siblings.length > 1 && (
              <>
                <span className="text-muted-foreground mr-1 text-xs tabular-nums">
                  {position + 1}/{siblings.length}
                </span>
                <button
                  type="button"
                  aria-label="Previous task"
                  disabled={position <= 0}
                  onClick={() => goTo(position - 1)}
                  className="hover:bg-sunken inline-flex h-7 w-7 cursor-pointer items-center justify-center rounded-lg transition-colors disabled:cursor-default disabled:opacity-40 disabled:hover:bg-transparent"
                >
                  <ChevronUp className="h-4 w-4" />
                </button>
                <button
                  type="button"
                  aria-label="Next task"
                  disabled={position >= siblings.length - 1}
                  onClick={() => goTo(position + 1)}
                  className="hover:bg-sunken inline-flex h-7 w-7 cursor-pointer items-center justify-center rounded-lg transition-colors disabled:cursor-default disabled:opacity-40 disabled:hover:bg-transparent"
                >
                  <ChevronDown className="h-4 w-4" />
                </button>
              </>
            )}
          </div>
        </div>
      </div>

      {/* Two columns, centred together as a pair with no rule between them: the body and the
          properties are the same page, not a document with a panel attached to it. */}
      <div className="min-h-0 flex-1 overflow-y-auto">
        <div className="mx-auto flex w-full max-w-[1020px] items-start gap-12 px-8 py-10">
          <div className="min-w-0 flex-1">
            {/* Title and notes are both edited in place with the shared EditableText — no
                borders, no Save button, committed on blur. Multiline because both wrap, but the
                title still finishes on Enter: it is one line even when it takes two. */}
            {isMine ? (
              <EditableText
                value={title}
                ariaLabel="Task description"
                multiline
                commitOnEnter
                underline={false}
                alwaysEditable
                className="text-2xl font-semibold leading-snug text-foreground"
                onSave={(next) =>
                  void optimisticUpdateTaskDescription(taskId, next, conversationId ?? undefined)
                }
              />
            ) : (
              <h1 className="text-2xl font-semibold leading-snug text-foreground">{title}</h1>
            )}

            {/* The deal — the same ConversationBadge the open-thread header carries, so the
                conversation reads and behaves identically wherever you meet it. It takes the
                conversation directly and opens it itself. */}
            {conversationId && (
              <div className="mt-2">
                <ConversationBadge conversationId={conversationId} linkable={false} />
              </div>
            )}

            {/* The task's own context — why it exists, what to say. Editable in place for the
                same reason the title is: it is prose the agent wrote, and correcting it should
                not mean opening something else. Empty prompts rather than rendering nothing, so
                there is somewhere to click. */}
            {isMine ? (
              <div className="mt-6">
                <EditableText
                  value={task.notes ?? ''}
                  ariaLabel="Task notes"
                  multiline
                  underline={false}
                  alwaysEditable
                  placeholder="Add notes…"
                  className={cn(
                    'text-sm leading-relaxed',
                    task.notes ? 'text-foreground' : 'text-muted-foreground',
                  )}
                  onSave={(next) => saveNotes.mutate({ taskId, notes: next })}
                />
              </div>
            ) : (
              task.notes && (
                <p className="mt-6 whitespace-pre-wrap text-sm leading-relaxed text-foreground">
                  {task.notes}
                </p>
              )
            )}

            {/* The thread this task is ABOUT (source_thread_id), when it named one. Rendered
                with the same ThreadDataSync + Thread pair the conversation timeline uses for an
                email row, so it hydrates itself and clicking it opens the full thread — the
                ticket is a way INTO the mail, not a dead end. Absent thread renders nothing at
                all: an empty box would imply mail that does not exist. */}
            {task.sourceThreadId && (
              <div className="mt-6 w-full">
                <ThreadDataSync threadId={task.sourceThreadId} />
                {/* Full width of the body column and tinted rather than outlined, so it lines up
                    with the title and the description instead of sitting in its own box. */}
                <div className="bg-muted/40 w-full overflow-hidden rounded-lg">
                  <Thread
                    message={{ id: task.sourceThreadId } as ParsedMessage}
                    onClick={(message: ParsedMessage) => () =>
                      openThread(message.threadId ?? message.id)
                    }
                    noPadding
                  />
                </div>
              </div>
            )}

            {slackDraft && (
              <div className="bg-muted/40 mt-6 w-full space-y-2 rounded-lg px-3 py-2.5">
                <div className="flex items-center gap-1.5">
                  <SlackLogo className="size-3.5 shrink-0" />
                  <span className="text-sm font-semibold">
                    {slackDraft.channelName
                      ? `#${slackDraft.channelName.replace(/^#/, '')}`
                      : 'Slack'}
                  </span>
                </div>
                {slackDraft.message && (
                  <p className="max-h-64 overflow-y-auto whitespace-pre-wrap text-sm text-foreground">
                    {slackDraft.message}
                  </p>
                )}
                <button
                  type="button"
                  onClick={handleSendSlack}
                  disabled={isSendingSlack || !canSendSlack}
                  className="bg-action text-action-foreground inline-flex h-7 cursor-pointer items-center justify-center rounded-full px-4 text-xs font-medium transition-opacity hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-50"
                >
                  {isSendingSlack ? 'Sending…' : 'Send and mark done'}
                </button>
              </div>
            )}

            {/* The task's actions, on one row: running it on the left, finishing or discarding
                it on the right. Create draft is the task card's button exactly — same pill, same
                action colour, same bot glyph, same Drafting state — so one action does not look
                like two different things depending on where you found the task. */}
            {isMine && (
              <div className="mt-8 flex items-center gap-2">
                {canExecute && (
                  <button
                    type="button"
                    onClick={() =>
                      void executeTaskNow({ taskId, conversationId, description: title })
                    }
                    disabled={isExecuting}
                    className="bg-action text-action-foreground inline-flex h-7 cursor-pointer items-center justify-center gap-1 rounded-full px-4 text-xs font-medium transition-opacity hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-50"
                  >
                    {isExecuting ? (
                      <Loader2 className="h-3 w-3 animate-spin" />
                    ) : (
                      <TabBotIcon isProcessing={false} isFinished={false} className="h-3.5 w-3.5" />
                    )}
                    {isExecuting ? 'Drafting…' : 'Create draft'}
                  </button>
                )}

                <button
                  type="button"
                  aria-label="Delete task"
                  title="Delete task"
                  onClick={() => {
                    void optimisticDeleteTask(taskId);
                    close();
                  }}
                  className="text-muted-foreground group ml-auto inline-flex h-7 w-7 cursor-pointer items-center justify-center rounded-lg transition-colors hover:bg-[#FDE4E9] hover:text-[#F43F5E] dark:hover:bg-[#411D23]"
                >
                  <Trash2 className="h-3.5 w-3.5" />
                </button>
              </div>
            )}

            {!isMine && (
              <p className="mt-6 text-sm text-muted-foreground">
                {slackDraft
                  ? `${foreignOwner?.name || 'A teammate'}'s task — sending it from here goes out as you and marks it done.`
                  : `${foreignOwner?.name || 'A teammate'}'s task — only they can run it.`}
              </p>
            )}
          </div>

          <TaskTicketProperties
            task={{
              id: taskId,
              status: task.status,
              dueDate: task.dueDate ?? null,
              taskGroup: task.taskGroup ?? null,
              taskOutput: task.taskOutput ?? null,
              taskCreatedBy: task.taskCreatedBy ?? null,
              createdAt: task.createdAt ?? null,
              conversationId,
              executions: task.executions ?? [],
              triggeringEvent,
            }}
            // A teammate's task is read-only here: all four mutations are owner-scoped server side,
            // so offering the controls would only produce a failed write.
            editable={isMine}
            onOpenTriggeringEvent={triggeringEvent ? openTriggeringEvent : undefined}
          />
        </div>
      </div>
    </div>
  );
}