resolve-tasks-on-send.ts12.1 KBView on GitHub /**
* Close the tasks a send just satisfied — on the client, at click time, everywhere at once.
*
* ## The bug this exists for
*
* Sending a Cedar-drafted email closes its task server-side inside the send request
* (`completeTaskByDraftId`, fired from `mail.send` / `drafts.send` / `slack.sendMessage`) and
* again from `handleExecuteFromClientSend`, which sweeps the thread and streams a `taskCompleted`
* back. Both are correct and both are LATE: the sweep only starts once the client fires its
* follow-up call, and the stream event lands seconds after the composer has already closed. In
* between, the user is looking at a sidebar that still lists the task they just did.
*
* The stream event also did not reach every surface. `taskCompletedProcessor` writes the Cedar
* store, but the empty chat's Tasks card (ThreadContextCards) renders straight off the
* `crm.getConversation` QUERY — nothing in the store path touches it, so its "3 tasks" count sat
* unchanged until something else happened to invalidate that query.
*
* ## What this does instead
*
* One entry point, called by every surface that sends something. It asks
* `tasksSatisfiedBySend` — a mirror of the server's own completion predicates — which open tasks
* this send closes, then applies that answer to EVERY place the client keeps tasks:
*
* 1. the pending-resolution mask, so no in-flight refetch can put the row back;
* 2. `userTasksSlice.tasks` + the date buckets (task list, kanban, execution rail);
* 3. `conversations[id].data.userTasks` (the conversation timeline and Strategic Overview);
* 4. the React Query caches, via task-status-cache.ts.
*
* (3) and (4) are separate on purpose: they are separate reads. Patching only the store is what
* left the Tasks card stale, and patching only the cache would leave the timeline stale.
*
* ## Why it is safe to be early
*
* `tasksSatisfiedBySend` closes exactly the rows the server is about to close and nothing else,
* so the mask below is not a lie waiting to be found out — it is the same answer, sooner. It is
* dropped after `SETTLE_MS` once the task queries have been refetched, at which point server
* truth speaks for itself. If the send is undone or fails, `rollback()` puts everything back.
*
* Deliberately a plain module function rather than a hook: the call sites are mutation callbacks
* and undo-send continuations, neither of which can host one.
*/
import {
clearTaskResolutionPending,
markTaskResolutionPending,
} from '@/modules/userTasks/lib/pending-task-resolutions';
import {
tasksSatisfiedBySend,
type SendArtifact,
type SendMatchableTask,
} from '@/modules/userTasks/lib/task-send-match';
import {
adjustCachedOpenTaskCounts,
cachedTaskRows,
setCachedTaskStatus,
} from '@/modules/userTasks/lib/task-status-cache';
import { getBrowserQueryClient } from '@/lib/browser-query-client';
import { useCedarStore } from '@/modules/store';
export type { SendArtifact } from '@/modules/userTasks/lib/task-send-match';
/**
* How long the optimistic close is held before server truth is asked for again.
*
* Long enough to cover the slowest of the server paths: `mail.send`'s fire-and-forget
* `completeTaskByDraftId`, and `handleExecuteFromClientSend`, which the client only starts after
* the send resolves and which fetches a conversation and an AOP before it gets to the sweep. Too
* short and the refetch lands while the row is still `todo` and the task blinks back; the cost of
* too long is only that a task the server declined to close stays hidden a few extra seconds.
*/
const SETTLE_MS = 20_000;
/** The handle a caller keeps so an undone or failed send can be put back. */
export interface SendResolutionHandle {
/** Ids closed by this send. Empty when the send satisfied nothing — the common case. */
taskIds: string[];
/** Restore every task this call closed. Safe to call twice; a no-op after the settle. */
rollback: () => void;
}
const NOOP_HANDLE: SendResolutionHandle = { taskIds: [], rollback: () => {} };
/**
* Every task the browser currently holds, from all four sources, first copy per id wins.
*
* All four are searched rather than just the slice because the surfaces do not agree on what
* they have loaded: a task can sit in the conversation query with the slice never having listed
* it (you opened one thread and never visited /tasks), or the reverse. Matching against the union
* is what makes this work across every task source instead of only the one on screen.
*/
function collectCandidateTasks(): SendMatchableTask[] {
const byId = new Map<string, SendMatchableTask>();
const add = (task: unknown) => {
const row = task as SendMatchableTask | null;
if (row?.id && !byId.has(row.id)) byId.set(row.id, row);
};
const state = useCedarStore.getState();
Object.values(state.tasks ?? {}).forEach(add);
Object.values(state.conversations ?? {}).forEach((entry) =>
(entry?.data?.userTasks ?? []).forEach(add),
);
cachedTaskRows().forEach(add);
return [...byId.values()];
}
/** Flip a task's status in every store and cache that holds a copy of it. */
function writeStatus(taskIds: ReadonlySet<string>, status: 'todo' | 'done'): void {
const done = status === 'done';
const state = useCedarStore.getState();
// 1. The slice, which every task list / kanban / execution-rail surface renders from.
for (const taskId of taskIds) {
if (!state.tasks?.[taskId]) continue;
state.updateTask(taskId, {
status,
completedAt: done ? new Date() : null,
updatedAt: new Date(),
});
}
// 2. The date buckets. `useTodoTasks` filters on status, but the execution rail reads the
// bucket lists directly, so a closed task left in a bucket keeps its slot on the rail.
// One-way: rollback re-hydrates the buckets from the caller's snapshot instead, because the
// original position cannot be recovered from the task alone.
if (done) {
const buckets = useCedarStore.getState().taskIdsByDateKey;
let changed = false;
const next = { ...buckets };
for (const key of Object.keys(next) as Array<keyof typeof next>) {
const ids = next[key];
if (!ids?.some((id) => taskIds.has(id))) continue;
next[key] = ids.filter((id) => !taskIds.has(id));
changed = true;
}
if (changed) useCedarStore.getState().setTaskIdsByDateKey(next);
}
// 3. The conversation store, which the timeline and Strategic Overview render from.
const conversationPatch: Record<string, (typeof state.conversations)[string]['data']> = {};
for (const [conversationId, entry] of Object.entries(state.conversations ?? {})) {
const userTasks = entry?.data?.userTasks;
if (!userTasks?.some((t) => taskIds.has(t.id))) continue;
conversationPatch[conversationId] = {
...entry.data,
userTasks: userTasks.map((t) => (taskIds.has(t.id) ? { ...t, status } : t)),
} as (typeof state.conversations)[string]['data'];
}
if (Object.keys(conversationPatch).length > 0) state.setConversations(conversationPatch);
// 4. The React Query caches. ThreadContextCards' Tasks card reads `crm.getConversation`
// directly and nothing above reaches it — this is the write that makes the count drop.
setCachedTaskStatus(taskIds, status);
}
/**
* Put closed tasks back on the rail at the index they were removed from.
*
* Re-inserting by index rather than appending because the buckets carry the user's manual order —
* a restored task that lands at the bottom of "Today" reads as a different task.
*/
function restoreDateBucketSlots(
slotsByTask: ReadonlyMap<string, Array<{ key=[redacted]; index: number }>>,
): void {
if (slotsByTask.size === 0) return;
const buckets = { ...useCedarStore.getState().taskIdsByDateKey } as Record<string, string[]>;
let changed = false;
for (const [taskId, slots] of slotsByTask) {
for (const { key, index } of slots) {
const ids = buckets[key] ?? [];
if (ids.includes(taskId)) continue;
const next = [...ids];
next.splice(Math.min(index, next.length), 0, taskId);
buckets[key] = next;
changed = true;
}
}
if (changed) {
useCedarStore.getState().setTaskIdsByDateKey(buckets as never);
}
}
/**
* Resolve every open task this send satisfies, immediately.
*
* Call it at the moment the user commits to sending — the same instant the surface paints its
* own optimistic message — not after the mutation resolves. The whole point is that the task
* disappears on the click.
*
* Returns a handle whose `rollback()` undoes the lot; wire it to the same undo / error paths that
* roll back the optimistic message, so the only states a user ever sees are "sent, task closed"
* and "not sent, task open".
*/
export function resolveTasksForSend(artifact: SendArtifact): SendResolutionHandle {
if (!artifact.draftId && !artifact.threadId && !artifact.taskIds?.length) return NOOP_HANDLE;
let candidates: SendMatchableTask[];
try {
candidates = collectCandidateTasks();
} catch (error) {
// A send must never fail because the optimistic pass did. The server still closes the task.
console.error('[resolveTasksForSend] Failed to read task sources:', error);
return NOOP_HANDLE;
}
// Ids the caller named are closed whether or not this browser holds the row: a teammate's task
// on a shared conversation is never in the (own-tasks-only) slice, and the surface that sends
// it fetched it by id. Masking it anyway means the answer is already there if the row arrives
// from a refetch before the server write lands.
const idSet = new Set([
...tasksSatisfiedBySend(candidates, artifact),
...(artifact.taskIds ?? []),
]);
const taskIds = [...idSet];
if (taskIds.length === 0) return NOOP_HANDLE;
const closedPerConversation = new Map<string, number>();
for (const task of candidates) {
if (!idSet.has(task.id) || !task.conversationId) continue;
closedPerConversation.set(
task.conversationId,
(closedPerConversation.get(task.conversationId) ?? 0) + 1,
);
}
// Where each closed task sat on the rail, so rollback can put it back in the same slot.
// Recorded per task rather than as a snapshot of the whole map: restoring a whole snapshot
// would also revert any unrelated bucket edit made during the undo window.
const savedBucketSlots = new Map<string, Array<{ key=[redacted]; index: number }>>();
const bucketsBefore = useCedarStore.getState().taskIdsByDateKey;
for (const [key, ids] of Object.entries(bucketsBefore) as Array<[string, string[] | undefined]>) {
ids?.forEach((id, index) => {
if (!idSet.has(id)) return;
const slots = savedBucketSlots.get(id) ?? [];
slots.push({ key, index });
savedBucketSlots.set(id, slots);
});
}
// The mask FIRST: everything below can trigger a refetch, and the mask is what stops the
// response re-hydrating a row we are in the middle of closing.
for (const taskId of taskIds) markTaskResolutionPending(taskId, 'done');
writeStatus(idSet, 'done');
adjustCachedOpenTaskCounts(closedPerConversation, -1);
let settled = false;
const settleTimer = setTimeout(() => {
settled = true;
const queryClient = getBrowserQueryClient();
const refreshed = queryClient
? Promise.all([
queryClient.invalidateQueries({ queryKey: [['userTasks', 'listUserTasks']] }),
queryClient.invalidateQueries({ queryKey: [['crm', 'getConversation']] }),
queryClient.invalidateQueries({ queryKey: [['crm', 'listConversations']] }),
])
: Promise.resolve();
// Drop the mask only once those refetches have settled. Clearing it first would let the
// in-flight response — still carrying `todo` — put every row straight back.
void refreshed
.catch(() => undefined)
.finally(() => taskIds.forEach(clearTaskResolutionPending));
}, SETTLE_MS);
return {
taskIds,
rollback: () => {
if (settled) return;
settled = true;
clearTimeout(settleTimer);
// Mask first again, in the other direction: it would otherwise re-hide the rows being
// restored the moment anything re-reads them.
taskIds.forEach(clearTaskResolutionPending);
writeStatus(idSet, 'todo');
restoreDateBucketSlots(savedBucketSlots);
adjustCachedOpenTaskCounts(closedPerConversation, 1);
},
};
}