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:
iliya 2026-03-22 15:02:07 +02:00
parent 9e87746b81
commit 40e9d7917c
5 changed files with 609 additions and 66 deletions

View file

@ -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 ? (

View file

@ -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>

View file

@ -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

View 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,
};
}

View 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,
};