use-optimistic-conversation-actions.ts19.7 KBView on GitHub /**
* Optimistic Conversation Actions Hook
*
* Provides optimistic update functions for conversation operations:
* 1. Immediately update conversationsSlice (optimistic UI update)
* 2. Call server mutation
* 3. Invalidate queries to trigger CRMDataSync refetch
* 4. On error, refetch will restore correct state
*
* Pattern inspired by use-optimistic-actions.ts but for conversation operations.
*/
import { triggerExecuteFromClientSend } from '@/modules/conversations/utils/triggerExecuteFromClientSend';
import { describeConversationUpdateError } from '../lib/conversation-update-error';
import {
clearConversationFieldsPending,
markConversationFieldsPending,
} from '../lib/pending-conversation-field-writes';
import type { HydratedConversation, WorkingMemoryEntry } from '../types';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useTRPC } from '@/providers/query-provider';
import { useCedarStore } from '@/modules/store';
import { useCallback } from 'react';
import posthog from 'posthog-js';
import { toast } from 'sonner';
export function useOptimisticConversationActions() {
const trpc = useTRPC();
const queryClient = useQueryClient();
// Get conversationsSlice mutation functions
const setConversations = useCedarStore((state) => state.setConversations);
// Get server mutation functions
const { mutateAsync: updateConversationServer } = useMutation(
trpc.crm.updateConversation.mutationOptions(),
);
const { mutateAsync: deleteConversationServer } = useMutation(
trpc.crm.deleteConversation.mutationOptions(),
);
const { mutateAsync: upsertWorkingMemoryServer } = useMutation(
trpc.crm.upsertWorkingMemory.mutationOptions(),
);
/**
* Optimistically update a conversation field
*/
const optimisticUpdateConversation = useCallback(
async (
conversationId: string,
updates: {
name?: string | null;
aopId?: string | null;
status?: string | null;
priority?: string | null;
nextSteps?: string | null;
nextStepDate?: Date | null;
statusOverview?: string | null;
notes?: string | null;
dealValue?: number | null;
important?: boolean;
},
) => {
if (!conversationId) return;
// Get current conversation from conversationsSlice
const currentConversation = useCedarStore.getState().conversations[conversationId];
if (!currentConversation) {
console.warn('[Conversations] Cannot update non-existent conversation:', conversationId);
return;
}
// Capture old values before updating (for agent execution)
const oldNextSteps = currentConversation.data.conversation.nextSteps;
const oldNextStepDate = currentConversation.data.conversation.nextStepDate;
// Hold these values above any server payload until the write has settled. The
// conversation the UI renders comes from a Zustand mirror with many writers, and a
// `listConversations` page fetched before this click still carries the old row — so
// the optimistic value cannot live only in the write below, it has to be re-applied
// on every ingest. See lib/pending-conversation-field-writes.ts.
markConversationFieldsPending(conversationId, updates);
const conversationQueryKey=[redacted] id: conversationId });
// 1. Optimistic update: Update conversationsSlice immediately. Before any await —
// this is the paint the user is waiting on, and the mask above already protects it
// from whatever lands while the rest of this runs.
const optimisticConversation: HydratedConversation = {
...currentConversation.data,
conversation: {
...currentConversation.data.conversation,
...updates,
// `crm_conversations.name` is NOT NULL — a null here means "leave it alone".
name: updates.name ?? currentConversation.data.conversation.name,
updatedAt: new Date(), // Update timestamp optimistically
},
};
setConversations({ [conversationId]: optimisticConversation });
// A `getConversation` fetch already in flight resolves with pre-edit data and would
// overwrite the cache patch below — which `ConversationView` and `ThreadContextCards`
// read straight out of, without passing through the mask. Cancel it; the invalidation
// after the mutation re-fetches with the committed row. Deliberately NOT applied to
// the `listConversations` prefix: cancelling that mid-pagination drops a page the
// infinite scroll has already committed to.
await queryClient.cancelQueries({ queryKey=[redacted] });
// Also update React Query cache to prevent ConversationDataSync from overwriting with stale data
queryClient.setQueryData(conversationQueryKey, optimisticConversation);
// 2. Call server
try {
const result = await updateConversationServer({
id: conversationId,
...updates,
});
posthog.capture('crm_conversation_updated');
// Reassigning the AOP changes every AOP-derived surface (type badge, status/
// priority enum options, custom field definitions, and the Strategic Overview
// layout). The optimistic snapshot only carried the new aopId, so the cached
// conversation + field-definition queries still describe the OLD AOP — that
// mismatch is what makes the view flicker/render stale right after assignment.
// Invalidate them so the server's AOP-consistent data loads cleanly.
if ('aopId' in updates) {
// The AOP refresh that follows is detached from this response (see the
// 60s-timeout fix in apps/server/docs/bug-reports/aop-change-60s-cloudfront-timeout.md),
// so the fields keep settling for ~30s afterwards via the invalidation below.
// The runId handed to the store is what makes that wait legible.
//
// Hand the background refresh's runId to the store so the conversation shows
// "Agent updating fields…" until that execution finishes (see
// useAopRefreshWatcher). Absent when the server couldn't open the execution —
// the refresh still runs, we just can't show progress for it.
if (result.aopRefresh?.runId) {
useCedarStore
.getState()
.setAopRefreshRun(conversationId, result.aopRefresh.runId);
}
await Promise.all([
queryClient.invalidateQueries({ queryKey=[redacted] }),
queryClient.invalidateQueries({
queryKey=[redacted],
}),
]);
}
// Fields the LIST is filtered or grouped on, decided server-side: `status` /
// `priority` go into listConversations as `excludedStatus` / `excludedPriority`,
// and `aopId` goes in as `aopIds` — and the conversation inbox additionally
// GROUPS its rows by `aopId` read out of the store. Without re-running the query
// nothing re-decides membership: marking a deal Closed Lost under "Exclude Closed
// Lost" left it sitting in the pipeline, and re-assigning an AOP left every cached
// page still holding the old one, ready to write it back over the change the next
// time one of the four list hooks pushed into the store.
if ('status' in updates || 'priority' in updates || 'aopId' in updates) {
await queryClient.invalidateQueries({
queryKey=[redacted],
});
}
// Trigger agent execution after successful server update for nextSteps or nextStepDate changes
const hasNextStepsKey=[redacted] in updates;
const hasNextStepDateKey=[redacted] in updates;
const nextStepsChanged = hasNextStepsKey && updates.nextSteps !== oldNextSteps;
const nextStepDateChanged =
hasNextStepDateKey && String(updates.nextStepDate) !== String(oldNextStepDate);
if (nextStepsChanged || nextStepDateChanged) {
// Build descriptive event summary with before/after values
const oldConv = currentConversation.data.conversation;
let eventSummary = 'User edited conversation fields:\n';
if (nextStepsChanged) {
const oldValue = oldConv.nextSteps || '(empty)';
const newValue = updates.nextSteps || '(empty)';
eventSummary += `- Next Steps changed from: "${oldValue}" to: "${newValue}"\n`;
}
if (nextStepDateChanged) {
const oldDate = oldConv.nextStepDate
? oldConv.nextStepDate instanceof Date
? oldConv.nextStepDate.toISOString()
: String(oldConv.nextStepDate)
: '(no date)';
const newDate = updates.nextStepDate
? updates.nextStepDate instanceof Date
? updates.nextStepDate.toISOString()
: String(updates.nextStepDate)
: '(no date)';
eventSummary += `- Next Step Date changed from: ${oldDate} to: ${newDate}\n`;
}
triggerExecuteFromClientSend({
conversationId,
triggerSource: 'conversation_update',
eventSummary,
}).catch((err) => {
console.warn('[Conversations] Failed to trigger next steps update:', err);
});
}
} catch (error) {
// Release the mask FIRST. It exists to keep server truth from winning while the
// write settles; the write failed, so server truth is the truth again and the
// refetches below have to be allowed through — otherwise the row the user was
// told did not save would keep showing the value that did not save.
clearConversationFieldsPending(conversationId, Object.keys(updates));
// On error, invalidate to trigger refetch (will restore correct state)
await queryClient.invalidateQueries({
queryKey=[redacted],
});
// Also invalidate single conversation query
await queryClient.invalidateQueries({
queryKey=[redacted],
});
const errorMessage = describeConversationUpdateError(error, Object.keys(updates));
toast.error(errorMessage);
console.error('[Conversations] Error updating conversation:', {
conversationId,
fields: Object.keys(updates),
error,
});
posthog.capture('crm_conversation_update_failed', {
fields: Object.keys(updates),
message: errorMessage,
});
}
},
[setConversations, updateConversationServer, queryClient, trpc],
);
/**
* Optimistically update conversation status
*/
const optimisticUpdateStatus = useCallback(
async (conversationId: string, status: string) => {
return optimisticUpdateConversation(conversationId, { status });
},
[optimisticUpdateConversation],
);
/**
* Optimistically update conversation priority
*/
const optimisticUpdatePriority = useCallback(
async (conversationId: string, priority: string) => {
return optimisticUpdateConversation(conversationId, { priority });
},
[optimisticUpdateConversation],
);
/**
* Optimistically update conversation next steps
*/
const optimisticUpdateNextSteps = useCallback(
async (conversationId: string, nextSteps: string) => {
return optimisticUpdateConversation(conversationId, { nextSteps });
},
[optimisticUpdateConversation],
);
/**
* Optimistically update conversation next step date
*/
const optimisticUpdateNextStepDate = useCallback(
async (conversationId: string, nextStepDate: Date | null) => {
return optimisticUpdateConversation(conversationId, { nextStepDate });
},
[optimisticUpdateConversation],
);
/**
* Optimistically update conversation notes
*/
const optimisticUpdateNotes = useCallback(
async (conversationId: string, notes: string) => {
return optimisticUpdateConversation(conversationId, { notes });
},
[optimisticUpdateConversation],
);
/**
* Optimistically update a custom field (working memory entry)
*/
const optimisticUpdateCustomField = useCallback(
async (conversationId: string, fieldName: string, fieldValue: string) => {
if (!conversationId) return;
// Get current conversation from conversationsSlice
const currentConversation = useCedarStore.getState().conversations[conversationId];
if (!currentConversation) {
console.warn(
'[Conversations] Cannot update custom field for non-existent conversation:',
conversationId,
);
return;
}
// 1. Optimistic update: Update conversationsSlice immediately
const existingCustomFields = currentConversation.data.customFields || [];
const existingFieldIndex = existingCustomFields.findIndex((f) => f.name === fieldName);
let optimisticCustomFields: WorkingMemoryEntry[];
if (existingFieldIndex >= 0) {
// Update existing field
optimisticCustomFields = existingCustomFields.map((field, idx) =>
idx === existingFieldIndex
? {
...field,
value: fieldValue,
lastEdited: new Date(),
updatedAt: new Date(),
}
: field,
);
} else {
// Add new field (create minimal entry - server will return full entry)
const newField: WorkingMemoryEntry = {
id: `temp-${Date.now()}`, // Temporary ID, server will assign real one
conversationId,
name: fieldName,
value: fieldValue,
agentExecutionId: null,
editedBy: 'user',
lastEdited: new Date(),
createdAt: new Date(),
updatedAt: new Date(),
signal: null,
signalReasoning: null,
};
optimisticCustomFields = [...existingCustomFields, newField];
}
const optimisticConversation: HydratedConversation = {
...currentConversation.data,
customFields: optimisticCustomFields,
conversation: {
...currentConversation.data.conversation,
},
};
setConversations({ [conversationId]: optimisticConversation });
// Also update React Query cache to prevent ConversationDataSync from overwriting with stale data
const conversationQueryKey=[redacted] id: conversationId });
queryClient.setQueryData(conversationQueryKey, optimisticConversation);
// 2. Call server
try {
await upsertWorkingMemoryServer({
conversationId,
name: fieldName,
value: fieldValue,
});
posthog.capture('crm_custom_field_updated');
} catch (error) {
// On error, invalidate to trigger refetch (will restore correct state)
await queryClient.invalidateQueries({
queryKey=[redacted],
});
// Also invalidate single conversation query
await queryClient.invalidateQueries({
queryKey=[redacted],
});
toast.error('Failed to update custom field');
console.error('[Conversations] Error updating custom field:', error);
}
},
[setConversations, upsertWorkingMemoryServer, queryClient, trpc],
);
/**
* Optimistically delete a conversation
* Note: We don't remove from conversationsSlice immediately since it doesn't have a remove method.
* Instead, we rely on the refetch to update the store.
*/
const optimisticDeleteConversation = useCallback(
async (conversationId: string) => {
if (!conversationId) return;
// Clear active conversation if this one was active
const activeId = useCedarStore.getState().activeConversationId;
if (activeId === conversationId) {
useCedarStore.getState().setActiveConversationId(null);
}
// 1. Call server directly (no optimistic remove since conversationsSlice doesn't support it)
try {
await deleteConversationServer({ id: conversationId });
// 2. Non-blocking invalidation - CRMDataSync will refetch and update stores
queryClient.invalidateQueries({
queryKey=[redacted],
});
posthog.capture('crm_conversation_deleted');
} catch (error) {
// On error, just show error (no need to revert since we didn't update optimistically)
toast.error('Failed to delete conversation');
console.error('[Conversations] Error deleting conversation:', error);
}
},
[deleteConversationServer, queryClient, trpc],
);
/**
* Optimistically update multiple conversations at once
*/
const optimisticBulkUpdate = useCallback(
async (
conversationIds: string[],
updates: {
status?: string;
priority?: string;
},
) => {
if (!conversationIds.length) return;
// Get current conversations from conversationsSlice
const conversations = useCedarStore.getState().conversations;
const optimisticConversations: Record<string, HydratedConversation> = {};
conversationIds.forEach((id) => {
const current = conversations[id];
if (current) {
optimisticConversations[id] = {
...current.data,
conversation: {
...current.data.conversation,
...updates,
updatedAt: new Date(),
},
};
}
});
// Same mask as the single-conversation path — a bulk status change is the case most
// likely to race a list refetch, because it is fired from the list itself.
for (const id of conversationIds) markConversationFieldsPending(id, updates);
// 1. Optimistic update: Update conversationsSlice immediately
setConversations(optimisticConversations);
// Also update React Query cache for each conversation to prevent ConversationDataSync from overwriting
conversationIds.forEach((id) => {
const optimisticData = optimisticConversations[id];
if (optimisticData) {
const conversationQueryKey=[redacted] id });
queryClient.setQueryData(conversationQueryKey, optimisticData);
}
});
// 2. Call server for each conversation
try {
await Promise.all(
conversationIds.map((id) =>
updateConversationServer({
id,
...updates,
}),
),
);
posthog.capture('crm_bulk_update', { count: conversationIds.length });
} catch (error) {
// Release the masks before refetching — see the single-conversation path.
for (const id of conversationIds) {
clearConversationFieldsPending(id, Object.keys(updates));
}
// On error, invalidate to trigger refetch
await queryClient.invalidateQueries({
queryKey=[redacted],
});
// Also invalidate individual conversation queries
conversationIds.forEach((id) => {
queryClient.invalidateQueries({
queryKey=[redacted] id }),
});
});
toast.error('Failed to update conversations');
console.error('[Conversations] Error bulk updating conversations:', error);
}
},
[setConversations, updateConversationServer, queryClient, trpc],
);
return {
optimisticUpdateConversation,
optimisticUpdateStatus,
optimisticUpdatePriority,
optimisticUpdateNextSteps,
optimisticUpdateNextStepDate,
optimisticUpdateNotes,
optimisticUpdateCustomField,
optimisticDeleteConversation,
optimisticBulkUpdate,
};
}