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:
iliya 2026-03-05 14:29:51 +02:00
parent afb0173c05
commit 82bea01e0f
6 changed files with 173 additions and 105 deletions

View file

@ -92,8 +92,8 @@ export const SendMessageDialog = ({
const [quote, setQuote] = useState<QuotedMessage | undefined>(undefined);
const [quoteExpanded, setQuoteExpanded] = useState(false);
const [member, setMember] = useState('');
const textDraft = useDraftPersistence({ key: 'sendMessage:text' });
const chipDraft = useChipDraftPersistence('sendMessage:chips');
const textDraft = useDraftPersistence({ key: `sendMessage:${teamName}:text` });
const chipDraft = useChipDraftPersistence(`sendMessage:${teamName}:chips`);
const [summary, setSummary] = useState('');
const prevOpenRef = useRef(false);
const prevResultRef = useRef<SendMessageResult | null>(null);
@ -115,7 +115,8 @@ export const SendMessageDialog = ({
const selectedMember = members.find((m) => m.name === member);
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);
// Reset form on open transition (avoid setState in render)
@ -174,7 +175,7 @@ export const SendMessageDialog = ({
[members, colorMap]
);
const attachmentsBlocked = attachments.length > 0 && !isLeadRecipient;
const attachmentsBlocked = attachments.length > 0 && !supportsAttachments;
const canSend =
member.trim().length > 0 &&
@ -363,7 +364,7 @@ export const SendMessageDialog = ({
onRemove={removeAttachment}
error={attachmentError}
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'}>

View file

@ -53,11 +53,43 @@ export const MemberLogsTab = ({
const [detailChunks, setDetailChunks] = useState<EnhancedChunk[] | null>(null);
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(() => {
onRefreshingChange?.(refreshing);
return () => onRefreshingChange?.(false);
}, [refreshing, onRefreshingChange]);
useEffect(() => {
if (!expandedId) return;
if (expandedLogSummary) return;
setExpandedId(null);
setDetailChunks(null);
setDetailLoading(false);
}, [expandedId, expandedLogSummary]);
useEffect(() => {
let cancelled = false;
const shouldAutoRefresh = taskId != null && taskStatus === 'in_progress';
@ -84,7 +116,7 @@ export const MemberLogsTab = ({
})
: await api.teams.getMemberLogs(teamName, memberName!);
if (!cancelled) {
setLogs(result);
setLogs(Array.isArray(result) ? [...result] : []);
hasLoadedRef.current = true;
}
} 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
}, [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(
async (log: MemberLogSummary) => {
const rowId =
log.kind === 'subagent'
? `subagent:${log.sessionId}:${log.subagentId}`
: `lead:${log.sessionId}`;
const rowId = getRowId(log);
if (expandedId === rowId) {
setExpandedId(null);
@ -126,20 +188,15 @@ export const MemberLogsTab = ({
setDetailChunks(null);
setDetailLoading(true);
try {
if (log.kind === 'subagent') {
const d = await api.getSubagentDetail(log.projectId, log.sessionId, log.subagentId);
setDetailChunks(d?.chunks ?? null);
} else {
const d = await api.getSessionDetail(log.projectId, log.sessionId);
setDetailChunks((d?.chunks ?? null) as unknown as EnhancedChunk[] | null);
}
const chunks = await fetchDetailForLog(log);
setDetailChunks(chunks ? [...chunks] : null);
} catch {
setDetailChunks(null);
} finally {
setDetailLoading(false);
}
},
[expandedId]
[expandedId, fetchDetailForLog, getRowId]
);
if (loading && logs.length === 0) {
@ -178,31 +235,16 @@ export const MemberLogsTab = ({
return (
<div className="w-full min-w-0 space-y-1.5">
{logs.map((log) => (
{sortedLogs.map((log) => (
<LogCard
key={
log.kind === 'subagent' ? `${log.sessionId}-${log.subagentId}` : `lead-${log.sessionId}`
}
key={getRowId(log)}
log={log}
expanded={
expandedId ===
(log.kind === 'subagent'
? `subagent:${log.sessionId}:${log.subagentId}`
: `lead:${log.sessionId}`)
}
expanded={expandedId === getRowId(log)}
detailChunks={
expandedId ===
(log.kind === 'subagent'
? `subagent:${log.sessionId}:${log.subagentId}`
: `lead:${log.sessionId}`)
? detailChunks
: null
expandedId === getRowId(log) ? detailChunks : null
}
detailLoading={
expandedId ===
(log.kind === 'subagent'
? `subagent:${log.sessionId}:${log.subagentId}`
: `lead:${log.sessionId}`) && detailLoading
expandedId === getRowId(log) && detailLoading
}
onToggle={() => void handleExpand(log)}
/>

View file

@ -152,8 +152,9 @@ export const MessageComposer = ({
const leadContext = useStore((s) =>
isLeadAgentRecipient ? s.leadContextByTeam[teamName] : undefined
);
const canAttach = isLeadRecipient && isTeamAlive && canAddMore;
const attachmentsBlocked = attachments.length > 0 && !isLeadRecipient;
const supportsAttachments = isLeadRecipient && !!isTeamAlive;
const canAttach = supportsAttachments && canAddMore;
const attachmentsBlocked = attachments.length > 0 && !supportsAttachments;
const canSend =
recipient.length > 0 &&
trimmed.length > 0 &&
@ -399,7 +400,7 @@ export const MessageComposer = ({
onRemove={removeAttachment}
error={attachmentError}
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

View file

@ -52,7 +52,7 @@ export function useAttachments(options?: UseAttachmentsOptions): UseAttachmentsR
const attachmentsRef = useRef<AttachmentPayload[]>([]);
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);
keyRef.current = persistenceKey;
@ -67,7 +67,7 @@ export function useAttachments(options?: UseAttachmentsOptions): UseAttachmentsR
const key = keyRef.current;
if (!key) return;
pendingRef.current = nextAttachments;
pendingRef.current = { key, value: nextAttachments };
if (timerRef.current != null) {
clearTimeout(timerRef.current);
@ -79,10 +79,10 @@ export function useAttachments(options?: UseAttachmentsOptions): UseAttachmentsR
pendingRef.current = null;
if (pending == null) return;
if (pending.length === 0) {
void draftStorage.deleteDraft(key);
if (pending.value.length === 0) {
void draftStorage.deleteDraft(pending.key);
} else {
void draftStorage.saveDraft(key, JSON.stringify(pending));
void draftStorage.saveDraft(pending.key, JSON.stringify(pending.value));
}
}, DEBOUNCE_MS);
}, []);
@ -93,14 +93,12 @@ export function useAttachments(options?: UseAttachmentsOptions): UseAttachmentsR
timerRef.current = null;
}
if (pendingRef.current != null) {
const val = pendingRef.current;
const key = keyRef.current;
const pending = pendingRef.current;
pendingRef.current = null;
if (!key) return;
if (val.length === 0) {
void draftStorage.deleteDraft(key);
if (pending.value.length === 0) {
void draftStorage.deleteDraft(pending.key);
} 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;
let cancelled = false;
// Flush any pending debounced save for the previous key before switching.
flushPending();
// Clear stale attachments from previous persistenceKey before loading
attachmentsRef.current = [];
setAttachments([]);
@ -136,7 +136,7 @@ export function useAttachments(options?: UseAttachmentsOptions): UseAttachmentsR
return () => {
cancelled = true;
};
}, [persistenceKey]);
}, [persistenceKey, flushPending]);
// Flush on unmount
useEffect(() => {

View file

@ -46,19 +46,45 @@ export function useChipDraftPersistence(key: string): UseChipDraftResult {
const [chips, setChipsState] = useState<InlineChip[]>([]);
const [isSaved, setIsSaved] = useState(false);
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);
keyRef.current = key;
// Ref for current chips — allows addChip/removeChip to read latest value
// without stale closures, using the same sync-ref pattern as keyRef.
const chipsRef = useRef<InlineChip[]>([]);
const mountedRef = useRef(true);
useEffect(() => {
keyRef.current = key;
}, [key]);
mountedRef.current = true;
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(() => {
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 () => {
const raw = await draftStorage.loadDraft(key);
if (cancelled || raw == null) return;
@ -76,23 +102,7 @@ export function useChipDraftPersistence(key: string): UseChipDraftResult {
return () => {
cancelled = true;
};
}, [key]);
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));
}
}
}, []);
}, [key, flushPending]);
// Flush on unmount
useEffect(() => {
@ -105,7 +115,7 @@ export function useChipDraftPersistence(key: string): UseChipDraftResult {
chipsRef.current = nextChips;
setChipsState(nextChips);
setIsSaved(false);
pendingRef.current = nextChips;
pendingRef.current = { key: keyRef.current, value: nextChips };
if (timerRef.current != null) {
clearTimeout(timerRef.current);
@ -117,11 +127,11 @@ export function useChipDraftPersistence(key: string): UseChipDraftResult {
pendingRef.current = null;
if (pending == null) return;
if (pending.length === 0) {
void draftStorage.deleteDraft(keyRef.current);
if (pending.value.length === 0) {
void draftStorage.deleteDraft(pending.key);
} else {
void draftStorage.saveDraft(keyRef.current, JSON.stringify(pending)).then(() => {
setIsSaved(true);
void draftStorage.saveDraft(pending.key, JSON.stringify(pending.value)).then(() => {
if (mountedRef.current) setIsSaved(true);
});
}
}, DEBOUNCE_MS);

View file

@ -25,15 +25,46 @@ export function useDraftPersistence({
const [value, setValueState] = useState(initialValue ?? '');
const [isSaved, setIsSaved] = useState(false);
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);
keyRef.current = key;
const mountedRef = useRef(true);
// Load draft on mount
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;
// 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 () => {
const draft = await draftStorage.loadDraft(key);
if (cancelled) return;
@ -46,24 +77,7 @@ export function useDraftPersistence({
return () => {
cancelled = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps -- load once on mount
}, [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);
}
}
}, []);
}, [key, enabled, initialValue, flushPending]);
// Flush on unmount
useEffect(() => {
@ -79,7 +93,7 @@ export function useDraftPersistence({
if (!enabled) return;
pendingValueRef.current = v;
pendingValueRef.current = { key: keyRef.current, value: v };
if (timerRef.current != null) {
clearTimeout(timerRef.current);
@ -91,11 +105,11 @@ export function useDraftPersistence({
pendingValueRef.current = null;
if (pending == null) return;
if (pending.length === 0) {
void draftStorage.deleteDraft(keyRef.current);
if (pending.value.length === 0) {
void draftStorage.deleteDraft(pending.key);
} else {
void draftStorage.saveDraft(keyRef.current, pending).then(() => {
setIsSaved(true);
void draftStorage.saveDraft(pending.key, pending.value).then(() => {
if (mountedRef.current) setIsSaved(true);
});
}
}, debounceMs);