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 [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'}>
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in a new issue