canvasSlice.ts46.2 KBView on GitHub
/**
 * Canvas Slice
 *
 * Implements §8.1 of docs/architecture/canvas-architecture.md.
 *
 * Replaces `agentCanvasSlice` — all agent canvas state and actions are now scoped
 * to a specific Canvas.id rather than being a global singleton.
 *
 * Ephemeral canvases (homeViewOrder = null) live only in memory.
 * Persisted canvases are synced from/to the backend via canvasRouter tRPC.
 */

import type {
  AgentRunState,
  Canvas,
  CanvasUIState,
  CanvasViewConfig,
  ConversationEntry,
} from '@/modules/canvas/types/canvas-types';
import type {
  Citation,
  SearchResultPreview,
} from '@/modules/cedar-os/src/store/messages/MessageTypes';
import type { CedarStore } from '@/modules/store/CedarStoreTypes';
import type { StateCreator } from 'zustand';
import { api } from '@/modules/trpc/trpc';
import { syncSavedViewConfigToQueryCache } from '@/modules/canvas/utils/canvas-query-cache';
import { toast } from 'sonner';

// ============================================
// TYPES
// ============================================

export interface ActiveCanvasStream {
  /** Matches the toolCallId from the server so chunks can be routed correctly */
  toolCallId: string;
  canvasId: string;
  mode: 'upsert' | 'append';
  /** Accumulated markdown content so far */
  accumulatedContent: string;
}

// ============================================
// STATE
// ============================================

export interface CanvasSliceState {
  /** All canvases loaded for the current session, keyed by Canvas.id */
  canvasesById: Record<string, Canvas>;

  /**
   * Canvas IDs in the user's HomeView tab bar, in their personal display order.
   * Merged list: owned canvases + shared canvases the user has pinned.
   */
  homeViewCanvasIds: string[];

  /**
   * Ephemeral per-canvas UI state (never persisted to backend).
   * Keyed by Canvas.id.
   */
  canvasUIState: Record<string, CanvasUIState>;

  /**
   * Canvas IDs that exist in memory but have NOT been persisted to the backend.
   * No `canvases` row exists for these yet.
   * - Dismissed → removed from memory, no DB call.
   * - "Save to Home" → createCanvas tRPC, then removed from this set.
   */
  ephemeralCanvasIds: Set<string>;

  /**
   * Canvas IDs whose viewConfig has been modified in-memory by the agent
   * but not yet saved to the backend (for persisted HomeView canvases).
   * Drives the "Save" / "Save as new tab" button visibility.
   */
  dirtyCanvasIds: Set<string>;

  /**
   * In-progress agent canvas stream — non-null while a write-canvas-report tool call
   * is streaming. ReportCanvasView watches this to apply content as it arrives.
   */
  activeCanvasStream: ActiveCanvasStream | null;
}

// ============================================
// ACTIONS
// ============================================

export interface CanvasSliceActions {
  // ── Canvas CRUD ──────────────────────────────────────────────────────────

  /** Add or update a canvas in the store (synced from backend) */
  upsertCanvas: (canvas: Canvas) => void;

  /** Remove a canvas from the store and call deleteCanvas tRPC (owner only) */
  removeCanvas: (canvasId: string) => Promise<void>;

  /** Set the full ordered list of HomeView canvas IDs */
  setHomeViewCanvasIds: (ids: string[]) => void;

  // ── Sharing / HomeView pin (for shared canvases) ─────────────────────────

  /**
   * Pin or unpin a shared canvas from the user's HomeView.
   * Calls canvasRouter.setMyHomeViewPreference.
   */
  setSharedCanvasHomeViewPinned: (canvasId: string, pinned: boolean) => Promise<void>;

  /**
   * Reorder all HomeView canvases for the calling user.
   * Calls canvasRouter.reorderMyHomeView; server splits writes by ownership.
   */
  reorderMyHomeView: (orderedIds: string[]) => Promise<void>;

  // ── Agent writes (scoped to a canvas) ───────────────────────────────────

  /** Set/merge canvas-level agent run state */
  setCanvasAgentRunState: (canvasId: string, state: Partial<AgentRunState>) => void;

  /** Upsert a single conversation entry (deep-merge with existing) */
  upsertConversationEntry: (
    canvasId: string,
    conversationId: string,
    entry: Partial<ConversationEntry>,
  ) => void;

  /** Remove a conversation entry */
  clearConversationEntry: (canvasId: string, conversationId: string) => void;

  // ── Draft review UI ──────────────────────────────────────────────────────

  /** Open the draft review modal for a canvas, optionally starting at a specific conversation */
  openDraftReview: (canvasId: string, conversationId?: string) => void;
  closeDraftReview: (canvasId: string) => void;
  nextDraft: (canvasId: string) => void;
  prevDraft: (canvasId: string) => void;

  /** Mark a draft as sent — removes from queue, updates agentProcessing message */
  markDraftSent: (canvasId: string, conversationId: string) => void;

  /** Reject a draft — removes from queue */
  rejectDraft: (canvasId: string, conversationId: string) => void;

  /** Mark an external integration proposal as accepted */
  markExternalIntegrationProposalAccepted: (canvasId: string, conversationId: string) => void;

  /** Reject an external integration proposal */
  rejectExternalIntegrationProposal: (canvasId: string, conversationId: string) => void;

  // ── Analysis review UI ───────────────────────────────────────────────────

  openAnalysisReview: (canvasId: string, conversationId?: string) => void;
  closeAnalysisReview: (canvasId: string) => void;
  nextAnalysis: (canvasId: string) => void;
  prevAnalysis: (canvasId: string) => void;

  // ── Thread management ────────────────────────────────────────────────────

  /**
   * Set the active chat thread for a canvas.
   * Called when user clicks "+" to add a new thread.
   * selectChatThreadId will now return this threadId for the canvas.
   */
  setCanvasActiveThread: (canvasId: string, threadId: string) => void;

  // ── Ephemeral canvas management (§9.3) ───────────────────────────────────

  /** Add a brand-new in-memory canvas (no backend row yet) */
  upsertEphemeralCanvas: (canvas: Canvas) => void;

  /** Called after createCanvas tRPC succeeds — marks canvas as persisted */
  markCanvasPersisted: (canvasId: string) => void;

  /**
   * Update a canvas's viewConfig in memory (agent modifying an existing canvas).
   * Marks the canvas as dirty. Does NOT call the backend.
   * Snapshot of previous viewConfig is saved to canvasUIState for revert.
   */
  updateCanvasViewConfig: (canvasId: string, viewConfig: CanvasViewConfig) => void;

  /** Update a canvas's title in memory only. Does NOT call the backend. */
  updateCanvasTitle: (canvasId: string, title: string) => void;

  /**
   * Persist dirty viewConfig changes to the backend (Rule A "Save").
   * Calls canvasRouter.updateCanvas({ viewConfig }).
   * On success: clears dirty flag, clears savedViewConfig snapshot, resolves true.
   * On failure: toasts, LEAVES the canvas dirty, and resolves false — it never rejects, because
   * almost every caller fires it as `void saveCanvasViewConfig(id)` and a rejection there is an
   * unhandled one that nobody sees.
   */
  saveCanvasViewConfig: (canvasId: string) => Promise<boolean>;

  /**
   * Revert a dirty canvas to its last persisted viewConfig.
   * Restores from savedViewConfig snapshot. Clears dirty flag.
   */
  revertCanvasViewConfig: (canvasId: string) => void;

  /**
   * Pin an ephemeral canvas to HomeView (Rule C "Save to Home").
   * Calls canvasRouter.createCanvas, marks as persisted, appends to HomeView.
   */
  pinCanvasToHomeView: (canvasId: string) => Promise<void>;

  /**
   * Create a new persisted canvas from the current dirty viewConfig (Rule A "Save as new tab").
   * Calls canvasRouter.createCanvas with the current in-memory viewConfig + a new title.
   * Reverts the source canvas to its original saved viewConfig.
   * Appends new canvas to HomeView.
   */
  saveAsNewTab: (sourceCanvasId: string, title?: string) => Promise<void>;

  /**
   * Update the colour of a canvas (optimistic + persists to backend).
   */
  updateCanvasColour: (canvasId: string, colour: string) => Promise<void>;

  /**
   * Replace a `cardList` canvas's manual pin set (viewConfig.pinnedConversationIds)
   * — used for add / remove / reorder curation. Optimistic + persists the full
   * viewConfig through the existing updateCanvas tRPC mutation.
   */
  setCardListPinnedIds: (canvasId: string, pinnedConversationIds: string[]) => Promise<void>;

  // ── Report canvas streaming ──────────────────────────────────────────────

  /** Set or clear the active canvas stream (report streaming from agent) */
  setActiveCanvasStream: (stream: ActiveCanvasStream | null) => void;

  /** Append a chunk to the active canvas stream's accumulated content */
  appendCanvasStreamChunk: (toolCallId: string, chunk: string) => void;

  // ── Getters (for imperative access, e.g., agent tools) ───────────────────

  getCanvas: (canvasId: string) => Canvas | undefined;
  getConversationEntry: (canvasId: string, conversationId: string) => ConversationEntry | undefined;
  getActiveHomeViewCanvas: () => Canvas | null;

  /**
   * Get the active thread ID for a canvas.
   * Returns canvasUIState[id].activeThreadId ?? canvasId (initial thread convention).
   */
  getActiveCanvasThreadId: (canvasId: string) => string;

  /** Get the computed pending-draft queue for a canvas (sorted by conversationId insertion order) */
  getPendingDraftQueue: (canvasId: string) => string[];

  /** Get the computed pending-analysis queue for a canvas */
  getPendingAnalysisQueue: (canvasId: string) => string[];

  // ── Reasoning actions (subagent reasoning per conversation) ──────────────

  /** Start streaming subagent reasoning for a conversation on a canvas */
  startConversationReasoning: (canvasId: string, conversationId: string) => void;

  /** Append a chunk of reasoning text to a conversation's reasoning blocks */
  appendConversationReasoningChunk: (
    canvasId: string,
    conversationId: string,
    text: string,
  ) => void;

  /** Add search results block to a conversation's reasoning */
  addConversationReasoningSearchResults: (
    canvasId: string,
    conversationId: string,
    searchId: string,
    totalHits: number,
    results: SearchResultPreview[],
  ) => void;

  /** End streaming subagent reasoning for a conversation */
  endConversationReasoning: (canvasId: string, conversationId: string, error?: boolean) => void;

  // ── Analysis actions (pending analysis per conversation) ─────────────────

  /** Start a pending analysis for a conversation (creates landing pad for streamed chunks) */
  startConversationAnalysis: (
    canvasId: string,
    conversationId: string,
    searchQuery?: string,
  ) => void;

  /** Append a chunk of analysis text */
  appendConversationAnalysisChunk: (canvasId: string, conversationId: string, text: string) => void;

  /** Set parsed citations on a pending analysis */
  setConversationAnalysisCitations: (
    canvasId: string,
    conversationId: string,
    citations: Citation[],
  ) => void;

  /** Mark a pending analysis as finished streaming */
  finishConversationAnalysis: (canvasId: string, conversationId: string) => void;

  /**
   * Clear all pending analyses on a canvas.
   * Resets each conversation's reasoning and analysis state.
   */
  clearCanvasAnalyses: (canvasId: string) => void;

  // ── Draft update/clear actions ────────────────────────────────────────────

  /** Update a pending draft's content inline (for inline editing in review mode) */
  updateConversationDraft: (
    canvasId: string,
    conversationId: string,
    updates: { body?: string; subject?: string },
  ) => void;

  /** Clear a single pending draft (e.g., after auto-completion) */
  clearConversationDraft: (canvasId: string, conversationId: string) => void;
}

export interface CanvasSlice extends CanvasSliceState, CanvasSliceActions {}

// ============================================
// INITIAL STATE
// ============================================

const initialCanvasSliceState: CanvasSliceState = {
  canvasesById: {},
  homeViewCanvasIds: [],
  canvasUIState: {},
  ephemeralCanvasIds: new Set(),
  dirtyCanvasIds: new Set(),
  activeCanvasStream: null,
};

// ============================================
// HELPERS
// ============================================

/** Ensure a canvas entry exists in canvasesById (no-op if already there) */
function ensureCanvasUIState(state: CanvasSliceState, canvasId: string): void {
  if (!state.canvasUIState[canvasId]) {
    state.canvasUIState[canvasId] = {};
  }
}

/** Normalise a server timestamp (Date over superjson, or an ISO string) to ISO 8601. */
function toIsoTimestamp(value: Date | string | null | undefined): string {
  if (value instanceof Date) return value.toISOString();
  const parsed = value ? Date.parse(value) : NaN;
  return Number.isNaN(parsed) ? new Date().toISOString() : new Date(parsed).toISOString();
}

/**
 * True when `incoming` is a strictly older snapshot of a canvas we already hold.
 *
 * A canvas.getCanvases response that was already in flight when a viewConfig save landed carries
 * the pre-save row, and it arrives after the save cleared the dirty flag — so the dirty guard in
 * upsertCanvas can't catch it. Comparing timestamps can: every server-side update bumps
 * `updated_at`, and saveCanvasViewConfig writes the value the server returned back onto the store.
 */
export function isStaleSnapshot(incoming: Canvas, local: Canvas | undefined): boolean {
  if (!local) return false;
  const incomingAt = Date.parse(incoming.updatedAt);
  const localAt = Date.parse(local.updatedAt);
  if (Number.isNaN(incomingAt) || Number.isNaN(localAt)) return false;
  return incomingAt < localAt;
}

/**
 * One in-flight viewConfig save per canvas, chained.
 *
 * Every filter/sort control saves on each change, so working through a popover fires several
 * updateCanvas mutations against the same row at once and the last RESPONSE to land — not the
 * last edit the user made — decides what the server keeps. Chaining makes the order on the wire
 * the order they clicked in.
 */
const canvasSaveChains = new Map<string, Promise<void>>();

// ============================================
// SLICE CREATOR
// ============================================

export const createCanvasSlice: StateCreator<
  CedarStore,
  [['zustand/immer', never], ['zustand/devtools', never]],
  [],
  CanvasSlice
> = (set, get) => ({
  ...initialCanvasSliceState,

  // ── Canvas CRUD ────────────────────────────────────────────────────────────

  upsertCanvas: (canvas) =>
    set(
      (state) => {
        // Don't overwrite a canvas that has pending local changes (dirty).
        // This prevents a race condition where a background refetch overwrites
        // filter/sort changes that haven't finished saving to the server yet.
        if (state.dirtyCanvasIds.has(canvas.id)) return;
        if (isStaleSnapshot(canvas, state.canvasesById[canvas.id])) return;
        state.canvasesById[canvas.id] = canvas;
      },
      false,
      'canvas/upsertCanvas',
    ),

  removeCanvas: async (canvasId) => {
    const canvas = get().canvasesById[canvasId];
    const isEphemeral = get().ephemeralCanvasIds.has(canvasId);

    // Remove from store immediately (optimistic)
    set(
      (state) => {
        delete state.canvasesById[canvasId];
        state.homeViewCanvasIds = state.homeViewCanvasIds.filter((id) => id !== canvasId);
        state.ephemeralCanvasIds.delete(canvasId);
        state.dirtyCanvasIds.delete(canvasId);
        delete state.canvasUIState[canvasId];
        if (state.activeCanvasId === canvasId) {
          state.activeCanvasId = null;
        }
      },
      false,
      'canvas/removeCanvas',
    );

    // Only call tRPC if canvas was persisted
    if (canvas && !isEphemeral) {
      await api.canvas.deleteCanvas.mutate({ id: canvasId });
    }
  },

  setHomeViewCanvasIds: (ids) =>
    set(
      (state) => {
        state.homeViewCanvasIds = ids;
      },
      false,
      'canvas/setHomeViewCanvasIds',
    ),

  // ── Sharing / HomeView pin ────────────────────────────────────────────────────

  setSharedCanvasHomeViewPinned: async (canvasId, pinned) => {
    set(
      (state) => {
        if (pinned && !state.homeViewCanvasIds.includes(canvasId)) {
          state.homeViewCanvasIds.push(canvasId);
        } else if (!pinned) {
          state.homeViewCanvasIds = state.homeViewCanvasIds.filter((id) => id !== canvasId);
        }
        ensureCanvasUIState(state, canvasId);
      },
      false,
      'canvas/setSharedCanvasHomeViewPinned',
    );

    await api.canvas.setMyHomeViewPreference.mutate({ canvasId, pinnedToHomeView: pinned });
  },

  reorderMyHomeView: async (orderedIds) => {
    set(
      (state) => {
        state.homeViewCanvasIds = orderedIds;
        // Update homeViewOrder on each owned canvas
        orderedIds.forEach((id, idx) => {
          const canvas = state.canvasesById[id];
          if (canvas) {
            canvas.homeViewOrder = idx;
          }
        });
      },
      false,
      'canvas/reorderMyHomeView',
    );

    await api.canvas.reorderMyHomeView.mutate({ canvasIds: orderedIds });
  },

  // ── Agent writes (scoped to a canvas) ────────────────────────────────────────

  setCanvasAgentRunState: (canvasId, runState) =>
    set(
      (state) => {
        const canvas = state.canvasesById[canvasId];
        if (!canvas) return;

        canvas.data = canvas.data ?? {};
        canvas.data.agentRunState = {
          ...canvas.data.agentRunState,
          ...runState,
        };
      },
      false,
      'canvas/setCanvasAgentRunState',
    ),

  upsertConversationEntry: (canvasId, conversationId, entry) =>
    set(
      (state) => {
        const canvas = state.canvasesById[canvasId];
        if (!canvas) return;

        canvas.data = canvas.data ?? {};
        canvas.data.conversationEntries = canvas.data.conversationEntries ?? {};

        const existing = canvas.data.conversationEntries[conversationId] ?? {
          conversationId,
        };

        canvas.data.conversationEntries[conversationId] = deepMergeEntry(existing, entry);
      },
      false,
      'canvas/upsertConversationEntry',
    ),

  clearConversationEntry: (canvasId, conversationId) =>
    set(
      (state) => {
        const canvas = state.canvasesById[canvasId];
        if (!canvas?.data?.conversationEntries) return;
        delete canvas.data.conversationEntries[conversationId];
      },
      false,
      'canvas/clearConversationEntry',
    ),

  // ── Draft review UI ────────────────────────────────────────────────────────────

  openDraftReview: (canvasId, conversationId) => {
    set(
      (state) => {
        ensureCanvasUIState(state, canvasId);
        const queue = computePendingDraftQueue(state, canvasId);
        if (queue.length === 0) return;

        let startIndex = 0;
        if (conversationId) {
          const idx = queue.indexOf(conversationId);
          if (idx >= 0) startIndex = idx;
        }

        state.canvasUIState[canvasId].draftReview = { isOpen: true, currentIndex: startIndex };
      },
      false,
      'canvas/openDraftReview',
    );
    // Close the open conversation (url-driven-layout: flag derives from selectedArtifact).
    if (get().isConversationOpen) {
      get().setIsConversationOpen(false);
    }
  },

  closeDraftReview: (canvasId) =>
    set(
      (state) => {
        ensureCanvasUIState(state, canvasId);
        if (state.canvasUIState[canvasId].draftReview) {
          state.canvasUIState[canvasId].draftReview!.isOpen = false;
        }
      },
      false,
      'canvas/closeDraftReview',
    ),

  nextDraft: (canvasId) =>
    set(
      (state) => {
        const uiState = state.canvasUIState[canvasId];
        if (!uiState?.draftReview) return;
        const queue = computePendingDraftQueue(state, canvasId);
        if (uiState.draftReview.currentIndex < queue.length - 1) {
          uiState.draftReview.currentIndex++;
        }
      },
      false,
      'canvas/nextDraft',
    ),

  prevDraft: (canvasId) =>
    set(
      (state) => {
        const uiState = state.canvasUIState[canvasId];
        if (!uiState?.draftReview) return;
        if (uiState.draftReview.currentIndex > 0) {
          uiState.draftReview.currentIndex--;
        }
      },
      false,
      'canvas/prevDraft',
    ),

  markDraftSent: (canvasId, conversationId) =>
    set(
      (state) => {
        const entry = state.canvasesById[canvasId]?.data?.conversationEntries?.[conversationId];
        if (entry?.pendingDraft) {
          entry.pendingDraft.reviewStatus = 'sent';
        }
        if (entry?.agentProcessing) {
          entry.agentProcessing.message = 'Sent';
        }
        // Recompute queue and auto-close if empty
        const queue = computePendingDraftQueue(state, canvasId);
        const uiState = state.canvasUIState[canvasId];
        if (uiState?.draftReview) {
          if (queue.length === 0) {
            uiState.draftReview.isOpen = false;
            uiState.draftReview.currentIndex = 0;
          } else {
            uiState.draftReview.currentIndex = Math.min(
              uiState.draftReview.currentIndex,
              queue.length - 1,
            );
          }
        }
      },
      false,
      'canvas/markDraftSent',
    ),

  rejectDraft: (canvasId, conversationId) =>
    set(
      (state) => {
        const entry = state.canvasesById[canvasId]?.data?.conversationEntries?.[conversationId];
        if (entry?.pendingDraft) {
          entry.pendingDraft.reviewStatus = 'rejected';
        }
        if (entry?.agentProcessing) {
          entry.agentProcessing.message = 'Rejected';
        }
        const queue = computePendingDraftQueue(state, canvasId);
        const uiState = state.canvasUIState[canvasId];
        if (uiState?.draftReview) {
          if (queue.length === 0) {
            uiState.draftReview.isOpen = false;
            uiState.draftReview.currentIndex = 0;
          } else {
            uiState.draftReview.currentIndex = Math.min(
              uiState.draftReview.currentIndex,
              queue.length - 1,
            );
          }
        }
      },
      false,
      'canvas/rejectDraft',
    ),

  markExternalIntegrationProposalAccepted: (canvasId, conversationId) =>
    set(
      (state) => {
        const entry = state.canvasesById[canvasId]?.data?.conversationEntries?.[conversationId];
        if (entry?.pendingExternalIntegrationProposal) {
          entry.pendingExternalIntegrationProposal.reviewStatus = 'accepted';
        }
        if (entry?.agentProcessing) {
          entry.agentProcessing.message = 'Executed';
        }
      },
      false,
      'canvas/markExternalIntegrationProposalAccepted',
    ),

  rejectExternalIntegrationProposal: (canvasId, conversationId) =>
    set(
      (state) => {
        const entry = state.canvasesById[canvasId]?.data?.conversationEntries?.[conversationId];
        if (entry?.pendingExternalIntegrationProposal) {
          entry.pendingExternalIntegrationProposal.reviewStatus = 'rejected';
        }
        if (entry?.agentProcessing) {
          entry.agentProcessing.message = 'Rejected';
        }
      },
      false,
      'canvas/rejectExternalIntegrationProposal',
    ),

  // ── Analysis review UI ─────────────────────────────────────────────────────────

  openAnalysisReview: (canvasId, conversationId) => {
    set(
      (state) => {
        ensureCanvasUIState(state, canvasId);
        const queue = computePendingAnalysisQueue(state, canvasId);
        if (queue.length === 0) return;

        let startIndex = 0;
        if (conversationId) {
          const idx = queue.indexOf(conversationId);
          if (idx >= 0) startIndex = idx;
        }

        state.canvasUIState[canvasId].analysisReview = { isOpen: true, currentIndex: startIndex };
      },
      false,
      'canvas/openAnalysisReview',
    );
    // Close the open conversation (url-driven-layout: flag derives from selectedArtifact).
    if (get().isConversationOpen) {
      get().setIsConversationOpen(false);
    }
  },

  closeAnalysisReview: (canvasId) =>
    set(
      (state) => {
        ensureCanvasUIState(state, canvasId);
        if (state.canvasUIState[canvasId].analysisReview) {
          state.canvasUIState[canvasId].analysisReview!.isOpen = false;
        }
      },
      false,
      'canvas/closeAnalysisReview',
    ),

  nextAnalysis: (canvasId) =>
    set(
      (state) => {
        const uiState = state.canvasUIState[canvasId];
        if (!uiState?.analysisReview) return;
        const queue = computePendingAnalysisQueue(state, canvasId);
        if (uiState.analysisReview.currentIndex < queue.length - 1) {
          uiState.analysisReview.currentIndex++;
        }
      },
      false,
      'canvas/nextAnalysis',
    ),

  prevAnalysis: (canvasId) =>
    set(
      (state) => {
        const uiState = state.canvasUIState[canvasId];
        if (!uiState?.analysisReview) return;
        if (uiState.analysisReview.currentIndex > 0) {
          uiState.analysisReview.currentIndex--;
        }
      },
      false,
      'canvas/prevAnalysis',
    ),

  // ── Thread management ─────────────────────────────────────────────────────────

  setCanvasActiveThread: (canvasId, threadId) =>
    set(
      (state) => {
        ensureCanvasUIState(state, canvasId);
        state.canvasUIState[canvasId].activeThreadId = threadId;
      },
      false,
      'canvas/setCanvasActiveThread',
    ),

  // ── Ephemeral canvas management ────────────────────────────────────────────────

  upsertEphemeralCanvas: (canvas) =>
    set(
      (state) => {
        state.canvasesById[canvas.id] = canvas;
        state.ephemeralCanvasIds.add(canvas.id);
      },
      false,
      'canvas/upsertEphemeralCanvas',
    ),

  markCanvasPersisted: (canvasId) =>
    set(
      (state) => {
        state.ephemeralCanvasIds.delete(canvasId);
      },
      false,
      'canvas/markCanvasPersisted',
    ),

  updateCanvasViewConfig: (canvasId, viewConfig) =>
    set(
      (state) => {
        const canvas = state.canvasesById[canvasId];
        if (!canvas) return;

        // Snapshot the current viewConfig before overwriting (for revert)
        ensureCanvasUIState(state, canvasId);
        if (!state.canvasUIState[canvasId].savedViewConfig) {
          state.canvasUIState[canvasId].savedViewConfig = canvas.viewConfig;
        }

        canvas.viewConfig = viewConfig;
        state.dirtyCanvasIds.add(canvasId);
      },
      false,
      'canvas/updateCanvasViewConfig',
    ),

  updateCanvasTitle: (canvasId, title) =>
    set(
      (state) => {
        const canvas = state.canvasesById[canvasId];
        if (!canvas) return;
        canvas.title = title;
      },
      false,
      'canvas/updateCanvasTitle',
    ),

  saveCanvasViewConfig: (canvasId) => {
    const run = async (): Promise<boolean> => {
      const canvas = get().canvasesById[canvasId];
      if (!canvas) return false;

      // Snapshot what we send: another edit can land while the request is in flight, and the
      // cache/timestamp below must describe the config the server actually stored.
      const savedViewConfig = canvas.viewConfig;
      const updated = await api.canvas.updateCanvas.mutate({
        id: canvasId,
        viewConfig: savedViewConfig,
      });

      const updatedAt = toIsoTimestamp(updated?.updatedAt);

      // The store rehydrates from the cached canvas.getCanvases response on every mount (and after
      // a reload, since it is persisted). Without this the pre-save row wins that rehydration and
      // the change the user just made reads as "didn't save".
      syncSavedViewConfigToQueryCache(canvasId, savedViewConfig, updatedAt);

      // Only reached when the mutation resolved. Throwing skips this block, which is what leaves
      // the canvas in `dirtyCanvasIds` — and a dirty canvas is one `upsertCanvas` refuses to
      // overwrite, so the user's unsaved filter stays on screen until the save is retried.
      set(
        (state) => {
          const stored = state.canvasesById[canvasId];
          // Lets upsertCanvas recognise an in-flight refetch's pre-save row as stale.
          if (stored) stored.updatedAt = updatedAt;
          state.dirtyCanvasIds.delete(canvasId);
          // Clear the saved snapshot — current viewConfig IS the saved state now
          if (state.canvasUIState[canvasId]) {
            delete state.canvasUIState[canvasId].savedViewConfig;
          }
        },
        false,
        'canvas/saveCanvasViewConfig',
      );
      return true;
    };

    // Reported as "my pipeline filters don't survive a refresh". Every filter/sort control calls
    // this as `void saveCanvasViewConfig(id)`, so a rejection had no observer whatsoever: no
    // toast, no retry, and a local config the user believed was stored. The revert then arrived a
    // reload later, when the server's pre-save row came back and won — which reads as the filter
    // being forgotten rather than as a write that failed. Report it where it happens.
    const report = async (): Promise<boolean> => {
      try {
        return await run();
      } catch (error) {
        console.error('[Canvas] Failed to save view config', { canvasId, error });
        toast.error("Couldn't save this view", {
          description: 'Your changes are still here and will be retried — they are not lost.',
        });
        return false;
      }
    };

    // Chain onto whatever save is already in flight for this canvas. `report` reads viewConfig
    // when it EXECUTES, not when it is queued, so a superseded link sends the newer config
    // rather than a stale snapshot.
    const chained = (canvasSaveChains.get(canvasId) ?? Promise.resolve()).then(report);
    canvasSaveChains.set(
      canvasId,
      chained.then(() => undefined),
    );
    return chained;
  },

  revertCanvasViewConfig: (canvasId) =>
    set(
      (state) => {
        const canvas = state.canvasesById[canvasId];
        const savedViewConfig = state.canvasUIState[canvasId]?.savedViewConfig;
        if (!canvas || !savedViewConfig) return;

        canvas.viewConfig = savedViewConfig;
        state.dirtyCanvasIds.delete(canvasId);
        delete state.canvasUIState[canvasId].savedViewConfig;
      },
      false,
      'canvas/revertCanvasViewConfig',
    ),

  pinCanvasToHomeView: async (canvasId) => {
    const canvas = get().canvasesById[canvasId];
    if (!canvas) return;

    const nextOrder = get().homeViewCanvasIds.length;
    const isEphemeral = get().ephemeralCanvasIds.has(canvasId);

    if (isEphemeral) {
      // Create a new persisted canvas from the ephemeral one
      const created = await api.canvas.createCanvas.mutate({
        title: canvas.title,
        type: canvas.type,
        description: canvas.description,
        actionText: canvas.actionText,
        viewConfig: canvas.viewConfig,
        data: canvas.data,
        homeViewOrder: nextOrder,
      });

      set(
        (state) => {
          // Replace the ephemeral canvas with the persisted one
          delete state.canvasesById[canvasId];
          state.canvasesById[created.id] = created as unknown as Canvas;
          state.ephemeralCanvasIds.delete(canvasId);
          state.homeViewCanvasIds.push(created.id);
          state.activeCanvasId = created.id;
        },
        false,
        'canvas/pinCanvasToHomeView',
      );
    } else {
      // Persisted canvas — just update homeViewOrder
      await api.canvas.updateCanvas.mutate({ id: canvasId, homeViewOrder: nextOrder });

      set(
        (state) => {
          if (state.canvasesById[canvasId]) {
            state.canvasesById[canvasId].homeViewOrder = nextOrder;
          }
          if (!state.homeViewCanvasIds.includes(canvasId)) {
            state.homeViewCanvasIds.push(canvasId);
          }
        },
        false,
        'canvas/pinCanvasToHomeView:persisted',
      );
    }
  },

  saveAsNewTab: async (sourceCanvasId, title) => {
    const canvas = get().canvasesById[sourceCanvasId];
    if (!canvas) return;

    const nextOrder = get().homeViewCanvasIds.length;
    const newTitle = title ?? `${canvas.title} (copy)`;

    const created = await api.canvas.createCanvas.mutate({
      title: newTitle,
      type: canvas.type,
      description: canvas.description,
      actionText: canvas.actionText,
      viewConfig: canvas.viewConfig, // current (possibly dirty) viewConfig
      data: {},
      homeViewOrder: nextOrder,
    });

    set(
      (state) => {
        state.canvasesById[created.id] = created as unknown as Canvas;
        state.homeViewCanvasIds.push(created.id);
      },
      false,
      'canvas/saveAsNewTab',
    );

    // Revert the source canvas to its original saved viewConfig
    get().revertCanvasViewConfig(sourceCanvasId);
  },

  updateCanvasColour: async (canvasId, colour) => {
    set(
      (state) => {
        const canvas = state.canvasesById[canvasId];
        if (canvas) {
          canvas.colour = colour;
        }
      },
      false,
      'canvas/updateCanvasColour',
    );
    await api.canvas.updateCanvas.mutate({ id: canvasId, colour });
  },

  setCardListPinnedIds: async (canvasId, pinnedConversationIds) => {
    let nextViewConfig: CanvasViewConfig | undefined;
    set(
      (state) => {
        const canvas = state.canvasesById[canvasId];
        if (!canvas) return;
        canvas.viewConfig = {
          ...canvas.viewConfig,
          pinnedConversationIds,
        } as CanvasViewConfig;
        nextViewConfig = canvas.viewConfig;
      },
      false,
      'canvas/setCardListPinnedIds',
    );

    if (!nextViewConfig) return;
    await api.canvas.updateCanvas.mutate({ id: canvasId, viewConfig: nextViewConfig });
  },

  // ── Report canvas streaming ──────────────────────────────────────────────────

  setActiveCanvasStream: (stream) =>
    set({ activeCanvasStream: stream }, false, 'canvas/setActiveCanvasStream'),

  appendCanvasStreamChunk: (toolCallId, chunk) =>
    set(
      (state) => {
        if (!state.activeCanvasStream || state.activeCanvasStream.toolCallId !== toolCallId) return;
        state.activeCanvasStream.accumulatedContent += chunk;
      },
      false,
      'canvas/appendCanvasStreamChunk',
    ),

  // ── Getters ─────────────────────────────────────────────────────────────────

  getCanvas: (canvasId) => get().canvasesById[canvasId],

  getConversationEntry: (canvasId, conversationId) =>
    get().canvasesById[canvasId]?.data?.conversationEntries?.[conversationId],

  getActiveHomeViewCanvas: () => {
    const { activeCanvasId, canvasesById } = get();
    return activeCanvasId ? (canvasesById[activeCanvasId] ?? null) : null;
  },

  getActiveCanvasThreadId: (canvasId) => get().canvasUIState[canvasId]?.activeThreadId ?? canvasId,

  getPendingDraftQueue: (canvasId) => computePendingDraftQueue(get(), canvasId),

  getPendingAnalysisQueue: (canvasId) => computePendingAnalysisQueue(get(), canvasId),

  // ── Reasoning actions ─────────────────────────────────────────────────────

  startConversationReasoning: (canvasId, conversationId) =>
    set(
      (state) => {
        const canvas = state.canvasesById[canvasId];
        if (!canvas) return;
        canvas.data = canvas.data ?? {};
        canvas.data.conversationEntries = canvas.data.conversationEntries ?? {};
        const entry = (canvas.data.conversationEntries[conversationId] ??= { conversationId });
        entry.reasoning = {
          isStreaming: true,
          blocks: [],
          startedAt: new Date().toISOString(),
        };
      },
      false,
      'canvas/startConversationReasoning',
    ),

  appendConversationReasoningChunk: (canvasId, conversationId, text) =>
    set(
      (state) => {
        const entry = state.canvasesById[canvasId]?.data?.conversationEntries?.[conversationId];
        if (!entry) return;
        if (!entry.reasoning) {
          entry.reasoning = { isStreaming: true, blocks: [], startedAt: new Date().toISOString() };
        }
        const blocks = entry.reasoning.blocks;
        const lastBlock = blocks[blocks.length - 1];
        if (lastBlock && lastBlock.type === 'text') {
          lastBlock.content += text;
        } else {
          blocks.push({ type: 'text', content: text });
        }
      },
      false,
      'canvas/appendConversationReasoningChunk',
    ),

  addConversationReasoningSearchResults: (canvasId, conversationId, searchId, totalHits, results) =>
    set(
      (state) => {
        const entry = state.canvasesById[canvasId]?.data?.conversationEntries?.[conversationId];
        if (!entry?.reasoning) return;
        const blocks = entry.reasoning.blocks;
        const existingIdx = blocks.findIndex(
          (b) => b.type === 'search-results' && b.searchId === searchId,
        );
        if (existingIdx !== -1) {
          const existing = blocks[existingIdx];
          if (existing.type === 'search-results') {
            existing.results = existing.results.map((r) => {
              const incoming = results.find((ir) => ir.documentId === r.documentId);
              return incoming ? { ...r, ...incoming } : r;
            });
            existing.totalHits = totalHits;
          }
        } else {
          blocks.push({ type: 'search-results', searchId, results, totalHits });
        }
      },
      false,
      'canvas/addConversationReasoningSearchResults',
    ),

  endConversationReasoning: (canvasId, conversationId, error) =>
    set(
      (state) => {
        const entry = state.canvasesById[canvasId]?.data?.conversationEntries?.[conversationId];
        if (!entry?.reasoning) return;
        entry.reasoning.isStreaming = false;
        entry.reasoning.hasError = error;
        entry.reasoning.endedAt = new Date().toISOString();
      },
      false,
      'canvas/endConversationReasoning',
    ),

  // ── Analysis actions ─────────────────────────────────────────────────────

  startConversationAnalysis: (canvasId, conversationId, searchQuery) =>
    set(
      (state) => {
        const canvas = state.canvasesById[canvasId];
        if (!canvas) return;
        canvas.data = canvas.data ?? {};
        canvas.data.conversationEntries = canvas.data.conversationEntries ?? {};
        const entry = (canvas.data.conversationEntries[conversationId] ??= { conversationId });
        // Snapshot the current agent row message as search query if not provided
        const agentMessage = entry.agentProcessing?.message;
        entry.pendingAnalysis = {
          conversationId,
          content: '',
          citations: [],
          isStreaming: true,
          searchQuery: searchQuery ?? agentMessage,
          createdAt: new Date().toISOString(),
        };
      },
      false,
      'canvas/startConversationAnalysis',
    ),

  appendConversationAnalysisChunk: (canvasId, conversationId, text) =>
    set(
      (state) => {
        const entry = state.canvasesById[canvasId]?.data?.conversationEntries?.[conversationId];
        if (!entry) return;
        if (!entry.pendingAnalysis) {
          entry.pendingAnalysis = {
            conversationId,
            content: text,
            citations: [],
            isStreaming: true,
            createdAt: new Date().toISOString(),
          };
        } else {
          entry.pendingAnalysis.content += text;
        }
      },
      false,
      'canvas/appendConversationAnalysisChunk',
    ),

  setConversationAnalysisCitations: (canvasId, conversationId, citations) =>
    set(
      (state) => {
        const entry = state.canvasesById[canvasId]?.data?.conversationEntries?.[conversationId];
        if (entry?.pendingAnalysis) {
          entry.pendingAnalysis.citations = citations;
        }
      },
      false,
      'canvas/setConversationAnalysisCitations',
    ),

  finishConversationAnalysis: (canvasId, conversationId) =>
    set(
      (state) => {
        const entry = state.canvasesById[canvasId]?.data?.conversationEntries?.[conversationId];
        if (entry?.pendingAnalysis) {
          entry.pendingAnalysis.isStreaming = false;
        }
      },
      false,
      'canvas/finishConversationAnalysis',
    ),

  clearCanvasAnalyses: (canvasId) =>
    set(
      (state) => {
        const canvas = state.canvasesById[canvasId];
        if (!canvas?.data?.conversationEntries) return;
        for (const entry of Object.values(canvas.data.conversationEntries)) {
          delete entry.pendingAnalysis;
          delete entry.reasoning;
          if (entry.agentProcessing) {
            // Reset action column to idle (remove "View Analysis" button)
            const hasAction = entry.agentProcessing.columns.some(
              (c) => c.content.type === 'action',
            );
            if (hasAction) {
              entry.agentProcessing.columns = entry.agentProcessing.columns.map((col) =>
                col.content.type === 'action'
                  ? {
                      ...col,
                      content: { type: 'status', state: 'idle' as const, message: 'Cleared' },
                    }
                  : col,
              );
            }
          }
        }
        // Close analysis review if open
        if (state.canvasUIState[canvasId]?.analysisReview) {
          state.canvasUIState[canvasId].analysisReview = { isOpen: false, currentIndex: 0 };
        }
      },
      false,
      'canvas/clearCanvasAnalyses',
    ),

  // ── Draft update/clear actions ─────────────────────────────────────────────

  updateConversationDraft: (canvasId, conversationId, updates) =>
    set(
      (state) => {
        const entry = state.canvasesById[canvasId]?.data?.conversationEntries?.[conversationId];
        if (!entry?.pendingDraft) return;
        if (updates.body !== undefined) entry.pendingDraft.body = updates.body;
        if (updates.subject !== undefined) entry.pendingDraft.subject = updates.subject;
      },
      false,
      'canvas/updateConversationDraft',
    ),

  clearConversationDraft: (canvasId, conversationId) =>
    set(
      (state) => {
        const entry = state.canvasesById[canvasId]?.data?.conversationEntries?.[conversationId];
        if (!entry) return;
        delete entry.pendingDraft;
        if (entry.agentProcessing) {
          entry.agentProcessing.columns = [];
        }
      },
      false,
      'canvas/clearConversationDraft',
    ),
});

// ============================================
// PURE COMPUTATION HELPERS
// ============================================

/**
 * Compute the sorted list of conversationIds that have a pending draft.
 * Excludes drafts that have been sent or rejected.
 * Result is COMPUTED, not stored.
 */
function computePendingDraftQueue(state: CanvasSliceState, canvasId: string): string[] {
  const entries = state.canvasesById[canvasId]?.data?.conversationEntries ?? {};
  return Object.entries(entries)
    .filter(([, entry]) => entry.pendingDraft?.reviewStatus === 'pending')
    .map(([id]) => id);
}

/**
 * Compute the sorted list of conversationIds that have a completed (non-streaming) analysis.
 */
function computePendingAnalysisQueue(state: CanvasSliceState, canvasId: string): string[] {
  const entries = state.canvasesById[canvasId]?.data?.conversationEntries ?? {};
  return Object.entries(entries)
    .filter(([, entry]) => entry.pendingAnalysis && !entry.pendingAnalysis.isStreaming)
    .map(([id]) => id);
}

/**
 * Deeply merge a ConversationEntry partial into an existing entry.
 * Top-level keys (agentProcessing, reasoning, pendingDraft, pendingAnalysis) are merged,
 * not replaced, to avoid overwriting sub-fields.
 */
function deepMergeEntry(
  existing: ConversationEntry,
  update: Partial<ConversationEntry>,
): ConversationEntry {
  const merged: ConversationEntry = { ...existing };

  if (update.agentProcessing !== undefined) {
    merged.agentProcessing = update.agentProcessing
      ? { ...existing.agentProcessing, ...update.agentProcessing }
      : undefined;
  }
  if (update.reasoning !== undefined) {
    merged.reasoning = update.reasoning
      ? { ...(existing.reasoning ?? { isStreaming: false, blocks: [] }), ...update.reasoning }
      : undefined;
  }
  // Use 'in' operator rather than !== undefined: callers like cancelStream pass
  // `pendingDraft: undefined` explicitly to clear the field, but `undefined !== undefined`
  // is false so the old check silently preserved it instead of clearing it.
  if ('pendingDraft' in update) {
    merged.pendingDraft = update.pendingDraft
      ? { ...existing.pendingDraft, ...update.pendingDraft }
      : undefined;
  }
  if ('pendingAnalysis' in update) {
    merged.pendingAnalysis = update.pendingAnalysis
      ? { ...existing.pendingAnalysis, ...update.pendingAnalysis }
      : undefined;
  }

  return merged;
}