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 { MentionableTextarea } from '@renderer/components/ui/MentionableTextarea';
|
||||||
import { getTeamColorSet, getThemedBadge } from '@renderer/constants/teamColors';
|
import { getTeamColorSet, getThemedBadge } from '@renderer/constants/teamColors';
|
||||||
import { useChipDraftPersistence } from '@renderer/hooks/useChipDraftPersistence';
|
import { useChipDraftPersistence } from '@renderer/hooks/useChipDraftPersistence';
|
||||||
|
import { useCreateTeamDraft } from '@renderer/hooks/useCreateTeamDraft';
|
||||||
import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence';
|
import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence';
|
||||||
import { useFileListCacheWarmer } from '@renderer/hooks/useFileListCacheWarmer';
|
import { useFileListCacheWarmer } from '@renderer/hooks/useFileListCacheWarmer';
|
||||||
import { useTaskSuggestions } from '@renderer/hooks/useTaskSuggestions';
|
import { useTaskSuggestions } from '@renderer/hooks/useTaskSuggestions';
|
||||||
|
|
@ -43,8 +44,6 @@ import { SkipPermissionsCheckbox } from './SkipPermissionsCheckbox';
|
||||||
import { computeEffectiveTeamModel, TeamModelSelector } from './TeamModelSelector';
|
import { computeEffectiveTeamModel, TeamModelSelector } from './TeamModelSelector';
|
||||||
import { getNextSuggestedTeamName } from './teamNameSets';
|
import { getNextSuggestedTeamName } from './teamNameSets';
|
||||||
|
|
||||||
import type { MemberDraft } from '@renderer/components/team/members/membersEditorTypes';
|
|
||||||
|
|
||||||
const TEAM_COLOR_NAMES = [
|
const TEAM_COLOR_NAMES = [
|
||||||
'blue',
|
'blue',
|
||||||
'green',
|
'green',
|
||||||
|
|
@ -227,14 +226,33 @@ export const CreateTeamDialog = ({
|
||||||
}: CreateTeamDialogProps): React.JSX.Element => {
|
}: CreateTeamDialogProps): React.JSX.Element => {
|
||||||
const { isLight } = useTheme();
|
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 descriptionDraft = useDraftPersistence({ key: 'createTeam:description' });
|
||||||
const promptDraft = useDraftPersistence({ key: 'createTeam:prompt' });
|
const promptDraft = useDraftPersistence({ key: 'createTeam:prompt' });
|
||||||
const promptChipDraft = useChipDraftPersistence('createTeam:prompt:chips');
|
const promptChipDraft = useChipDraftPersistence('createTeam:prompt:chips');
|
||||||
const [members, setMembers] = useState<MemberDraft[]>([]);
|
|
||||||
const [cwdMode, setCwdMode] = useState<'project' | 'custom'>('project');
|
// ── Transient UI state (NOT persisted) ───────────────────────────────
|
||||||
const [selectedProjectPath, setSelectedProjectPath] = useState('');
|
|
||||||
const [customCwd, setCustomCwd] = useState('');
|
|
||||||
const [projects, setProjects] = useState<Project[]>([]);
|
const [projects, setProjects] = useState<Project[]>([]);
|
||||||
const [projectsLoading, setProjectsLoading] = useState(false);
|
const [projectsLoading, setProjectsLoading] = useState(false);
|
||||||
const [projectsError, setProjectsError] = useState<string | null>(null);
|
const [projectsError, setProjectsError] = useState<string | null>(null);
|
||||||
|
|
@ -250,9 +268,6 @@ export const CreateTeamDialog = ({
|
||||||
cwd?: string;
|
cwd?: string;
|
||||||
}>({});
|
}>({});
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
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 [conflictDismissed, setConflictDismissed] = useState(false);
|
||||||
const [selectedModel, setSelectedModelRaw] = useState(() => {
|
const [selectedModel, setSelectedModelRaw] = useState(() => {
|
||||||
const stored = localStorage.getItem('team:lastSelectedModel');
|
const stored = localStorage.getItem('team:lastSelectedModel');
|
||||||
|
|
@ -334,18 +349,11 @@ export const CreateTeamDialog = ({
|
||||||
};
|
};
|
||||||
|
|
||||||
const resetFormState = (): void => {
|
const resetFormState = (): void => {
|
||||||
setTeamName('');
|
clearDraft();
|
||||||
lastAutoDescriptionRef.current = null;
|
lastAutoDescriptionRef.current = null;
|
||||||
descriptionDraft.clearDraft();
|
descriptionDraft.clearDraft();
|
||||||
promptDraft.clearDraft();
|
promptDraft.clearDraft();
|
||||||
promptChipDraft.clearChipDraft();
|
promptChipDraft.clearChipDraft();
|
||||||
setMembers([]);
|
|
||||||
setTeamColor('');
|
|
||||||
setCwdMode('project');
|
|
||||||
setSelectedProjectPath('');
|
|
||||||
setCustomCwd('');
|
|
||||||
setLaunchTeam(true);
|
|
||||||
setSoloTeam(false);
|
|
||||||
resetUIState();
|
resetUIState();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -473,7 +481,7 @@ export const CreateTeamDialog = ({
|
||||||
}, [open, defaultProjectPath]);
|
}, [open, defaultProjectPath]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) {
|
if (!open || !draftLoaded) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -510,15 +518,17 @@ export const CreateTeamDialog = ({
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- initialData is checked once on open
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- initialData is checked once on open/draftLoaded
|
||||||
}, [open]);
|
}, [open, draftLoaded]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open || initialData) {
|
if (!open || initialData || !draftLoaded) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setTeamName((prev) => (prev.trim().length === 0 ? suggestedTeamName : prev));
|
if (teamName.trim().length === 0) {
|
||||||
}, [initialData, open, suggestedTeamName]);
|
setTeamName(suggestedTeamName);
|
||||||
|
}
|
||||||
|
}, [initialData, open, suggestedTeamName, draftLoaded]); // eslint-disable-line react-hooks/exhaustive-deps -- teamName read once
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open || initialData) {
|
if (!open || initialData) {
|
||||||
|
|
@ -1187,7 +1197,12 @@ export const CreateTeamDialog = ({
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
disabled={!canCreate || isSubmitting || (launchTeam && prepareState !== 'ready')}
|
disabled={
|
||||||
|
!canCreate ||
|
||||||
|
!draftLoaded ||
|
||||||
|
isSubmitting ||
|
||||||
|
(launchTeam && prepareState !== 'ready')
|
||||||
|
}
|
||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
>
|
>
|
||||||
{isSubmitting ? (
|
{isSubmitting ? (
|
||||||
|
|
|
||||||
|
|
@ -120,6 +120,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
||||||
const propsTeamName = props.teamName ?? '';
|
const propsTeamName = props.teamName ?? '';
|
||||||
const [selectedTeamName, setSelectedTeamName] = useState('');
|
const [selectedTeamName, setSelectedTeamName] = useState('');
|
||||||
const teamByName = useStore((s) => s.teamByName);
|
const teamByName = useStore((s) => s.teamByName);
|
||||||
|
const openDashboard = useStore((s) => s.openDashboard);
|
||||||
const teamOptions = useMemo(
|
const teamOptions = useMemo(
|
||||||
() =>
|
() =>
|
||||||
Object.values(teamByName)
|
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" />
|
<AlertTriangle className="mt-0.5 size-4 shrink-0 text-red-400" />
|
||||||
<div className="min-w-0 space-y-1">
|
<div className="min-w-0 space-y-1">
|
||||||
<p className="font-medium text-red-300">
|
<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>
|
||||||
<p className="text-red-300/80">
|
<p className="text-red-300/80">
|
||||||
{prepareMessage ?? 'Failed to prepare environment'}
|
{prepareMessage ?? 'Failed to prepare environment'}
|
||||||
|
|
@ -765,10 +766,21 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
<p className="text-[11px] text-[var(--color-text-muted)]">
|
<div className="flex items-center gap-2 pt-1">
|
||||||
Make sure <span className="font-mono">claude</span> CLI is installed and available
|
<p className="text-[11px] text-[var(--color-text-muted)]">
|
||||||
in PATH, then reopen this dialog.
|
Install Claude CLI from the Dashboard, then reopen this dialog.
|
||||||
</p>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,6 @@ import { useStore } from '@renderer/store';
|
||||||
import { filterTeamMessages } from '@renderer/utils/teamMessageFiltering';
|
import { filterTeamMessages } from '@renderer/utils/teamMessageFiltering';
|
||||||
import { toMessageKey } from '@renderer/utils/teamMessageKey';
|
import { toMessageKey } from '@renderer/utils/teamMessageKey';
|
||||||
import {
|
import {
|
||||||
Bell,
|
|
||||||
CheckCheck,
|
CheckCheck,
|
||||||
ChevronsDownUp,
|
ChevronsDownUp,
|
||||||
ChevronsUpDown,
|
ChevronsUpDown,
|
||||||
|
|
@ -427,23 +426,6 @@ export const MessagesPanel = memo(function MessagesPanel({
|
||||||
<TooltipContent side="bottom">Mark all as read</TooltipContent>
|
<TooltipContent side="bottom">Mark all as read</TooltipContent>
|
||||||
</Tooltip>
|
</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">
|
<div className="ml-auto flex items-center gap-1">
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
|
|
@ -596,24 +578,6 @@ export const MessagesPanel = memo(function MessagesPanel({
|
||||||
}
|
}
|
||||||
headerExtra={
|
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>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<Button
|
<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