open-task.ts5.5 KBView on GitHub
/**
 * Opening a task's context.
 *
 * A task that PRODUCED something opens that output where the output actually lives: an email
 * draft opens its thread, a Slack message opens its channel. Only a task that produced nothing
 * opens the TASK — its own ticket (TaskTicketView), which renders the task, the deal it belongs
 * to, the thread it is about, and Execute.
 *
 * A task with no output used to open its CONVERSATION instead, landing the user in the deal's
 * inbox. That was a workaround for the ticket being near-empty at the time — one card floating in
 * the centre column — and it read as the wrong thing entirely: a deal view, with the task the user
 * had just clicked named nowhere on it, and no way to tell which of the two lists in front of them
 * was the thing to act on. Now that the ticket holds the task's own content, the task is what
 * opens.
 *
 * The thread case is URL-driven — it writes the `threadOpen` param that LayoutUrlSync reads — so it
 * needs the caller's `setThreadOpen` rather than navigating itself; the ticket case is
 * store-driven via `openTaskOutput`.
 *
 * Extracted from TaskGroupDetailSidebar so the sidebar, the kanban card, and the board share one
 * definition of "open this task".
 */

/**
 * Whether a Slack task's channel reference can actually be opened.
 *
 * Slack channel ids start `C` (channel) or `D` (DM). A value that does not is a channel NAME
 * that reached the field by mistake, and there is no way to resolve it to an id client-side.
 */
function isOpenableSlackChannel(
  channelId: string | undefined,
  workspaceId: string | undefined,
): channelId is string {
  return !!channelId && !!workspaceId && /^[CD]/.test(channelId);
}

/** The minimum a task needs to expose for us to know what to open. */
export interface OpenableTask {
  id: string;
  conversationId: string | null;
  /**
   * Whether the task is finished. Only the Slack branch reads it, and only to decide whether the
   * drafted message is still a DRAFT: sending it completes the task but leaves the text on
   * `taskActionData`, so without this a done task re-seeds an already-posted message into the
   * composer, one Enter from a duplicate. Absent means "not known to be sent" — the previous
   * behaviour, and the right default for a caller that does not carry the column.
   */
  status?: string | null;
  taskActionData?: {
    channel?: string;
    threadId?: string;
    message?: string;
    /** A Slack task's channel — the same key the unibox addresses a channel by (`?slack=`). */
    channelId?: string;
    channelName?: string;
    /** Needed to READ the channel: the messages query is keyed by workspace + channel. */
    workspaceId?: string;
  } | null;
}

export interface OpenTaskDeps {
  /** Store action linking a thread to its conversation before the thread renders. */
  updateThreadConversationId: (threadId: string, conversationId: string) => void;
  /** `useQueryState('threadOpen')` setter. */
  setThreadOpen: (threadId: string) => void | Promise<unknown>;
  /**
   * Opens a Slack channel in the main column, the way `setThreadOpen` opens an email thread:
   * one artifact write, one derived flag, one view. The optional draft is seeded into that
   * channel's composer first, so the message the task produced is there to read and send.
   */
  openSlackChannel: (target: {
    conversationId: string | null;
    channelId: string;
    channelName?: string;
    /** The channel cannot be READ without it — the messages query is keyed by both. */
    workspaceId?: string;
    message?: string;
    /** The message is already on Slack, so it must not be seeded back into the composer. */
    alreadySent?: boolean;
  }) => void;
  /**
   * Opens the task's own ticket (sets the `task` display artifact) — where a task with no output
   * lands, since there is nothing else to show.
   */
  openTaskOutput: (taskId: string) => void;
}

export function openTask(task: OpenableTask, deps: OpenTaskDeps): void {
  const ad = task.taskActionData;
  const threadId = ad && ad.channel === 'email' ? ad.threadId : undefined;

  if (threadId) {
    if (task.conversationId) deps.updateThreadConversationId(threadId, task.conversationId);
    void deps.setThreadOpen(threadId);
    return;
  }
  // A Slack task's output is a message in a channel, so it opens THE CHANNEL — the same view the
  // unibox opens, through the same artifact mechanism an email task opens its thread with. A
  // drafted message rides along and is seeded into that channel's composer.
  //
  // Both a channel ID and a workspace are required, and the id has to look like one. Measured on
  // prod: of 757 Slack tasks, 45 hold a channel NAME in `channelId` ("ar-meeting-follow-up") and
  // 13 carry no workspace at all. Neither can be read — `inbox.slackChannelMessages` is keyed by
  // workspace + channel id — so opening them would show an empty channel or crash the view.
  // Those fall through to the ticket, which still renders the draft and Execute. Better a
  // truthful fallback than a broken channel.
  if (ad?.channel === 'slack' && isOpenableSlackChannel(ad.channelId, ad.workspaceId)) {
    deps.openSlackChannel({
      conversationId: task.conversationId,
      channelId: ad.channelId,
      channelName: ad.channelName,
      workspaceId: ad.workspaceId,
      message: ad.message,
      alreadySent: task.status === 'done',
    });
    return;
  }
  // Everything left has produced nothing — a reminder, a research nudge. The task itself is the
  // only thing there is to show, so it opens its own ticket.
  deps.openTaskOutput(task.id);
}