agent-ecosystem/src/renderer/hooks/useComposerDraft.ts
iliya 51f8f3545c style: auto-fix import/export sorting and formatting
Ran pnpm lint:fix to resolve 220 auto-fixable lint issues.
All changes are import/export reordering — no logic changes.
2026-03-16 20:48:42 +02:00

479 lines
14 KiB
TypeScript

/**
* Unified composer draft hook — atomic persistence of text + chips + attachments.
*
* Replaces the trio of `useDraftPersistence`, `useChipDraftPersistence`, and
* `useAttachments` for the team `MessageComposer`.
*
* Key guarantees:
* - Single IndexedDB key per team (`composer:<teamName>`), no TTL.
* - Race-safe: late async load never overwrites fresh user input.
* - Debounced writes with immediate flush on unmount and lifecycle transitions.
* - Legacy migration from three-key format on first load.
*/
import { useCallback, useEffect, useRef, useState } from 'react';
import {
type ComposerDraftSnapshot,
composerDraftStorage,
} from '@renderer/services/composerDraftStorage';
import {
fileToAttachmentPayload,
MAX_FILES,
MAX_TOTAL_SIZE,
validateAttachment,
} from '@renderer/utils/attachmentUtils';
import type { InlineChip } from '@renderer/types/inlineChip';
import type { AgentActionMode, AttachmentPayload } from '@shared/types';
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface UseComposerDraftResult {
// Text
text: string;
setText: (v: string) => void;
// Chips
chips: InlineChip[];
addChip: (chip: InlineChip) => void;
removeChip: (chipId: string) => void;
// Attachments
attachments: AttachmentPayload[];
attachmentError: string | null;
canAddMore: boolean;
addFiles: (files: FileList | File[]) => Promise<void>;
removeAttachment: (id: string) => void;
clearAttachments: () => void;
clearAttachmentError: () => void;
handlePaste: (event: React.ClipboardEvent) => void;
handleDrop: (event: React.DragEvent) => void;
// Action mode
actionMode: AgentActionMode;
setActionMode: (mode: AgentActionMode) => void;
// Status
isSaved: boolean;
isLoaded: boolean;
// Clear all
clearDraft: () => void;
}
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const DEBOUNCE_MS = 400;
// ---------------------------------------------------------------------------
// Hook
// ---------------------------------------------------------------------------
export function useComposerDraft(teamName: string): UseComposerDraftResult {
const [text, setTextState] = useState('');
const [chips, setChipsState] = useState<InlineChip[]>([]);
const [attachments, setAttachmentsState] = useState<AttachmentPayload[]>([]);
const [attachmentError, setAttachmentError] = useState<string | null>(null);
const [actionMode, setActionModeState] = useState<AgentActionMode>('do');
const [isSaved, setIsSaved] = useState(false);
const [isLoaded, setIsLoaded] = useState(false);
// Refs for latest values — avoids stale closures in callbacks
const textRef = useRef('');
const chipsRef = useRef<InlineChip[]>([]);
const attachmentsRef = useRef<AttachmentPayload[]>([]);
const actionModeRef = useRef<AgentActionMode>('do');
const teamNameRef = useRef(teamName);
const mountedRef = useRef(true);
// Track whether user has interacted since last load to prevent race
const userTouchedRef = useRef(false);
// Debounce timer
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const pendingRef = useRef<{ teamName: string; snapshot: ComposerDraftSnapshot } | null>(null);
// Keep teamNameRef in sync
useEffect(() => {
teamNameRef.current = teamName;
}, [teamName]);
useEffect(() => {
mountedRef.current = true;
return () => {
mountedRef.current = false;
};
}, []);
// ---------------------------------------------------------------------------
// Persist helpers
// ---------------------------------------------------------------------------
const buildSnapshot = useCallback((): ComposerDraftSnapshot => {
return {
version: 1,
teamName: teamNameRef.current,
text: textRef.current,
chips: chipsRef.current,
attachments: attachmentsRef.current,
actionMode: actionModeRef.current,
updatedAt: Date.now(),
};
}, []);
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.snapshot.text.length === 0 &&
pending.snapshot.chips.length === 0 &&
pending.snapshot.attachments.length === 0;
if (isEmpty) {
void composerDraftStorage.deleteSnapshot(pending.teamName);
} else {
void composerDraftStorage.saveSnapshot(pending.teamName, pending.snapshot);
}
}
}, []);
const scheduleSave = useCallback(() => {
const snapshot = buildSnapshot();
pendingRef.current = { teamName: teamNameRef.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.snapshot.text.length === 0 &&
pending.snapshot.chips.length === 0 &&
pending.snapshot.attachments.length === 0;
if (isEmpty) {
void composerDraftStorage.deleteSnapshot(pending.teamName);
if (mountedRef.current) setIsSaved(true);
} else {
void composerDraftStorage.saveSnapshot(pending.teamName, pending.snapshot).then(() => {
if (mountedRef.current) setIsSaved(true);
});
}
}, DEBOUNCE_MS);
}, [buildSnapshot]);
// ---------------------------------------------------------------------------
// Apply snapshot to state
// ---------------------------------------------------------------------------
const applySnapshot = useCallback((snap: ComposerDraftSnapshot) => {
textRef.current = snap.text;
chipsRef.current = snap.chips;
attachmentsRef.current = snap.attachments;
actionModeRef.current = snap.actionMode ?? 'do';
setTextState(snap.text);
setChipsState(snap.chips);
setAttachmentsState(snap.attachments);
setActionModeState(snap.actionMode ?? 'do');
}, []);
// ---------------------------------------------------------------------------
// Load on mount / teamName change
// ---------------------------------------------------------------------------
useEffect(() => {
let cancelled = false;
flushPending();
userTouchedRef.current = false;
// Reset to empty for the new teamName.
// Wrapped in queueMicrotask to avoid synchronous setState inside effect body.
const empty = composerDraftStorage.emptySnapshot(teamName);
queueMicrotask(() => {
if (cancelled) return;
applySnapshot(empty);
setIsSaved(false);
setIsLoaded(false);
setAttachmentError(null);
});
void (async () => {
// Try loading unified snapshot first
let snapshot = await composerDraftStorage.loadSnapshot(teamName);
// If none found, try legacy migration
if (snapshot == null) {
snapshot = await composerDraftStorage.migrateLegacy(teamName);
}
if (cancelled) return;
// Race protection: if user already started typing, don't overwrite
if (userTouchedRef.current) {
if (mountedRef.current) setIsLoaded(true);
return;
}
if (snapshot != null) {
// Validate attachment limits
const totalSize = snapshot.attachments.reduce((sum, a) => sum + a.size, 0);
if (totalSize > MAX_TOTAL_SIZE || snapshot.attachments.length > MAX_FILES) {
snapshot = { ...snapshot, attachments: [] };
}
applySnapshot(snapshot);
setIsSaved(true);
}
if (mountedRef.current) setIsLoaded(true);
})();
return () => {
cancelled = true;
};
}, [teamName, flushPending, applySnapshot]);
// Flush on unmount
useEffect(() => {
return () => {
flushPending();
};
}, [flushPending]);
// ---------------------------------------------------------------------------
// Text
// ---------------------------------------------------------------------------
const setText = useCallback(
(v: string) => {
userTouchedRef.current = true;
textRef.current = v;
setTextState(v);
setIsSaved(false);
scheduleSave();
},
[scheduleSave]
);
// ---------------------------------------------------------------------------
// Chips
// ---------------------------------------------------------------------------
const addChip = useCallback(
(chip: InlineChip) => {
userTouchedRef.current = true;
const next = [...chipsRef.current, chip];
chipsRef.current = next;
setChipsState(next);
setIsSaved(false);
scheduleSave();
},
[scheduleSave]
);
const removeChip = useCallback(
(chipId: string) => {
userTouchedRef.current = true;
const next = chipsRef.current.filter((c) => c.id !== chipId);
chipsRef.current = next;
setChipsState(next);
setIsSaved(false);
scheduleSave();
},
[scheduleSave]
);
// ---------------------------------------------------------------------------
// Action mode
// ---------------------------------------------------------------------------
const setActionMode = useCallback(
(mode: AgentActionMode) => {
userTouchedRef.current = true;
actionModeRef.current = mode;
setActionModeState(mode);
setIsSaved(false);
scheduleSave();
},
[scheduleSave]
);
// ---------------------------------------------------------------------------
// Attachments
// ---------------------------------------------------------------------------
const totalSize = attachments.reduce((sum, a) => sum + a.size, 0);
const canAddMore = attachments.length < MAX_FILES && totalSize < MAX_TOTAL_SIZE;
const addFiles = useCallback(
async (files: FileList | File[]) => {
userTouchedRef.current = true;
setAttachmentError(null);
const fileArray = Array.from(files);
if (fileArray.length === 0) return;
let batchSize = 0;
for (const file of fileArray) {
const validation = validateAttachment(file);
if (!validation.valid) {
setAttachmentError(validation.error);
return;
}
batchSize += file.size;
}
const newPayloads: AttachmentPayload[] = [];
for (const file of fileArray) {
try {
const payload = await fileToAttachmentPayload(file);
newPayloads.push(payload);
} catch {
setAttachmentError(`Failed to read file: ${file.name}`);
return;
}
}
const prev = attachmentsRef.current;
if (prev.length + newPayloads.length > MAX_FILES) {
setAttachmentError(`Maximum ${MAX_FILES} attachments allowed`);
return;
}
const currentTotal = prev.reduce((sum, a) => sum + a.size, 0);
if (currentTotal + batchSize > MAX_TOTAL_SIZE) {
setAttachmentError('Total attachment size exceeds 20MB limit');
return;
}
const next = [...prev, ...newPayloads];
attachmentsRef.current = next;
setAttachmentsState(next);
setIsSaved(false);
scheduleSave();
},
[scheduleSave]
);
const removeAttachment = useCallback(
(id: string) => {
userTouchedRef.current = true;
const next = attachmentsRef.current.filter((a) => a.id !== id);
attachmentsRef.current = next;
setAttachmentsState(next);
setAttachmentError(null);
setIsSaved(false);
scheduleSave();
},
[scheduleSave]
);
const clearAttachments = useCallback(() => {
userTouchedRef.current = true;
attachmentsRef.current = [];
setAttachmentsState([]);
setAttachmentError(null);
setIsSaved(false);
scheduleSave();
}, [scheduleSave]);
const clearAttachmentError = useCallback(() => {
setAttachmentError(null);
}, []);
const handlePaste = useCallback(
(event: React.ClipboardEvent) => {
const items = event.clipboardData?.items;
if (!items) return;
const imageFiles: File[] = [];
for (const item of Array.from(items)) {
if (item.kind === 'file' && item.type.startsWith('image/')) {
const file = item.getAsFile();
if (file) imageFiles.push(file);
}
}
if (imageFiles.length > 0) {
event.preventDefault();
void addFiles(imageFiles);
}
},
[addFiles]
);
const handleDrop = useCallback(
(event: React.DragEvent) => {
event.preventDefault();
const files = event.dataTransfer?.files;
if (!files?.length) return;
const allFiles = Array.from(files);
const imageFiles = allFiles.filter((f) => f.type.startsWith('image/'));
if (imageFiles.length > 0) {
void addFiles(imageFiles);
} else if (allFiles.length > 0) {
setAttachmentError('Only image files are supported');
}
},
[addFiles]
);
// ---------------------------------------------------------------------------
// Clear all
// ---------------------------------------------------------------------------
const clearDraft = useCallback(() => {
if (timerRef.current != null) {
clearTimeout(timerRef.current);
timerRef.current = null;
}
pendingRef.current = null;
textRef.current = '';
chipsRef.current = [];
attachmentsRef.current = [];
// actionMode is intentionally NOT reset — it is "sticky" across sends
setTextState('');
setChipsState([]);
setAttachmentsState([]);
setAttachmentError(null);
setIsSaved(false);
void composerDraftStorage.deleteSnapshot(teamNameRef.current);
}, []);
return {
text,
setText,
chips,
addChip,
removeChip,
attachments,
attachmentError,
canAddMore,
addFiles,
removeAttachment,
clearAttachments,
clearAttachmentError,
handlePaste,
handleDrop,
actionMode,
setActionMode,
isSaved,
isLoaded,
clearDraft,
};
}