import { create } from 'zustand' import type { Artifact, ArtifactIndex, DiffPreview } from '@janhq/core' import { getServiceHub } from '@/hooks/useServiceHub' /** * Debounce delay for autosave (ms) */ const AUTOSAVE_DEBOUNCE = 1200 /** * Artifact store state */ type ArtifactsState = { // Data threadArtifacts: Record threadIndex: Record artifactContent: Record // artifact_id → content dirtyArtifacts: Set // artifact_ids with unsaved changes // Conflict tracking editorVersions: Record // UI state splitViewOpen: Record splitRatio: Record // Proposals pendingProposals: Record // Loading states loadingArtifacts: Record savingArtifacts: Set // Debounce timer saveTimers: Record // Conflict state conflicts: Record // Actions loadArtifacts: (threadId: string) => Promise createArtifact: ( threadId: string, name: string, contentType: string, language: string | undefined, initialContent: string ) => Promise getArtifactContent: (threadId: string, artifactId: string) => Promise updateArtifactContent: (artifactId: string, content: string) => void saveArtifact: (threadId: string, artifactId: string) => Promise deleteArtifact: (threadId: string, artifactId: string) => Promise renameArtifact: (threadId: string, artifactId: string, newName: string) => Promise setActiveArtifact: (threadId: string, artifactId: string | null) => void toggleSplitView: (threadId: string) => void setSplitRatio: (threadId: string, ratio: number) => void proposeUpdate: (threadId: string, artifactId: string, content: string) => Promise applyProposal: (threadId: string, artifactId: string, proposalId: string, selectedHunks?: number[]) => Promise discardProposal: (threadId: string, artifactId: string, proposalId: string) => void clearPendingProposal: (artifactId: string) => void forceSaveAll: (threadId: string) => Promise resolveConflict: (threadId: string, artifactId: string, resolution: 'keep-mine' | 'take-theirs') => Promise clearConflict: (artifactId: string) => void } /** * Load split view state from localStorage */ function loadSplitState(threadId: string): { open: boolean; ratio: number } { const open = localStorage.getItem(`artifacts_split_open_${threadId}`) === 'true' const ratio = parseInt(localStorage.getItem(`artifacts_split_ratio_${threadId}`) || '60', 10) return { open, ratio: Math.max(40, Math.min(80, ratio)) } } /** * Save split view state to localStorage */ function saveSplitState(threadId: string, open: boolean, ratio: number) { localStorage.setItem(`artifacts_split_open_${threadId}`, open.toString()) localStorage.setItem(`artifacts_split_ratio_${threadId}`, ratio.toString()) } export const useArtifacts = create()((set, get) => ({ // Initial state threadArtifacts: {}, threadIndex: {}, artifactContent: {}, dirtyArtifacts: new Set(), editorVersions: {}, splitViewOpen: {}, splitRatio: {}, pendingProposals: {}, loadingArtifacts: {}, savingArtifacts: new Set(), saveTimers: {}, conflicts: {}, // Load artifacts for a thread loadArtifacts: async (threadId: string) => { set((state) => ({ loadingArtifacts: { ...state.loadingArtifacts, [threadId]: true }, })) try { const index = await getServiceHub().artifacts().listArtifacts(threadId) // Load split view state from localStorage const { open, ratio } = loadSplitState(threadId) set((state) => ({ threadArtifacts: { ...state.threadArtifacts, [threadId]: index.artifacts, }, threadIndex: { ...state.threadIndex, [threadId]: index, }, splitViewOpen: { ...state.splitViewOpen, [threadId]: open, }, splitRatio: { ...state.splitRatio, [threadId]: ratio, }, loadingArtifacts: { ...state.loadingArtifacts, [threadId]: false }, })) } catch (error) { console.error('Error loading artifacts:', error) set((state) => ({ loadingArtifacts: { ...state.loadingArtifacts, [threadId]: false }, })) } }, // Create a new artifact createArtifact: async (threadId, name, contentType, language, initialContent) => { try { const artifact = await getServiceHub().artifacts().createArtifact(threadId, { name, content_type: contentType, language, content: initialContent, }) // Reload artifacts to get updated index await get().loadArtifacts(threadId) // Set as active and open split view set((state) => { const newSplitOpen = { ...state.splitViewOpen, [threadId]: true } saveSplitState(threadId, true, state.splitRatio[threadId] || 60) return { splitViewOpen: newSplitOpen, artifactContent: { ...state.artifactContent, [artifact.id]: initialContent }, editorVersions: { ...state.editorVersions, [artifact.id]: { version: artifact.version, hash: artifact.hash }, }, } }) // Set as active await getServiceHub().artifacts().setActiveArtifact(threadId, artifact.id) return artifact } catch (error) { console.error('Error creating artifact:', error) return null } }, // Get artifact content getArtifactContent: async (threadId, artifactId) => { const state = get() // Check if already loaded if (state.artifactContent[artifactId]) { return state.artifactContent[artifactId] } try { const content = await getServiceHub().artifacts().getArtifactContent(threadId, artifactId) // Find artifact to get version/hash const artifacts = state.threadArtifacts[threadId] || [] const artifact = artifacts.find((a) => a.id === artifactId) set((state) => ({ artifactContent: { ...state.artifactContent, [artifactId]: content }, editorVersions: artifact ? { ...state.editorVersions, [artifactId]: { version: artifact.version, hash: artifact.hash }, } : state.editorVersions, })) return content } catch (error) { console.error('Error getting artifact content:', error) return null } }, // Update artifact content in memory (triggers debounced save) updateArtifactContent: (artifactId, content) => { const state = get() set((state) => ({ artifactContent: { ...state.artifactContent, [artifactId]: content }, dirtyArtifacts: new Set([...state.dirtyArtifacts, artifactId]), })) // Clear existing timer if (state.saveTimers[artifactId]) { clearTimeout(state.saveTimers[artifactId]) } // Set debounced save timer const timer = setTimeout(() => { // Find the thread for this artifact const threadId = Object.keys(state.threadArtifacts).find((tid) => state.threadArtifacts[tid]?.some((a) => a.id === artifactId) ) if (threadId) { get().saveArtifact(threadId, artifactId) } }, AUTOSAVE_DEBOUNCE) set((state) => ({ saveTimers: { ...state.saveTimers, [artifactId]: timer }, })) }, // Save artifact to backend saveArtifact: async (threadId, artifactId) => { const state = get() const content = state.artifactContent[artifactId] const version = state.editorVersions[artifactId] if (!content || !version) { console.warn('No content or version info for artifact:', artifactId) return } // Mark as saving set((state) => ({ savingArtifacts: new Set([...state.savingArtifacts, artifactId]), })) try { const updatedArtifact = await getServiceHub().artifacts().updateArtifact( threadId, artifactId, content, version.version, version.hash ) // Update version/hash and clear dirty flag set((state) => { const newDirty = new Set(state.dirtyArtifacts) newDirty.delete(artifactId) const newSaving = new Set(state.savingArtifacts) newSaving.delete(artifactId) return { editorVersions: { ...state.editorVersions, [artifactId]: { version: updatedArtifact.version, hash: updatedArtifact.hash }, }, dirtyArtifacts: newDirty, savingArtifacts: newSaving, } }) // Reload artifacts to update metadata await get().loadArtifacts(threadId) } catch (error: unknown) { console.error('Error saving artifact:', error) // Check for conflict error if (error instanceof Error && (error.message.includes('conflict') || error.message.includes('Version') || error.message.includes('Hash'))) { // Load server content try { const serverContent = await getServiceHub().artifacts().getArtifactContent(threadId, artifactId) if (serverContent) { set((state) => ({ conflicts: { ...state.conflicts, [artifactId]: { localContent: content, serverContent, }, }, })) } } catch (err) { console.error('Failed to load server content for conflict:', err) } } // Remove from saving set set((state) => { const newSaving = new Set(state.savingArtifacts) newSaving.delete(artifactId) return { savingArtifacts: newSaving } }) } }, // Delete an artifact deleteArtifact: async (threadId, artifactId) => { try { await getServiceHub().artifacts().deleteArtifact(threadId, artifactId) // Clear from state set((state) => { const newContent = { ...state.artifactContent } delete newContent[artifactId] const newVersions = { ...state.editorVersions } delete newVersions[artifactId] const newDirty = new Set(state.dirtyArtifacts) newDirty.delete(artifactId) return { artifactContent: newContent, editorVersions: newVersions, dirtyArtifacts: newDirty, } }) // Reload artifacts await get().loadArtifacts(threadId) } catch (error) { console.error('Error deleting artifact:', error) } }, // Rename an artifact renameArtifact: async (threadId, artifactId, newName) => { try { await getServiceHub().artifacts().renameArtifact(threadId, artifactId, newName) // Reload artifacts to get updated metadata await get().loadArtifacts(threadId) } catch (error) { console.error('Error renaming artifact:', error) } }, // Set active artifact setActiveArtifact: (threadId, artifactId) => { getServiceHub().artifacts().setActiveArtifact(threadId, artifactId) set((state) => { const index = state.threadIndex[threadId] if (!index) return state return { threadIndex: { ...state.threadIndex, [threadId]: { ...index, active_artifact_id: artifactId }, }, } }) }, // Toggle split view toggleSplitView: (threadId) => { set((state) => { const newOpen = !state.splitViewOpen[threadId] saveSplitState(threadId, newOpen, state.splitRatio[threadId] || 60) return { splitViewOpen: { ...state.splitViewOpen, [threadId]: newOpen }, } }) }, // Set split ratio setSplitRatio: (threadId, ratio) => { const clampedRatio = Math.max(40, Math.min(80, ratio)) set((state) => { saveSplitState(threadId, state.splitViewOpen[threadId] ?? false, clampedRatio) return { splitRatio: { ...state.splitRatio, [threadId]: clampedRatio }, } }) }, // Propose an update proposeUpdate: async (threadId, artifactId, content) => { try { const diffPreview = await getServiceHub().artifacts().proposeUpdate(threadId, artifactId, content) set((state) => ({ pendingProposals: { ...state.pendingProposals, [artifactId]: diffPreview }, })) return diffPreview } catch (error) { console.error('Error proposing update:', error) return null } }, // Apply a proposal applyProposal: async (threadId, artifactId, proposalId, selectedHunks) => { try { await getServiceHub().artifacts().applyProposal(threadId, artifactId, proposalId, selectedHunks) // Clear proposal and reload artifact content get().clearPendingProposal(artifactId) await get().getArtifactContent(threadId, artifactId) await get().loadArtifacts(threadId) } catch (error) { console.error('Error applying proposal:', error) } }, // Discard a proposal discardProposal: (threadId, artifactId, proposalId) => { getServiceHub().artifacts().discardProposal(threadId, artifactId, proposalId) get().clearPendingProposal(artifactId) }, // Clear pending proposal from state clearPendingProposal: (artifactId) => { set((state) => { const newProposals = { ...state.pendingProposals } delete newProposals[artifactId] return { pendingProposals: newProposals } }) }, // Force save all dirty artifacts (e.g., on window unload) forceSaveAll: async (threadId) => { const state = get() const artifacts = state.threadArtifacts[threadId] || [] const savePromises = Array.from(state.dirtyArtifacts) .filter((artifactId) => artifacts.some((a) => a.id === artifactId)) .map((artifactId) => get().saveArtifact(threadId, artifactId)) await Promise.all(savePromises) }, // Resolve a conflict resolveConflict: async (threadId, artifactId, resolution) => { const state = get() const conflict = state.conflicts[artifactId] if (!conflict) return const contentToSave = resolution === 'keep-mine' ? conflict.localContent : conflict.serverContent // Get current server version await get().loadArtifacts(threadId) const artifacts = state.threadArtifacts[threadId] || [] const artifact = artifacts.find((a) => a.id === artifactId) if (!artifact) return // Update with server's current version set((state) => ({ artifactContent: { ...state.artifactContent, [artifactId]: contentToSave }, editorVersions: { ...state.editorVersions, [artifactId]: { version: artifact.version, hash: artifact.hash }, }, })) // Save with correct version await get().saveArtifact(threadId, artifactId) // Clear conflict get().clearConflict(artifactId) }, // Clear conflict state clearConflict: (artifactId) => { set((state) => { const newConflicts = { ...state.conflicts } delete newConflicts[artifactId] return { conflicts: newConflicts } }) }, }))