feat: enhance message handling and improve UI components
- Updated SendMessageDialog and MessageComposer to support team-specific draft persistence for messages and attachments. - Refactored logic for attachment handling to ensure proper validation based on team status. - Improved MemberLogsTab to sort logs and manage expanded log details more effectively. - Enhanced useDraftPersistence and useChipDraftPersistence hooks to manage draft states with improved key handling. - Added debounced save functionality for draft persistence to prevent stale data issues.
This commit is contained in:
parent
afb0173c05
commit
82bea01e0f
6 changed files with 173 additions and 105 deletions
|
|
@ -92,8 +92,8 @@ export const SendMessageDialog = ({
|
||||||
const [quote, setQuote] = useState<QuotedMessage | undefined>(undefined);
|
const [quote, setQuote] = useState<QuotedMessage | undefined>(undefined);
|
||||||
const [quoteExpanded, setQuoteExpanded] = useState(false);
|
const [quoteExpanded, setQuoteExpanded] = useState(false);
|
||||||
const [member, setMember] = useState('');
|
const [member, setMember] = useState('');
|
||||||
const textDraft = useDraftPersistence({ key: 'sendMessage:text' });
|
const textDraft = useDraftPersistence({ key: `sendMessage:${teamName}:text` });
|
||||||
const chipDraft = useChipDraftPersistence('sendMessage:chips');
|
const chipDraft = useChipDraftPersistence(`sendMessage:${teamName}:chips`);
|
||||||
const [summary, setSummary] = useState('');
|
const [summary, setSummary] = useState('');
|
||||||
const prevOpenRef = useRef(false);
|
const prevOpenRef = useRef(false);
|
||||||
const prevResultRef = useRef<SendMessageResult | null>(null);
|
const prevResultRef = useRef<SendMessageResult | null>(null);
|
||||||
|
|
@ -115,7 +115,8 @@ export const SendMessageDialog = ({
|
||||||
|
|
||||||
const selectedMember = members.find((m) => m.name === member);
|
const selectedMember = members.find((m) => m.name === member);
|
||||||
const isLeadRecipient = selectedMember?.role === 'lead' || selectedMember?.name === 'team-lead';
|
const isLeadRecipient = selectedMember?.role === 'lead' || selectedMember?.name === 'team-lead';
|
||||||
const canAttach = isLeadRecipient && isTeamAlive && canAddMore;
|
const supportsAttachments = isLeadRecipient && !!isTeamAlive;
|
||||||
|
const canAttach = supportsAttachments && canAddMore;
|
||||||
|
|
||||||
const [pendingAutoClose, setPendingAutoClose] = useState(false);
|
const [pendingAutoClose, setPendingAutoClose] = useState(false);
|
||||||
// Reset form on open transition (avoid setState in render)
|
// Reset form on open transition (avoid setState in render)
|
||||||
|
|
@ -174,7 +175,7 @@ export const SendMessageDialog = ({
|
||||||
[members, colorMap]
|
[members, colorMap]
|
||||||
);
|
);
|
||||||
|
|
||||||
const attachmentsBlocked = attachments.length > 0 && !isLeadRecipient;
|
const attachmentsBlocked = attachments.length > 0 && !supportsAttachments;
|
||||||
|
|
||||||
const canSend =
|
const canSend =
|
||||||
member.trim().length > 0 &&
|
member.trim().length > 0 &&
|
||||||
|
|
@ -363,7 +364,7 @@ export const SendMessageDialog = ({
|
||||||
onRemove={removeAttachment}
|
onRemove={removeAttachment}
|
||||||
error={attachmentError}
|
error={attachmentError}
|
||||||
disabled={attachmentsBlocked}
|
disabled={attachmentsBlocked}
|
||||||
disabledHint="Image attachments are only supported when sending to team lead. Remove attachments or switch recipient."
|
disabledHint="Image attachments are only supported when sending to the team lead while the team is online. Remove attachments or switch recipient."
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className={quote ? 'flex flex-col' : 'contents'}>
|
<div className={quote ? 'flex flex-col' : 'contents'}>
|
||||||
|
|
|
||||||
|
|
@ -53,11 +53,43 @@ export const MemberLogsTab = ({
|
||||||
const [detailChunks, setDetailChunks] = useState<EnhancedChunk[] | null>(null);
|
const [detailChunks, setDetailChunks] = useState<EnhancedChunk[] | null>(null);
|
||||||
const [detailLoading, setDetailLoading] = useState(false);
|
const [detailLoading, setDetailLoading] = useState(false);
|
||||||
|
|
||||||
|
const getRowId = useCallback((log: MemberLogSummary): string => {
|
||||||
|
return log.kind === 'subagent'
|
||||||
|
? `subagent:${log.sessionId}:${log.subagentId}`
|
||||||
|
: `lead:${log.sessionId}`;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const sortedLogs = useMemo(() => {
|
||||||
|
const withIndex = logs.map((log, index) => ({ log, index }));
|
||||||
|
withIndex.sort((a, b) => {
|
||||||
|
const aTime = new Date(a.log.startTime).getTime();
|
||||||
|
const bTime = new Date(b.log.startTime).getTime();
|
||||||
|
if (Number.isFinite(aTime) && Number.isFinite(bTime) && aTime !== bTime) return bTime - aTime;
|
||||||
|
if (Number.isFinite(aTime) && !Number.isFinite(bTime)) return -1;
|
||||||
|
if (!Number.isFinite(aTime) && Number.isFinite(bTime)) return 1;
|
||||||
|
return a.index - b.index;
|
||||||
|
});
|
||||||
|
return withIndex.map((x) => x.log);
|
||||||
|
}, [logs]);
|
||||||
|
|
||||||
|
const expandedLogSummary = useMemo(() => {
|
||||||
|
if (!expandedId) return null;
|
||||||
|
return logs.find((log) => getRowId(log) === expandedId) ?? null;
|
||||||
|
}, [expandedId, getRowId, logs]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
onRefreshingChange?.(refreshing);
|
onRefreshingChange?.(refreshing);
|
||||||
return () => onRefreshingChange?.(false);
|
return () => onRefreshingChange?.(false);
|
||||||
}, [refreshing, onRefreshingChange]);
|
}, [refreshing, onRefreshingChange]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!expandedId) return;
|
||||||
|
if (expandedLogSummary) return;
|
||||||
|
setExpandedId(null);
|
||||||
|
setDetailChunks(null);
|
||||||
|
setDetailLoading(false);
|
||||||
|
}, [expandedId, expandedLogSummary]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
const shouldAutoRefresh = taskId != null && taskStatus === 'in_progress';
|
const shouldAutoRefresh = taskId != null && taskStatus === 'in_progress';
|
||||||
|
|
@ -84,7 +116,7 @@ export const MemberLogsTab = ({
|
||||||
})
|
})
|
||||||
: await api.teams.getMemberLogs(teamName, memberName!);
|
: await api.teams.getMemberLogs(teamName, memberName!);
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
setLogs(result);
|
setLogs(Array.isArray(result) ? [...result] : []);
|
||||||
hasLoadedRef.current = true;
|
hasLoadedRef.current = true;
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
@ -110,12 +142,42 @@ export const MemberLogsTab = ({
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- intervalsKey drives refresh; deps intentionally minimal to avoid refetch loops
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- intervalsKey drives refresh; deps intentionally minimal to avoid refetch loops
|
||||||
}, [teamName, memberName, taskId, taskOwner, taskStatus, intervalsKey]);
|
}, [teamName, memberName, taskId, taskOwner, taskStatus, intervalsKey]);
|
||||||
|
|
||||||
|
const fetchDetailForLog = useCallback(async (log: MemberLogSummary): Promise<EnhancedChunk[] | null> => {
|
||||||
|
if (log.kind === 'subagent') {
|
||||||
|
const d = await api.getSubagentDetail(log.projectId, log.sessionId, log.subagentId);
|
||||||
|
return (d?.chunks ?? null) as EnhancedChunk[] | null;
|
||||||
|
}
|
||||||
|
const d = await api.getSessionDetail(log.projectId, log.sessionId);
|
||||||
|
return (d?.chunks ?? null) as unknown as EnhancedChunk[] | null;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const shouldAutoRefreshSummary = taskId != null && taskStatus === 'in_progress';
|
||||||
|
if (!expandedLogSummary) return;
|
||||||
|
if (!shouldAutoRefreshSummary && !expandedLogSummary.isOngoing) return;
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
const interval = setInterval(async () => {
|
||||||
|
try {
|
||||||
|
const next = await fetchDetailForLog(expandedLogSummary);
|
||||||
|
if (cancelled) return;
|
||||||
|
// Ensure new reference so memoized transforms update.
|
||||||
|
setDetailChunks(next ? [...next] : null);
|
||||||
|
} catch {
|
||||||
|
// Keep last successful data; avoid flicker during transient errors.
|
||||||
|
}
|
||||||
|
}, 5000);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
clearInterval(interval);
|
||||||
|
};
|
||||||
|
}, [expandedLogSummary, fetchDetailForLog, taskId, taskStatus]);
|
||||||
|
|
||||||
const handleExpand = useCallback(
|
const handleExpand = useCallback(
|
||||||
async (log: MemberLogSummary) => {
|
async (log: MemberLogSummary) => {
|
||||||
const rowId =
|
const rowId = getRowId(log);
|
||||||
log.kind === 'subagent'
|
|
||||||
? `subagent:${log.sessionId}:${log.subagentId}`
|
|
||||||
: `lead:${log.sessionId}`;
|
|
||||||
|
|
||||||
if (expandedId === rowId) {
|
if (expandedId === rowId) {
|
||||||
setExpandedId(null);
|
setExpandedId(null);
|
||||||
|
|
@ -126,20 +188,15 @@ export const MemberLogsTab = ({
|
||||||
setDetailChunks(null);
|
setDetailChunks(null);
|
||||||
setDetailLoading(true);
|
setDetailLoading(true);
|
||||||
try {
|
try {
|
||||||
if (log.kind === 'subagent') {
|
const chunks = await fetchDetailForLog(log);
|
||||||
const d = await api.getSubagentDetail(log.projectId, log.sessionId, log.subagentId);
|
setDetailChunks(chunks ? [...chunks] : null);
|
||||||
setDetailChunks(d?.chunks ?? null);
|
|
||||||
} else {
|
|
||||||
const d = await api.getSessionDetail(log.projectId, log.sessionId);
|
|
||||||
setDetailChunks((d?.chunks ?? null) as unknown as EnhancedChunk[] | null);
|
|
||||||
}
|
|
||||||
} catch {
|
} catch {
|
||||||
setDetailChunks(null);
|
setDetailChunks(null);
|
||||||
} finally {
|
} finally {
|
||||||
setDetailLoading(false);
|
setDetailLoading(false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[expandedId]
|
[expandedId, fetchDetailForLog, getRowId]
|
||||||
);
|
);
|
||||||
|
|
||||||
if (loading && logs.length === 0) {
|
if (loading && logs.length === 0) {
|
||||||
|
|
@ -178,31 +235,16 @@ export const MemberLogsTab = ({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full min-w-0 space-y-1.5">
|
<div className="w-full min-w-0 space-y-1.5">
|
||||||
{logs.map((log) => (
|
{sortedLogs.map((log) => (
|
||||||
<LogCard
|
<LogCard
|
||||||
key={
|
key={getRowId(log)}
|
||||||
log.kind === 'subagent' ? `${log.sessionId}-${log.subagentId}` : `lead-${log.sessionId}`
|
|
||||||
}
|
|
||||||
log={log}
|
log={log}
|
||||||
expanded={
|
expanded={expandedId === getRowId(log)}
|
||||||
expandedId ===
|
|
||||||
(log.kind === 'subagent'
|
|
||||||
? `subagent:${log.sessionId}:${log.subagentId}`
|
|
||||||
: `lead:${log.sessionId}`)
|
|
||||||
}
|
|
||||||
detailChunks={
|
detailChunks={
|
||||||
expandedId ===
|
expandedId === getRowId(log) ? detailChunks : null
|
||||||
(log.kind === 'subagent'
|
|
||||||
? `subagent:${log.sessionId}:${log.subagentId}`
|
|
||||||
: `lead:${log.sessionId}`)
|
|
||||||
? detailChunks
|
|
||||||
: null
|
|
||||||
}
|
}
|
||||||
detailLoading={
|
detailLoading={
|
||||||
expandedId ===
|
expandedId === getRowId(log) && detailLoading
|
||||||
(log.kind === 'subagent'
|
|
||||||
? `subagent:${log.sessionId}:${log.subagentId}`
|
|
||||||
: `lead:${log.sessionId}`) && detailLoading
|
|
||||||
}
|
}
|
||||||
onToggle={() => void handleExpand(log)}
|
onToggle={() => void handleExpand(log)}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -152,8 +152,9 @@ export const MessageComposer = ({
|
||||||
const leadContext = useStore((s) =>
|
const leadContext = useStore((s) =>
|
||||||
isLeadAgentRecipient ? s.leadContextByTeam[teamName] : undefined
|
isLeadAgentRecipient ? s.leadContextByTeam[teamName] : undefined
|
||||||
);
|
);
|
||||||
const canAttach = isLeadRecipient && isTeamAlive && canAddMore;
|
const supportsAttachments = isLeadRecipient && !!isTeamAlive;
|
||||||
const attachmentsBlocked = attachments.length > 0 && !isLeadRecipient;
|
const canAttach = supportsAttachments && canAddMore;
|
||||||
|
const attachmentsBlocked = attachments.length > 0 && !supportsAttachments;
|
||||||
const canSend =
|
const canSend =
|
||||||
recipient.length > 0 &&
|
recipient.length > 0 &&
|
||||||
trimmed.length > 0 &&
|
trimmed.length > 0 &&
|
||||||
|
|
@ -399,7 +400,7 @@ export const MessageComposer = ({
|
||||||
onRemove={removeAttachment}
|
onRemove={removeAttachment}
|
||||||
error={attachmentError}
|
error={attachmentError}
|
||||||
disabled={attachmentsBlocked}
|
disabled={attachmentsBlocked}
|
||||||
disabledHint="Image attachments are only supported when sending to team lead. Remove attachments or switch recipient."
|
disabledHint="Image attachments are only supported when sending to the team lead while the team is online. Remove attachments or switch recipient."
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<MentionableTextarea
|
<MentionableTextarea
|
||||||
|
|
|
||||||
|
|
@ -52,7 +52,7 @@ export function useAttachments(options?: UseAttachmentsOptions): UseAttachmentsR
|
||||||
|
|
||||||
const attachmentsRef = useRef<AttachmentPayload[]>([]);
|
const attachmentsRef = useRef<AttachmentPayload[]>([]);
|
||||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
const pendingRef = useRef<AttachmentPayload[] | null>(null);
|
const pendingRef = useRef<{ key: string; value: AttachmentPayload[] } | null>(null);
|
||||||
const keyRef = useRef(persistenceKey);
|
const keyRef = useRef(persistenceKey);
|
||||||
keyRef.current = persistenceKey;
|
keyRef.current = persistenceKey;
|
||||||
|
|
||||||
|
|
@ -67,7 +67,7 @@ export function useAttachments(options?: UseAttachmentsOptions): UseAttachmentsR
|
||||||
const key = keyRef.current;
|
const key = keyRef.current;
|
||||||
if (!key) return;
|
if (!key) return;
|
||||||
|
|
||||||
pendingRef.current = nextAttachments;
|
pendingRef.current = { key, value: nextAttachments };
|
||||||
|
|
||||||
if (timerRef.current != null) {
|
if (timerRef.current != null) {
|
||||||
clearTimeout(timerRef.current);
|
clearTimeout(timerRef.current);
|
||||||
|
|
@ -79,10 +79,10 @@ export function useAttachments(options?: UseAttachmentsOptions): UseAttachmentsR
|
||||||
pendingRef.current = null;
|
pendingRef.current = null;
|
||||||
if (pending == null) return;
|
if (pending == null) return;
|
||||||
|
|
||||||
if (pending.length === 0) {
|
if (pending.value.length === 0) {
|
||||||
void draftStorage.deleteDraft(key);
|
void draftStorage.deleteDraft(pending.key);
|
||||||
} else {
|
} else {
|
||||||
void draftStorage.saveDraft(key, JSON.stringify(pending));
|
void draftStorage.saveDraft(pending.key, JSON.stringify(pending.value));
|
||||||
}
|
}
|
||||||
}, DEBOUNCE_MS);
|
}, DEBOUNCE_MS);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
@ -93,14 +93,12 @@ export function useAttachments(options?: UseAttachmentsOptions): UseAttachmentsR
|
||||||
timerRef.current = null;
|
timerRef.current = null;
|
||||||
}
|
}
|
||||||
if (pendingRef.current != null) {
|
if (pendingRef.current != null) {
|
||||||
const val = pendingRef.current;
|
const pending = pendingRef.current;
|
||||||
const key = keyRef.current;
|
|
||||||
pendingRef.current = null;
|
pendingRef.current = null;
|
||||||
if (!key) return;
|
if (pending.value.length === 0) {
|
||||||
if (val.length === 0) {
|
void draftStorage.deleteDraft(pending.key);
|
||||||
void draftStorage.deleteDraft(key);
|
|
||||||
} else {
|
} else {
|
||||||
void draftStorage.saveDraft(key, JSON.stringify(val));
|
void draftStorage.saveDraft(pending.key, JSON.stringify(pending.value));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
@ -110,6 +108,8 @@ export function useAttachments(options?: UseAttachmentsOptions): UseAttachmentsR
|
||||||
if (!persistenceKey) return;
|
if (!persistenceKey) return;
|
||||||
|
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
// Flush any pending debounced save for the previous key before switching.
|
||||||
|
flushPending();
|
||||||
// Clear stale attachments from previous persistenceKey before loading
|
// Clear stale attachments from previous persistenceKey before loading
|
||||||
attachmentsRef.current = [];
|
attachmentsRef.current = [];
|
||||||
setAttachments([]);
|
setAttachments([]);
|
||||||
|
|
@ -136,7 +136,7 @@ export function useAttachments(options?: UseAttachmentsOptions): UseAttachmentsR
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [persistenceKey]);
|
}, [persistenceKey, flushPending]);
|
||||||
|
|
||||||
// Flush on unmount
|
// Flush on unmount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
|
||||||
|
|
@ -46,19 +46,45 @@ export function useChipDraftPersistence(key: string): UseChipDraftResult {
|
||||||
const [chips, setChipsState] = useState<InlineChip[]>([]);
|
const [chips, setChipsState] = useState<InlineChip[]>([]);
|
||||||
const [isSaved, setIsSaved] = useState(false);
|
const [isSaved, setIsSaved] = useState(false);
|
||||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
const pendingRef = useRef<InlineChip[] | null>(null);
|
const pendingRef = useRef<{ key: string; value: InlineChip[] } | null>(null);
|
||||||
const keyRef = useRef(key);
|
const keyRef = useRef(key);
|
||||||
|
keyRef.current = key;
|
||||||
// Ref for current chips — allows addChip/removeChip to read latest value
|
// Ref for current chips — allows addChip/removeChip to read latest value
|
||||||
// without stale closures, using the same sync-ref pattern as keyRef.
|
// without stale closures, using the same sync-ref pattern as keyRef.
|
||||||
const chipsRef = useRef<InlineChip[]>([]);
|
const chipsRef = useRef<InlineChip[]>([]);
|
||||||
|
const mountedRef = useRef(true);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
keyRef.current = key;
|
mountedRef.current = true;
|
||||||
}, [key]);
|
return () => {
|
||||||
|
mountedRef.current = false;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Load on mount
|
const flushPending = useCallback(() => {
|
||||||
|
if (timerRef.current != null) {
|
||||||
|
clearTimeout(timerRef.current);
|
||||||
|
timerRef.current = null;
|
||||||
|
}
|
||||||
|
if (pendingRef.current != null) {
|
||||||
|
const pending = pendingRef.current;
|
||||||
|
pendingRef.current = null;
|
||||||
|
if (pending.value.length === 0) {
|
||||||
|
void draftStorage.deleteDraft(pending.key);
|
||||||
|
} else {
|
||||||
|
void draftStorage.saveDraft(pending.key, JSON.stringify(pending.value));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Load on mount / key change
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
// Flush any pending debounced save for the previous key and reset local state for the new key.
|
||||||
|
flushPending();
|
||||||
|
chipsRef.current = [];
|
||||||
|
setChipsState([]);
|
||||||
|
setIsSaved(false);
|
||||||
void (async () => {
|
void (async () => {
|
||||||
const raw = await draftStorage.loadDraft(key);
|
const raw = await draftStorage.loadDraft(key);
|
||||||
if (cancelled || raw == null) return;
|
if (cancelled || raw == null) return;
|
||||||
|
|
@ -76,23 +102,7 @@ export function useChipDraftPersistence(key: string): UseChipDraftResult {
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [key]);
|
}, [key, flushPending]);
|
||||||
|
|
||||||
const flushPending = useCallback(() => {
|
|
||||||
if (timerRef.current != null) {
|
|
||||||
clearTimeout(timerRef.current);
|
|
||||||
timerRef.current = null;
|
|
||||||
}
|
|
||||||
if (pendingRef.current != null) {
|
|
||||||
const val = pendingRef.current;
|
|
||||||
pendingRef.current = null;
|
|
||||||
if (val.length === 0) {
|
|
||||||
void draftStorage.deleteDraft(keyRef.current);
|
|
||||||
} else {
|
|
||||||
void draftStorage.saveDraft(keyRef.current, JSON.stringify(val));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Flush on unmount
|
// Flush on unmount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -105,7 +115,7 @@ export function useChipDraftPersistence(key: string): UseChipDraftResult {
|
||||||
chipsRef.current = nextChips;
|
chipsRef.current = nextChips;
|
||||||
setChipsState(nextChips);
|
setChipsState(nextChips);
|
||||||
setIsSaved(false);
|
setIsSaved(false);
|
||||||
pendingRef.current = nextChips;
|
pendingRef.current = { key: keyRef.current, value: nextChips };
|
||||||
|
|
||||||
if (timerRef.current != null) {
|
if (timerRef.current != null) {
|
||||||
clearTimeout(timerRef.current);
|
clearTimeout(timerRef.current);
|
||||||
|
|
@ -117,11 +127,11 @@ export function useChipDraftPersistence(key: string): UseChipDraftResult {
|
||||||
pendingRef.current = null;
|
pendingRef.current = null;
|
||||||
if (pending == null) return;
|
if (pending == null) return;
|
||||||
|
|
||||||
if (pending.length === 0) {
|
if (pending.value.length === 0) {
|
||||||
void draftStorage.deleteDraft(keyRef.current);
|
void draftStorage.deleteDraft(pending.key);
|
||||||
} else {
|
} else {
|
||||||
void draftStorage.saveDraft(keyRef.current, JSON.stringify(pending)).then(() => {
|
void draftStorage.saveDraft(pending.key, JSON.stringify(pending.value)).then(() => {
|
||||||
setIsSaved(true);
|
if (mountedRef.current) setIsSaved(true);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, DEBOUNCE_MS);
|
}, DEBOUNCE_MS);
|
||||||
|
|
|
||||||
|
|
@ -25,15 +25,46 @@ export function useDraftPersistence({
|
||||||
const [value, setValueState] = useState(initialValue ?? '');
|
const [value, setValueState] = useState(initialValue ?? '');
|
||||||
const [isSaved, setIsSaved] = useState(false);
|
const [isSaved, setIsSaved] = useState(false);
|
||||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
const pendingValueRef = useRef<string | null>(null);
|
const pendingValueRef = useRef<{ key: string; value: string } | null>(null);
|
||||||
const keyRef = useRef(key);
|
const keyRef = useRef(key);
|
||||||
keyRef.current = key;
|
keyRef.current = key;
|
||||||
|
const mountedRef = useRef(true);
|
||||||
|
|
||||||
// Load draft on mount
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!enabled) return;
|
mountedRef.current = true;
|
||||||
|
return () => {
|
||||||
|
mountedRef.current = false;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const flushPending = useCallback(() => {
|
||||||
|
if (timerRef.current != null) {
|
||||||
|
clearTimeout(timerRef.current);
|
||||||
|
timerRef.current = null;
|
||||||
|
}
|
||||||
|
if (pendingValueRef.current != null) {
|
||||||
|
const pending = pendingValueRef.current;
|
||||||
|
pendingValueRef.current = null;
|
||||||
|
if (pending.value.length === 0) {
|
||||||
|
void draftStorage.deleteDraft(pending.key);
|
||||||
|
} else {
|
||||||
|
void draftStorage.saveDraft(pending.key, pending.value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Load draft on mount / key change
|
||||||
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
// Prevent debounced saves for the previous key from landing under the new key.
|
||||||
|
flushPending();
|
||||||
|
// Reset local state for the new key immediately. If a draft exists, it will overwrite below.
|
||||||
|
setValueState(initialValue ?? '');
|
||||||
|
setIsSaved(false);
|
||||||
|
|
||||||
|
if (!enabled) return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
void (async () => {
|
void (async () => {
|
||||||
const draft = await draftStorage.loadDraft(key);
|
const draft = await draftStorage.loadDraft(key);
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
|
|
@ -46,24 +77,7 @@ export function useDraftPersistence({
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- load once on mount
|
}, [key, enabled, initialValue, flushPending]);
|
||||||
}, [key, enabled]);
|
|
||||||
|
|
||||||
const flushPending = useCallback(() => {
|
|
||||||
if (timerRef.current != null) {
|
|
||||||
clearTimeout(timerRef.current);
|
|
||||||
timerRef.current = null;
|
|
||||||
}
|
|
||||||
if (pendingValueRef.current != null) {
|
|
||||||
const val = pendingValueRef.current;
|
|
||||||
pendingValueRef.current = null;
|
|
||||||
if (val.length === 0) {
|
|
||||||
void draftStorage.deleteDraft(keyRef.current);
|
|
||||||
} else {
|
|
||||||
void draftStorage.saveDraft(keyRef.current, val);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Flush on unmount
|
// Flush on unmount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -79,7 +93,7 @@ export function useDraftPersistence({
|
||||||
|
|
||||||
if (!enabled) return;
|
if (!enabled) return;
|
||||||
|
|
||||||
pendingValueRef.current = v;
|
pendingValueRef.current = { key: keyRef.current, value: v };
|
||||||
|
|
||||||
if (timerRef.current != null) {
|
if (timerRef.current != null) {
|
||||||
clearTimeout(timerRef.current);
|
clearTimeout(timerRef.current);
|
||||||
|
|
@ -91,11 +105,11 @@ export function useDraftPersistence({
|
||||||
pendingValueRef.current = null;
|
pendingValueRef.current = null;
|
||||||
if (pending == null) return;
|
if (pending == null) return;
|
||||||
|
|
||||||
if (pending.length === 0) {
|
if (pending.value.length === 0) {
|
||||||
void draftStorage.deleteDraft(keyRef.current);
|
void draftStorage.deleteDraft(pending.key);
|
||||||
} else {
|
} else {
|
||||||
void draftStorage.saveDraft(keyRef.current, pending).then(() => {
|
void draftStorage.saveDraft(pending.key, pending.value).then(() => {
|
||||||
setIsSaved(true);
|
if (mountedRef.current) setIsSaved(true);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, debounceMs);
|
}, debounceMs);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue