feat(team): persist create-team form state across tab navigation
Add IndexedDB-backed draft persistence for CreateTeamDialog so that navigating away (e.g. to Dashboard to install CLI) and back preserves all form fields: team name, members, paths, solo/launch flags, color. - New createTeamDraftStorage service (IDB + in-memory fallback) - New useCreateTeamDraft hook (debounced save, flush on unmount, race-safe) - Gate useEffect for defaults/initialData on draftLoaded to prevent race - Block submit button until draft is loaded - Improve CLI-missing error in LaunchTeamDialog with Dashboard navigation - Remove notification bell button from MessagesPanel
This commit is contained in:
parent
9e87746b81
commit
40e9d7917c
5 changed files with 609 additions and 66 deletions
|
|
@ -25,6 +25,7 @@ import { Label } from '@renderer/components/ui/label';
|
|||
import { MentionableTextarea } from '@renderer/components/ui/MentionableTextarea';
|
||||
import { getTeamColorSet, getThemedBadge } from '@renderer/constants/teamColors';
|
||||
import { useChipDraftPersistence } from '@renderer/hooks/useChipDraftPersistence';
|
||||
import { useCreateTeamDraft } from '@renderer/hooks/useCreateTeamDraft';
|
||||
import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence';
|
||||
import { useFileListCacheWarmer } from '@renderer/hooks/useFileListCacheWarmer';
|
||||
import { useTaskSuggestions } from '@renderer/hooks/useTaskSuggestions';
|
||||
|
|
@ -43,8 +44,6 @@ import { SkipPermissionsCheckbox } from './SkipPermissionsCheckbox';
|
|||
import { computeEffectiveTeamModel, TeamModelSelector } from './TeamModelSelector';
|
||||
import { getNextSuggestedTeamName } from './teamNameSets';
|
||||
|
||||
import type { MemberDraft } from '@renderer/components/team/members/membersEditorTypes';
|
||||
|
||||
const TEAM_COLOR_NAMES = [
|
||||
'blue',
|
||||
'green',
|
||||
|
|
@ -227,14 +226,33 @@ export const CreateTeamDialog = ({
|
|||
}: CreateTeamDialogProps): React.JSX.Element => {
|
||||
const { isLight } = useTheme();
|
||||
|
||||
const [teamName, setTeamName] = useState('');
|
||||
// ── Persisted draft state (survives tab navigation) ──────────────────
|
||||
const {
|
||||
teamName,
|
||||
setTeamName,
|
||||
members,
|
||||
setMembers,
|
||||
cwdMode,
|
||||
setCwdMode,
|
||||
selectedProjectPath,
|
||||
setSelectedProjectPath,
|
||||
customCwd,
|
||||
setCustomCwd,
|
||||
soloTeam,
|
||||
setSoloTeam,
|
||||
launchTeam,
|
||||
setLaunchTeam,
|
||||
teamColor,
|
||||
setTeamColor,
|
||||
isLoaded: draftLoaded,
|
||||
clearDraft,
|
||||
} = useCreateTeamDraft();
|
||||
|
||||
const descriptionDraft = useDraftPersistence({ key: 'createTeam:description' });
|
||||
const promptDraft = useDraftPersistence({ key: 'createTeam:prompt' });
|
||||
const promptChipDraft = useChipDraftPersistence('createTeam:prompt:chips');
|
||||
const [members, setMembers] = useState<MemberDraft[]>([]);
|
||||
const [cwdMode, setCwdMode] = useState<'project' | 'custom'>('project');
|
||||
const [selectedProjectPath, setSelectedProjectPath] = useState('');
|
||||
const [customCwd, setCustomCwd] = useState('');
|
||||
|
||||
// ── Transient UI state (NOT persisted) ───────────────────────────────
|
||||
const [projects, setProjects] = useState<Project[]>([]);
|
||||
const [projectsLoading, setProjectsLoading] = useState(false);
|
||||
const [projectsError, setProjectsError] = useState<string | null>(null);
|
||||
|
|
@ -250,9 +268,6 @@ export const CreateTeamDialog = ({
|
|||
cwd?: string;
|
||||
}>({});
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [launchTeam, setLaunchTeam] = useState(true);
|
||||
const [soloTeam, setSoloTeam] = useState(false);
|
||||
const [teamColor, setTeamColor] = useState('');
|
||||
const [conflictDismissed, setConflictDismissed] = useState(false);
|
||||
const [selectedModel, setSelectedModelRaw] = useState(() => {
|
||||
const stored = localStorage.getItem('team:lastSelectedModel');
|
||||
|
|
@ -334,18 +349,11 @@ export const CreateTeamDialog = ({
|
|||
};
|
||||
|
||||
const resetFormState = (): void => {
|
||||
setTeamName('');
|
||||
clearDraft();
|
||||
lastAutoDescriptionRef.current = null;
|
||||
descriptionDraft.clearDraft();
|
||||
promptDraft.clearDraft();
|
||||
promptChipDraft.clearChipDraft();
|
||||
setMembers([]);
|
||||
setTeamColor('');
|
||||
setCwdMode('project');
|
||||
setSelectedProjectPath('');
|
||||
setCustomCwd('');
|
||||
setLaunchTeam(true);
|
||||
setSoloTeam(false);
|
||||
resetUIState();
|
||||
};
|
||||
|
||||
|
|
@ -473,7 +481,7 @@ export const CreateTeamDialog = ({
|
|||
}, [open, defaultProjectPath]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
if (!open || !draftLoaded) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -510,15 +518,17 @@ export const CreateTeamDialog = ({
|
|||
})
|
||||
)
|
||||
);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- initialData is checked once on open
|
||||
}, [open]);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- initialData is checked once on open/draftLoaded
|
||||
}, [open, draftLoaded]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || initialData) {
|
||||
if (!open || initialData || !draftLoaded) {
|
||||
return;
|
||||
}
|
||||
setTeamName((prev) => (prev.trim().length === 0 ? suggestedTeamName : prev));
|
||||
}, [initialData, open, suggestedTeamName]);
|
||||
if (teamName.trim().length === 0) {
|
||||
setTeamName(suggestedTeamName);
|
||||
}
|
||||
}, [initialData, open, suggestedTeamName, draftLoaded]); // eslint-disable-line react-hooks/exhaustive-deps -- teamName read once
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || initialData) {
|
||||
|
|
@ -1187,7 +1197,12 @@ export const CreateTeamDialog = ({
|
|||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={!canCreate || isSubmitting || (launchTeam && prepareState !== 'ready')}
|
||||
disabled={
|
||||
!canCreate ||
|
||||
!draftLoaded ||
|
||||
isSubmitting ||
|
||||
(launchTeam && prepareState !== 'ready')
|
||||
}
|
||||
onClick={handleSubmit}
|
||||
>
|
||||
{isSubmitting ? (
|
||||
|
|
|
|||
|
|
@ -120,6 +120,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
|||
const propsTeamName = props.teamName ?? '';
|
||||
const [selectedTeamName, setSelectedTeamName] = useState('');
|
||||
const teamByName = useStore((s) => s.teamByName);
|
||||
const openDashboard = useStore((s) => s.openDashboard);
|
||||
const teamOptions = useMemo(
|
||||
() =>
|
||||
Object.values(teamByName)
|
||||
|
|
@ -747,7 +748,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
|||
<AlertTriangle className="mt-0.5 size-4 shrink-0 text-red-400" />
|
||||
<div className="min-w-0 space-y-1">
|
||||
<p className="font-medium text-red-300">
|
||||
CLI environment is not available — launch is blocked
|
||||
Claude CLI is not installed — launch is blocked
|
||||
</p>
|
||||
<p className="text-red-300/80">
|
||||
{prepareMessage ?? 'Failed to prepare environment'}
|
||||
|
|
@ -765,10 +766,21 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
|||
))}
|
||||
</div>
|
||||
) : null}
|
||||
<p className="text-[11px] text-[var(--color-text-muted)]">
|
||||
Make sure <span className="font-mono">claude</span> CLI is installed and available
|
||||
in PATH, then reopen this dialog.
|
||||
</p>
|
||||
<div className="flex items-center gap-2 pt-1">
|
||||
<p className="text-[11px] text-[var(--color-text-muted)]">
|
||||
Install Claude CLI from the Dashboard, then reopen this dialog.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
className="shrink-0 rounded bg-blue-600 px-2 py-0.5 text-[11px] font-medium text-white transition-colors hover:bg-blue-500"
|
||||
onClick={() => {
|
||||
onClose();
|
||||
openDashboard();
|
||||
}}
|
||||
>
|
||||
Go to Dashboard
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -10,7 +10,6 @@ import { useStore } from '@renderer/store';
|
|||
import { filterTeamMessages } from '@renderer/utils/teamMessageFiltering';
|
||||
import { toMessageKey } from '@renderer/utils/teamMessageKey';
|
||||
import {
|
||||
Bell,
|
||||
CheckCheck,
|
||||
ChevronsDownUp,
|
||||
ChevronsUpDown,
|
||||
|
|
@ -427,23 +426,6 @@ export const MessagesPanel = memo(function MessagesPanel({
|
|||
<TooltipContent side="bottom">Mark all as read</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="size-6 p-0 text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)]"
|
||||
onClick={() => {
|
||||
void window.electronAPI.openExternal(
|
||||
'https://github.com/777genius/claude-notifications-go'
|
||||
);
|
||||
}}
|
||||
>
|
||||
<Bell size={12} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">Desktop notifications plugin</TooltipContent>
|
||||
</Tooltip>
|
||||
<div className="ml-auto flex items-center gap-1">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
|
|
@ -596,24 +578,6 @@ export const MessagesPanel = memo(function MessagesPanel({
|
|||
}
|
||||
headerExtra={
|
||||
<>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="pointer-events-auto size-6 p-0 text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)]"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
void window.electronAPI.openExternal(
|
||||
'https://github.com/777genius/claude-notifications-go'
|
||||
);
|
||||
}}
|
||||
>
|
||||
<Bell size={12} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">Desktop notifications plugin</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
|
|
|
|||
376
src/renderer/hooks/useCreateTeamDraft.ts
Normal file
376
src/renderer/hooks/useCreateTeamDraft.ts
Normal file
|
|
@ -0,0 +1,376 @@
|
|||
/**
|
||||
* 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 type { MemberDraft } from '@renderer/components/team/members/membersEditorTypes';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface UseCreateTeamDraftResult {
|
||||
teamName: string;
|
||||
setTeamName: (v: string) => void;
|
||||
members: MemberDraft[];
|
||||
setMembers: (v: MemberDraft[]) => 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 }) => ({
|
||||
id,
|
||||
name,
|
||||
roleSelection,
|
||||
customRole,
|
||||
workflow,
|
||||
}));
|
||||
}
|
||||
|
||||
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,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hook
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function useCreateTeamDraft(): UseCreateTeamDraftResult {
|
||||
// ── State ──────────────────────────────────────────────────────────────
|
||||
const [teamName, setTeamNameState] = useState('');
|
||||
const [members, setMembersState] = useState<MemberDraft[]>([]);
|
||||
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 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),
|
||||
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);
|
||||
|
||||
teamNameRef.current = snap.teamName;
|
||||
membersRef.current = deserialized;
|
||||
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);
|
||||
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 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(() => {
|
||||
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 = [];
|
||||
cwdModeRef.current = 'project';
|
||||
selectedProjectPathRef.current = '';
|
||||
customCwdRef.current = '';
|
||||
soloTeamRef.current = false;
|
||||
launchTeamRef.current = true;
|
||||
teamColorRef.current = '';
|
||||
|
||||
setTeamNameState('');
|
||||
setMembersState([]);
|
||||
setCwdModeState('project');
|
||||
setSelectedProjectPathState('');
|
||||
setCustomCwdState('');
|
||||
setSoloTeamState(false);
|
||||
setLaunchTeamState(true);
|
||||
setTeamColorState('');
|
||||
|
||||
void createTeamDraftStorage.deleteSnapshot();
|
||||
}, []);
|
||||
|
||||
return {
|
||||
teamName,
|
||||
setTeamName,
|
||||
members,
|
||||
setMembers,
|
||||
cwdMode,
|
||||
setCwdMode,
|
||||
selectedProjectPath,
|
||||
setSelectedProjectPath,
|
||||
customCwd,
|
||||
setCustomCwd,
|
||||
soloTeam,
|
||||
setSoloTeam,
|
||||
launchTeam,
|
||||
setLaunchTeam,
|
||||
teamColor,
|
||||
setTeamColor,
|
||||
isLoaded,
|
||||
clearDraft,
|
||||
};
|
||||
}
|
||||
176
src/renderer/services/createTeamDraftStorage.ts
Normal file
176
src/renderer/services/createTeamDraftStorage.ts
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
/**
|
||||
* 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';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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;
|
||||
}
|
||||
|
||||
export interface CreateTeamDraftSnapshot {
|
||||
version: number;
|
||||
teamName: string;
|
||||
members: SerializedMemberDraft[];
|
||||
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<string, unknown>;
|
||||
return (
|
||||
typeof obj.id === 'string' &&
|
||||
typeof obj.name === 'string' &&
|
||||
typeof obj.roleSelection === 'string' &&
|
||||
typeof obj.customRole === 'string'
|
||||
);
|
||||
}
|
||||
|
||||
function isValidSnapshot(data: unknown): data is CreateTeamDraftSnapshot {
|
||||
if (typeof data !== 'object' || data === null) return false;
|
||||
const obj = data as Record<string, unknown>;
|
||||
return (
|
||||
typeof obj.version === 'number' &&
|
||||
obj.version === SNAPSHOT_VERSION &&
|
||||
typeof obj.teamName === 'string' &&
|
||||
Array.isArray(obj.members) &&
|
||||
obj.members.every(isValidMember) &&
|
||||
(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<string, CreateTeamDraftSnapshot>();
|
||||
|
||||
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<void> {
|
||||
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<CreateTeamDraftSnapshot | null> {
|
||||
if (idbUnavailable) {
|
||||
return fallbackStore.get(STORAGE_KEY) ?? null;
|
||||
}
|
||||
try {
|
||||
const data = await get<unknown>(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<void> {
|
||||
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: [],
|
||||
cwdMode: 'project',
|
||||
selectedProjectPath: '',
|
||||
customCwd: '',
|
||||
soloTeam: false,
|
||||
launchTeam: true,
|
||||
teamColor: '',
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Export
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const createTeamDraftStorage = {
|
||||
saveSnapshot,
|
||||
loadSnapshot,
|
||||
deleteSnapshot,
|
||||
emptySnapshot,
|
||||
};
|
||||
Loading…
Reference in a new issue