/** * Atomic draft storage for CreateTeamDialog form snapshots. * * Stores the full form state (team name, members, paths, flags) under a single * IndexedDB key so that navigating away from the Teams tab and back preserves * user input. No TTL — drafts persist until explicitly cleared on successful * team creation. * * Pattern mirrors `composerDraftStorage.ts`. */ import { del, get, set } from 'idb-keyval'; import { isTeamProviderId } from '@shared/utils/teamProvider'; import type { TeamProviderId } from '@shared/types'; import { isTeamEffortLevel } from '@shared/utils/effortLevels'; import type { EffortLevel } from '@shared/types'; // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- /** Current snapshot schema version. Bump when shape changes. */ const SNAPSHOT_VERSION = 1; /** Serializable subset of MemberDraft — excludes transient `workflowChips`. */ export interface SerializedMemberDraft { id: string; name: string; roleSelection: string; customRole: string; workflow?: string; isolation?: 'worktree'; providerId?: TeamProviderId; model?: string; effort?: EffortLevel; } export interface CreateTeamDraftSnapshot { version: number; teamName: string; members: SerializedMemberDraft[]; syncModelsWithLead?: boolean; teammateWorktreeDefault?: boolean; cwdMode: 'project' | 'custom'; selectedProjectPath: string; customCwd: string; soloTeam: boolean; launchTeam: boolean; teamColor: string; updatedAt: number; } // --------------------------------------------------------------------------- // Key // --------------------------------------------------------------------------- const STORAGE_KEY = 'createTeamDraft:form'; // --------------------------------------------------------------------------- // Validation // --------------------------------------------------------------------------- function isValidMember(m: unknown): m is SerializedMemberDraft { if (typeof m !== 'object' || m === null) return false; const obj = m as Record; return ( typeof obj.id === 'string' && typeof obj.name === 'string' && typeof obj.roleSelection === 'string' && typeof obj.customRole === 'string' && (obj.isolation === undefined || obj.isolation === 'worktree') && (obj.providerId === undefined || isTeamProviderId(obj.providerId)) && (obj.model === undefined || typeof obj.model === 'string') && (obj.effort === undefined || isTeamEffortLevel(obj.effort)) ); } function isValidSnapshot(data: unknown): data is CreateTeamDraftSnapshot { if (typeof data !== 'object' || data === null) return false; const obj = data as Record; return ( typeof obj.version === 'number' && obj.version === SNAPSHOT_VERSION && typeof obj.teamName === 'string' && Array.isArray(obj.members) && obj.members.every(isValidMember) && (obj.syncModelsWithLead === undefined || typeof obj.syncModelsWithLead === 'boolean') && (obj.teammateWorktreeDefault === undefined || typeof obj.teammateWorktreeDefault === 'boolean') && (obj.cwdMode === 'project' || obj.cwdMode === 'custom') && typeof obj.selectedProjectPath === 'string' && typeof obj.customCwd === 'string' && typeof obj.soloTeam === 'boolean' && typeof obj.launchTeam === 'boolean' && typeof obj.teamColor === 'string' && typeof obj.updatedAt === 'number' ); } // --------------------------------------------------------------------------- // IDB availability tracking // --------------------------------------------------------------------------- let idbUnavailable = false; let idbUnavailableLogged = false; const fallbackStore = new Map(); function markIdbUnavailable(): void { if (!idbUnavailableLogged) { idbUnavailableLogged = true; console.warn( '[createTeamDraftStorage] IndexedDB unavailable, using in-memory storage for this session.' ); } idbUnavailable = true; } // --------------------------------------------------------------------------- // Core API // --------------------------------------------------------------------------- async function saveSnapshot(snapshot: CreateTeamDraftSnapshot): Promise { if (idbUnavailable) { fallbackStore.set(STORAGE_KEY, snapshot); return; } try { await set(STORAGE_KEY, snapshot); } catch { markIdbUnavailable(); fallbackStore.set(STORAGE_KEY, snapshot); } } async function loadSnapshot(): Promise { if (idbUnavailable) { return fallbackStore.get(STORAGE_KEY) ?? null; } try { const data = await get(STORAGE_KEY); if (data == null) return null; if (isValidSnapshot(data)) return data; // Invalid shape — discard silently void del(STORAGE_KEY); return null; } catch { markIdbUnavailable(); return fallbackStore.get(STORAGE_KEY) ?? null; } } async function deleteSnapshot(): Promise { if (idbUnavailable) { fallbackStore.delete(STORAGE_KEY); return; } try { await del(STORAGE_KEY); } catch { markIdbUnavailable(); fallbackStore.delete(STORAGE_KEY); } } // --------------------------------------------------------------------------- // Factory // --------------------------------------------------------------------------- function emptySnapshot(): CreateTeamDraftSnapshot { return { version: SNAPSHOT_VERSION, teamName: '', members: [], syncModelsWithLead: true, teammateWorktreeDefault: false, cwdMode: 'project', selectedProjectPath: '', customCwd: '', soloTeam: false, launchTeam: true, teamColor: '', updatedAt: Date.now(), }; } // --------------------------------------------------------------------------- // Export // --------------------------------------------------------------------------- export const createTeamDraftStorage = { saveSnapshot, loadSnapshot, deleteSnapshot, emptySnapshot, };