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