diff --git a/src/renderer/components/team/dialogs/SendMessageDialog.tsx b/src/renderer/components/team/dialogs/SendMessageDialog.tsx index 4c5961bd..0815db50 100644 --- a/src/renderer/components/team/dialogs/SendMessageDialog.tsx +++ b/src/renderer/components/team/dialogs/SendMessageDialog.tsx @@ -92,8 +92,8 @@ export const SendMessageDialog = ({ const [quote, setQuote] = useState(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(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." />
diff --git a/src/renderer/components/team/members/MemberLogsTab.tsx b/src/renderer/components/team/members/MemberLogsTab.tsx index 15b01582..faeecc5e 100644 --- a/src/renderer/components/team/members/MemberLogsTab.tsx +++ b/src/renderer/components/team/members/MemberLogsTab.tsx @@ -53,11 +53,43 @@ export const MemberLogsTab = ({ const [detailChunks, setDetailChunks] = useState(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 => { + 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 (
- {logs.map((log) => ( + {sortedLogs.map((log) => ( void handleExpand(log)} /> diff --git a/src/renderer/components/team/messages/MessageComposer.tsx b/src/renderer/components/team/messages/MessageComposer.tsx index 47e45bf4..fd3a903a 100644 --- a/src/renderer/components/team/messages/MessageComposer.tsx +++ b/src/renderer/components/team/messages/MessageComposer.tsx @@ -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." /> ([]); const timerRef = useRef | null>(null); - const pendingRef = useRef(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(() => { diff --git a/src/renderer/hooks/useChipDraftPersistence.ts b/src/renderer/hooks/useChipDraftPersistence.ts index e0c9b49b..e7874ada 100644 --- a/src/renderer/hooks/useChipDraftPersistence.ts +++ b/src/renderer/hooks/useChipDraftPersistence.ts @@ -46,19 +46,45 @@ export function useChipDraftPersistence(key: string): UseChipDraftResult { const [chips, setChipsState] = useState([]); const [isSaved, setIsSaved] = useState(false); const timerRef = useRef | null>(null); - const pendingRef = useRef(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([]); + 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); diff --git a/src/renderer/hooks/useDraftPersistence.ts b/src/renderer/hooks/useDraftPersistence.ts index fe6ecb03..0d6b22b3 100644 --- a/src/renderer/hooks/useDraftPersistence.ts +++ b/src/renderer/hooks/useDraftPersistence.ts @@ -25,15 +25,46 @@ export function useDraftPersistence({ const [value, setValueState] = useState(initialValue ?? ''); const [isSaved, setIsSaved] = useState(false); const timerRef = useRef | null>(null); - const pendingValueRef = useRef(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);