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