446 lines
14 KiB
TypeScript
446 lines
14 KiB
TypeScript
/**
|
|
* Unified draft hook for CreateTeamDialog form state.
|
|
*
|
|
* Persists team name, members, paths, and flags to IndexedDB so that
|
|
* navigating away from the Teams tab and back preserves user input.
|
|
*
|
|
* Key guarantees:
|
|
* - Single IndexedDB key (`createTeamDraft:form`), no TTL.
|
|
* - Race-safe: late async load never overwrites fresh user input.
|
|
* - Debounced writes with immediate flush on unmount.
|
|
* - Draft is cleared only on successful team creation.
|
|
*
|
|
* Pattern mirrors `useComposerDraft.ts`.
|
|
*/
|
|
|
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
|
|
import { createMemberDraft } from '@renderer/components/team/members/membersEditorUtils';
|
|
import {
|
|
type CreateTeamDraftSnapshot,
|
|
createTeamDraftStorage,
|
|
type SerializedMemberDraft,
|
|
} from '@renderer/services/createTeamDraftStorage';
|
|
import {
|
|
getStoredCreateTeamSyncModelsWithLead,
|
|
setStoredCreateTeamMemberRuntimePreferences,
|
|
setStoredCreateTeamSyncModelsWithLead,
|
|
} from '@renderer/services/createTeamPreferences';
|
|
|
|
import type { MemberDraft } from '@renderer/components/team/members/membersEditorTypes';
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Types
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export interface UseCreateTeamDraftResult {
|
|
teamName: string;
|
|
setTeamName: (v: string) => void;
|
|
members: MemberDraft[];
|
|
setMembers: (v: MemberDraft[]) => void;
|
|
syncModelsWithLead: boolean;
|
|
setSyncModelsWithLead: (v: boolean, options?: { persistStoredPreference?: boolean }) => void;
|
|
teammateWorktreeDefault: boolean;
|
|
setTeammateWorktreeDefault: (v: boolean) => void;
|
|
cwdMode: 'project' | 'custom';
|
|
setCwdMode: (v: 'project' | 'custom') => void;
|
|
selectedProjectPath: string;
|
|
setSelectedProjectPath: (v: string) => void;
|
|
customCwd: string;
|
|
setCustomCwd: (v: string) => void;
|
|
soloTeam: boolean;
|
|
setSoloTeam: (v: boolean) => void;
|
|
launchTeam: boolean;
|
|
setLaunchTeam: (v: boolean) => void;
|
|
teamColor: string;
|
|
setTeamColor: (v: string) => void;
|
|
|
|
/** `true` after the initial IndexedDB load completes. */
|
|
isLoaded: boolean;
|
|
/** Clear all draft state and delete the IndexedDB entry. */
|
|
clearDraft: () => void;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Constants
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const DEBOUNCE_MS = 400;
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Serialization helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function serializeMembers(members: MemberDraft[]): SerializedMemberDraft[] {
|
|
return members.map(
|
|
({ id, name, roleSelection, customRole, workflow, isolation, providerId, model, effort }) => ({
|
|
id,
|
|
name,
|
|
roleSelection,
|
|
customRole,
|
|
workflow,
|
|
isolation,
|
|
providerId,
|
|
model,
|
|
effort,
|
|
})
|
|
);
|
|
}
|
|
|
|
function deserializeMembers(serialized: SerializedMemberDraft[]): MemberDraft[] {
|
|
return serialized.map((m) =>
|
|
createMemberDraft({
|
|
id: m.id,
|
|
name: m.name,
|
|
roleSelection: m.roleSelection,
|
|
customRole: m.customRole,
|
|
workflow: m.workflow,
|
|
isolation: m.isolation === 'worktree' ? 'worktree' : undefined,
|
|
providerId: m.providerId,
|
|
model: m.model,
|
|
effort: m.effort,
|
|
})
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Hook
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export function useCreateTeamDraft(): UseCreateTeamDraftResult {
|
|
const storedSyncModelsWithLead = getStoredCreateTeamSyncModelsWithLead();
|
|
|
|
// ── State ──────────────────────────────────────────────────────────────
|
|
const [teamName, setTeamNameState] = useState('');
|
|
const [members, setMembersState] = useState<MemberDraft[]>([]);
|
|
const [syncModelsWithLead, setSyncModelsWithLeadState] = useState(storedSyncModelsWithLead);
|
|
const [teammateWorktreeDefault, setTeammateWorktreeDefaultState] = useState(false);
|
|
const [cwdMode, setCwdModeState] = useState<'project' | 'custom'>('project');
|
|
const [selectedProjectPath, setSelectedProjectPathState] = useState('');
|
|
const [customCwd, setCustomCwdState] = useState('');
|
|
const [soloTeam, setSoloTeamState] = useState(false);
|
|
const [launchTeam, setLaunchTeamState] = useState(true);
|
|
const [teamColor, setTeamColorState] = useState('');
|
|
const [isLoaded, setIsLoaded] = useState(false);
|
|
|
|
// ── Refs (latest values for debounced callbacks) ───────────────────────
|
|
const teamNameRef = useRef('');
|
|
const membersRef = useRef<MemberDraft[]>([]);
|
|
const syncModelsWithLeadRef = useRef(storedSyncModelsWithLead);
|
|
const teammateWorktreeDefaultRef = useRef(false);
|
|
const cwdModeRef = useRef<'project' | 'custom'>('project');
|
|
const selectedProjectPathRef = useRef('');
|
|
const customCwdRef = useRef('');
|
|
const soloTeamRef = useRef(false);
|
|
const launchTeamRef = useRef(true);
|
|
const teamColorRef = useRef('');
|
|
const mountedRef = useRef(true);
|
|
const userTouchedRef = useRef(false);
|
|
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
const pendingRef = useRef<CreateTeamDraftSnapshot | null>(null);
|
|
|
|
useEffect(() => {
|
|
mountedRef.current = true;
|
|
return () => {
|
|
mountedRef.current = false;
|
|
};
|
|
}, []);
|
|
|
|
// ── Snapshot builder ───────────────────────────────────────────────────
|
|
|
|
const buildSnapshot = useCallback((): CreateTeamDraftSnapshot => {
|
|
return {
|
|
version: 1,
|
|
teamName: teamNameRef.current,
|
|
members: serializeMembers(membersRef.current),
|
|
syncModelsWithLead: syncModelsWithLeadRef.current,
|
|
teammateWorktreeDefault: teammateWorktreeDefaultRef.current,
|
|
cwdMode: cwdModeRef.current,
|
|
selectedProjectPath: selectedProjectPathRef.current,
|
|
customCwd: customCwdRef.current,
|
|
soloTeam: soloTeamRef.current,
|
|
launchTeam: launchTeamRef.current,
|
|
teamColor: teamColorRef.current,
|
|
updatedAt: Date.now(),
|
|
};
|
|
}, []);
|
|
|
|
// ── Flush / schedule ───────────────────────────────────────────────────
|
|
|
|
const flushPending = useCallback(() => {
|
|
if (timerRef.current != null) {
|
|
clearTimeout(timerRef.current);
|
|
timerRef.current = null;
|
|
}
|
|
if (pendingRef.current != null) {
|
|
const pending = pendingRef.current;
|
|
pendingRef.current = null;
|
|
const isEmpty = pending.teamName === '' && pending.members.length === 0;
|
|
if (isEmpty) {
|
|
void createTeamDraftStorage.deleteSnapshot();
|
|
} else {
|
|
void createTeamDraftStorage.saveSnapshot(pending);
|
|
}
|
|
}
|
|
}, []);
|
|
|
|
const scheduleSave = useCallback(() => {
|
|
const snapshot = buildSnapshot();
|
|
pendingRef.current = snapshot;
|
|
|
|
if (timerRef.current != null) {
|
|
clearTimeout(timerRef.current);
|
|
}
|
|
|
|
timerRef.current = setTimeout(() => {
|
|
timerRef.current = null;
|
|
const pending = pendingRef.current;
|
|
pendingRef.current = null;
|
|
if (pending == null) return;
|
|
|
|
const isEmpty = pending.teamName === '' && pending.members.length === 0;
|
|
if (isEmpty) {
|
|
void createTeamDraftStorage.deleteSnapshot();
|
|
} else {
|
|
void createTeamDraftStorage.saveSnapshot(pending);
|
|
}
|
|
}, DEBOUNCE_MS);
|
|
}, [buildSnapshot]);
|
|
|
|
// ── Apply snapshot to state ────────────────────────────────────────────
|
|
|
|
const applySnapshot = useCallback((snap: CreateTeamDraftSnapshot) => {
|
|
const deserialized = deserializeMembers(snap.members);
|
|
const nextSyncModelsWithLead =
|
|
snap.syncModelsWithLead ?? getStoredCreateTeamSyncModelsWithLead();
|
|
|
|
setStoredCreateTeamSyncModelsWithLead(nextSyncModelsWithLead);
|
|
if (!nextSyncModelsWithLead) {
|
|
setStoredCreateTeamMemberRuntimePreferences(deserialized);
|
|
}
|
|
|
|
teamNameRef.current = snap.teamName;
|
|
membersRef.current = deserialized;
|
|
syncModelsWithLeadRef.current = nextSyncModelsWithLead;
|
|
teammateWorktreeDefaultRef.current = snap.teammateWorktreeDefault === true;
|
|
cwdModeRef.current = snap.cwdMode;
|
|
selectedProjectPathRef.current = snap.selectedProjectPath;
|
|
customCwdRef.current = snap.customCwd;
|
|
soloTeamRef.current = snap.soloTeam;
|
|
launchTeamRef.current = snap.launchTeam;
|
|
teamColorRef.current = snap.teamColor;
|
|
|
|
setTeamNameState(snap.teamName);
|
|
setMembersState(deserialized);
|
|
setSyncModelsWithLeadState(nextSyncModelsWithLead);
|
|
setTeammateWorktreeDefaultState(snap.teammateWorktreeDefault === true);
|
|
setCwdModeState(snap.cwdMode);
|
|
setSelectedProjectPathState(snap.selectedProjectPath);
|
|
setCustomCwdState(snap.customCwd);
|
|
setSoloTeamState(snap.soloTeam);
|
|
setLaunchTeamState(snap.launchTeam);
|
|
setTeamColorState(snap.teamColor);
|
|
}, []);
|
|
|
|
// ── Load on mount ──────────────────────────────────────────────────────
|
|
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
|
|
void (async () => {
|
|
const snapshot = await createTeamDraftStorage.loadSnapshot();
|
|
if (cancelled) return;
|
|
|
|
// Race protection: if user already interacted, don't overwrite
|
|
if (userTouchedRef.current) {
|
|
if (mountedRef.current) setIsLoaded(true);
|
|
return;
|
|
}
|
|
|
|
if (snapshot != null) {
|
|
applySnapshot(snapshot);
|
|
}
|
|
|
|
if (mountedRef.current) setIsLoaded(true);
|
|
})();
|
|
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [applySnapshot]);
|
|
|
|
// Flush on unmount
|
|
useEffect(() => {
|
|
return () => {
|
|
flushPending();
|
|
};
|
|
}, [flushPending]);
|
|
|
|
// ── Setters ────────────────────────────────────────────────────────────
|
|
|
|
const setTeamName = useCallback(
|
|
(v: string) => {
|
|
userTouchedRef.current = true;
|
|
teamNameRef.current = v;
|
|
setTeamNameState(v);
|
|
scheduleSave();
|
|
},
|
|
[scheduleSave]
|
|
);
|
|
|
|
const setMembers = useCallback(
|
|
(v: MemberDraft[]) => {
|
|
userTouchedRef.current = true;
|
|
membersRef.current = v;
|
|
setMembersState(v);
|
|
scheduleSave();
|
|
},
|
|
[scheduleSave]
|
|
);
|
|
|
|
const setSyncModelsWithLead = useCallback(
|
|
(v: boolean, options?: { persistStoredPreference?: boolean }) => {
|
|
userTouchedRef.current = true;
|
|
syncModelsWithLeadRef.current = v;
|
|
setSyncModelsWithLeadState(v);
|
|
if (options?.persistStoredPreference !== false) {
|
|
setStoredCreateTeamSyncModelsWithLead(v);
|
|
}
|
|
scheduleSave();
|
|
},
|
|
[scheduleSave]
|
|
);
|
|
|
|
const setTeammateWorktreeDefault = useCallback(
|
|
(v: boolean) => {
|
|
userTouchedRef.current = true;
|
|
teammateWorktreeDefaultRef.current = v;
|
|
setTeammateWorktreeDefaultState(v);
|
|
scheduleSave();
|
|
},
|
|
[scheduleSave]
|
|
);
|
|
|
|
const setCwdMode = useCallback(
|
|
(v: 'project' | 'custom') => {
|
|
userTouchedRef.current = true;
|
|
cwdModeRef.current = v;
|
|
setCwdModeState(v);
|
|
scheduleSave();
|
|
},
|
|
[scheduleSave]
|
|
);
|
|
|
|
const setSelectedProjectPath = useCallback(
|
|
(v: string) => {
|
|
userTouchedRef.current = true;
|
|
selectedProjectPathRef.current = v;
|
|
setSelectedProjectPathState(v);
|
|
scheduleSave();
|
|
},
|
|
[scheduleSave]
|
|
);
|
|
|
|
const setCustomCwd = useCallback(
|
|
(v: string) => {
|
|
userTouchedRef.current = true;
|
|
customCwdRef.current = v;
|
|
setCustomCwdState(v);
|
|
scheduleSave();
|
|
},
|
|
[scheduleSave]
|
|
);
|
|
|
|
const setSoloTeam = useCallback(
|
|
(v: boolean) => {
|
|
userTouchedRef.current = true;
|
|
soloTeamRef.current = v;
|
|
setSoloTeamState(v);
|
|
scheduleSave();
|
|
},
|
|
[scheduleSave]
|
|
);
|
|
|
|
const setLaunchTeam = useCallback(
|
|
(v: boolean) => {
|
|
userTouchedRef.current = true;
|
|
launchTeamRef.current = v;
|
|
setLaunchTeamState(v);
|
|
scheduleSave();
|
|
},
|
|
[scheduleSave]
|
|
);
|
|
|
|
const setTeamColor = useCallback(
|
|
(v: string) => {
|
|
userTouchedRef.current = true;
|
|
teamColorRef.current = v;
|
|
setTeamColorState(v);
|
|
scheduleSave();
|
|
},
|
|
[scheduleSave]
|
|
);
|
|
|
|
// ── Clear all ──────────────────────────────────────────────────────────
|
|
|
|
const clearDraft = useCallback(() => {
|
|
const nextStoredSyncModelsWithLead = getStoredCreateTeamSyncModelsWithLead();
|
|
if (timerRef.current != null) {
|
|
clearTimeout(timerRef.current);
|
|
timerRef.current = null;
|
|
}
|
|
pendingRef.current = null;
|
|
// Keep userTouchedRef true so a late IDB load (theoretically impossible
|
|
// since isLoaded gates submit, but defensive) cannot resurrect deleted data.
|
|
userTouchedRef.current = true;
|
|
|
|
teamNameRef.current = '';
|
|
membersRef.current = [];
|
|
syncModelsWithLeadRef.current = nextStoredSyncModelsWithLead;
|
|
teammateWorktreeDefaultRef.current = false;
|
|
cwdModeRef.current = 'project';
|
|
selectedProjectPathRef.current = '';
|
|
customCwdRef.current = '';
|
|
soloTeamRef.current = false;
|
|
launchTeamRef.current = true;
|
|
teamColorRef.current = '';
|
|
|
|
setTeamNameState('');
|
|
setMembersState([]);
|
|
setSyncModelsWithLeadState(nextStoredSyncModelsWithLead);
|
|
setTeammateWorktreeDefaultState(false);
|
|
setCwdModeState('project');
|
|
setSelectedProjectPathState('');
|
|
setCustomCwdState('');
|
|
setSoloTeamState(false);
|
|
setLaunchTeamState(true);
|
|
setTeamColorState('');
|
|
|
|
void createTeamDraftStorage.deleteSnapshot();
|
|
}, []);
|
|
|
|
return {
|
|
teamName,
|
|
setTeamName,
|
|
members,
|
|
setMembers,
|
|
syncModelsWithLead,
|
|
setSyncModelsWithLead,
|
|
teammateWorktreeDefault,
|
|
setTeammateWorktreeDefault,
|
|
cwdMode,
|
|
setCwdMode,
|
|
selectedProjectPath,
|
|
setSelectedProjectPath,
|
|
customCwd,
|
|
setCustomCwd,
|
|
soloTeam,
|
|
setSoloTeam,
|
|
launchTeam,
|
|
setLaunchTeam,
|
|
teamColor,
|
|
setTeamColor,
|
|
isLoaded,
|
|
clearDraft,
|
|
};
|
|
}
|