feat: enhance message handling and UI components for improved user experience
- Updated message deduplication logic in handleGetData to exclude lead_process messages with a 'to' field. - Added source field to message payloads in TeamAgentToolsInstaller for system notifications. - Enriched sendMessage function in TeamDataService with leadSessionId for better session tracking. - Improved UI components including MessageComposer placeholder text and scroll behavior in ActivityTimeline. - Enhanced LeadThoughtsGroupRow to support live indicators and auto-scroll functionality for new messages.
This commit is contained in:
parent
410cb8a10f
commit
297780e3a8
13 changed files with 231 additions and 48 deletions
|
|
@ -430,7 +430,7 @@ async function handleGetData(
|
|||
const merged: typeof data.messages = [];
|
||||
const seen = new Set<string>();
|
||||
for (const msg of [...data.messages, ...live]) {
|
||||
if ((msg as { source?: unknown }).source === 'lead_process') {
|
||||
if ((msg as { source?: unknown }).source === 'lead_process' && !msg.to) {
|
||||
const fp = `${msg.from}\0${normalizeText(msg.text ?? '')}`;
|
||||
// Skip if same text already exists from any source (inbox, lead_session, etc.)
|
||||
if (existingTextFingerprints.has(fp)) {
|
||||
|
|
|
|||
|
|
@ -865,6 +865,7 @@ function sendInboxMessage(paths, teamName, flags) {
|
|||
summary,
|
||||
messageId,
|
||||
};
|
||||
if (flags.source) payload.source = flags.source;
|
||||
|
||||
var lastErr;
|
||||
for (var attempt = 0; attempt < 8; attempt++) {
|
||||
|
|
@ -915,6 +916,7 @@ function reviewApprove(paths, teamName, taskId, flags) {
|
|||
text: inboxText,
|
||||
summary: 'Approved #' + String(taskId),
|
||||
from,
|
||||
source: 'system_notification',
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -957,6 +959,7 @@ function reviewRequestChanges(paths, teamName, taskId, flags) {
|
|||
text: inboxText,
|
||||
summary: 'Fix request for #' + String(taskId),
|
||||
from,
|
||||
source: 'system_notification',
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -1282,6 +1285,7 @@ async function main() {
|
|||
text: parts.join('\n'),
|
||||
summary: 'New task #' + String(task.id) + ' assigned',
|
||||
from,
|
||||
source: 'system_notification',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -1319,6 +1323,7 @@ async function main() {
|
|||
text: 'Comment on task #' + String(result.taskId) + ' "' + String(result.subject) + '":\n\n' + (typeof args.flags.text === 'string' ? args.flags.text.trim() : ''),
|
||||
summary: 'Comment on #' + String(result.taskId),
|
||||
from: from,
|
||||
source: 'system_notification',
|
||||
});
|
||||
} catch (e) { /* best-effort */ }
|
||||
}
|
||||
|
|
@ -1396,6 +1401,7 @@ async function main() {
|
|||
text: parts.join('\n'),
|
||||
summary: 'Task #' + String(task.id) + ' assigned',
|
||||
from,
|
||||
source: 'system_notification',
|
||||
});
|
||||
}
|
||||
return;
|
||||
|
|
@ -1493,7 +1499,10 @@ async function main() {
|
|||
|
||||
if (domain === 'message') {
|
||||
if (action === 'send') {
|
||||
const result = sendInboxMessage(paths, teamName, args.flags);
|
||||
// Strip source from agent flags — only internal callers may set it
|
||||
var msgFlags = Object.assign({}, args.flags);
|
||||
delete msgFlags.source;
|
||||
const result = sendInboxMessage(paths, teamName, msgFlags);
|
||||
process.stdout.write(JSON.stringify(result, null, 2) + '\n');
|
||||
return;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -283,6 +283,7 @@ export class TeamDataService {
|
|||
|
||||
// Dedup: if a lead_process message text is also present in lead_session, prefer lead_session.
|
||||
// This avoids double-rendering when we persist lead process messages and later load the lead JSONL.
|
||||
// Exception: lead_process messages with `to` field are captured SendMessage — never dedup those.
|
||||
if (leadTexts.length > 0 && sentMessages.length > 0) {
|
||||
const normalizeText = (text: string): string => text.trim().replace(/\r\n/g, '\n');
|
||||
const leadSessionFingerprints = new Set<string>();
|
||||
|
|
@ -292,6 +293,8 @@ export class TeamDataService {
|
|||
}
|
||||
messages = messages.filter((m) => {
|
||||
if (m.source !== 'lead_process') return true;
|
||||
// Captured SendMessage messages (with recipient) are real messages — never dedup
|
||||
if (m.to) return true;
|
||||
const fp = `${m.from}\0${normalizeText(m.text ?? '')}`;
|
||||
return !leadSessionFingerprints.has(fp);
|
||||
});
|
||||
|
|
@ -311,8 +314,11 @@ export class TeamDataService {
|
|||
msg.leadSessionId = currentSessionId;
|
||||
}
|
||||
}
|
||||
// Backward pass: fill messages before the first known session
|
||||
currentSessionId = undefined;
|
||||
// Backward pass: fill messages before the first known session.
|
||||
// Seed with config.leadSessionId so that recent messages without an explicit
|
||||
// session ID inherit the current (most recent) session — this ensures that
|
||||
// session boundary separators work even when inbox entries lack the field.
|
||||
currentSessionId = config.leadSessionId;
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
if (messages[i].leadSessionId) {
|
||||
currentSessionId = messages[i].leadSessionId;
|
||||
|
|
@ -1117,6 +1123,17 @@ export class TeamDataService {
|
|||
}
|
||||
|
||||
async sendMessage(teamName: string, request: SendMessageRequest): Promise<SendMessageResult> {
|
||||
// Enrich with leadSessionId so session boundary separators work
|
||||
if (!request.leadSessionId) {
|
||||
try {
|
||||
const config = await this.configReader.getConfig(teamName);
|
||||
if (config?.leadSessionId) {
|
||||
request = { ...request, leadSessionId: config.leadSessionId };
|
||||
}
|
||||
} catch {
|
||||
// non-critical
|
||||
}
|
||||
}
|
||||
return this.inboxWriter.sendMessage(teamName, request);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ export class TeamInboxWriter {
|
|||
messageId,
|
||||
attachments: attachmentMeta?.length ? attachmentMeta : undefined,
|
||||
...(request.source && { source: request.source }),
|
||||
...(request.leadSessionId && { leadSessionId: request.leadSessionId }),
|
||||
};
|
||||
|
||||
await withInboxLock(inboxPath, async () => {
|
||||
|
|
|
|||
|
|
@ -79,8 +79,6 @@ const PREFLIGHT_PING_ARGS = [
|
|||
'haiku',
|
||||
'--max-turns',
|
||||
'1',
|
||||
'--max-budget-usd',
|
||||
'0.05',
|
||||
'--no-session-persistence',
|
||||
] as const;
|
||||
const PREFLIGHT_EXPECTED = 'PONG';
|
||||
|
|
@ -2797,6 +2795,16 @@ export class TeamProvisioningService {
|
|||
}
|
||||
|
||||
pushLiveLeadProcessMessage(teamName: string, message: InboxMessage): void {
|
||||
// Enrich with leadSessionId if missing — needed for session boundary separators
|
||||
if (!message.leadSessionId) {
|
||||
const runId = this.activeByTeam.get(teamName);
|
||||
if (runId) {
|
||||
const run = this.runs.get(runId);
|
||||
if (run?.detectedSessionId) {
|
||||
message.leadSessionId = run.detectedSessionId;
|
||||
}
|
||||
}
|
||||
}
|
||||
const MAX = 100;
|
||||
const list = this.liveLeadProcessMessages.get(teamName) ?? [];
|
||||
const id = typeof message.messageId === 'string' ? message.messageId.trim() : '';
|
||||
|
|
|
|||
|
|
@ -330,7 +330,8 @@ export const ClaudeLogsSection = ({ teamName }: ClaudeLogsSectionProps): React.J
|
|||
badge={badge}
|
||||
headerExtra={headerExtra}
|
||||
defaultOpen
|
||||
contentClassName="pt-0"
|
||||
// Prevent scroll anchoring from "pulling" the parent container when logs update.
|
||||
contentClassName="pt-0 [overflow-anchor:none]"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2 pb-2">
|
||||
<span className="text-[11px] text-[var(--color-text-muted)]">
|
||||
|
|
|
|||
|
|
@ -17,9 +17,11 @@ import { Bot, ChevronRight } from 'lucide-react';
|
|||
|
||||
import type { StreamJsonGroup } from '@renderer/utils/streamJsonParser';
|
||||
|
||||
type CliLogsOrder = 'oldest-first' | 'newest-first';
|
||||
|
||||
interface CliLogsRichViewProps {
|
||||
cliLogsTail: string;
|
||||
order?: 'oldest-first' | 'newest-first';
|
||||
order?: CliLogsOrder;
|
||||
onScroll?: (params: { scrollTop: number; scrollHeight: number; clientHeight: number }) => void;
|
||||
containerRefCallback?: (el: HTMLDivElement | null) => void;
|
||||
/** Optional local search query override for inline highlighting */
|
||||
|
|
@ -156,6 +158,8 @@ export const CliLogsRichView = ({
|
|||
className,
|
||||
}: CliLogsRichViewProps): React.JSX.Element => {
|
||||
const scrollRef = useRef<HTMLDivElement | null>(null);
|
||||
const stickToEdgeRef = useRef(true);
|
||||
const lastOrderRef = useRef<CliLogsOrder>(order);
|
||||
// Tracks groups manually collapsed by user (default: all auto-expanded)
|
||||
const [collapsedGroupIds, setCollapsedGroupIds] = useState<Set<string>>(new Set());
|
||||
const [expandedItemIds, setExpandedItemIds] = useState<Set<string>>(new Set());
|
||||
|
|
@ -173,15 +177,38 @@ export const CliLogsRichView = ({
|
|||
return expanded;
|
||||
}, [groups, collapsedGroupIds]);
|
||||
|
||||
// Auto-scroll to bottom on new content
|
||||
useEffect(() => {
|
||||
if (scrollRef.current) {
|
||||
const computeShouldStickToEdge = useCallback(
|
||||
(el: HTMLDivElement): boolean => {
|
||||
// Small threshold makes it feel "sticky" but still allows reading slightly away from the edge
|
||||
const thresholdPx = 16;
|
||||
if (order === 'newest-first') {
|
||||
scrollRef.current.scrollTop = 0;
|
||||
} else {
|
||||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
||||
return el.scrollTop <= thresholdPx;
|
||||
}
|
||||
const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight;
|
||||
return distanceFromBottom <= thresholdPx;
|
||||
},
|
||||
[order]
|
||||
);
|
||||
|
||||
// Auto-scroll only when user is pinned to the edge.
|
||||
useEffect(() => {
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
|
||||
// If the sort order changes, always snap once (expectation: show the "newest edge").
|
||||
if (lastOrderRef.current !== order) {
|
||||
lastOrderRef.current = order;
|
||||
stickToEdgeRef.current = true;
|
||||
}
|
||||
|
||||
if (!stickToEdgeRef.current) return;
|
||||
|
||||
if (order === 'newest-first') {
|
||||
el.scrollTop = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
el.scrollTop = el.scrollHeight;
|
||||
}, [cliLogsTail, order]);
|
||||
|
||||
const handleGroupToggle = useCallback((groupId: string) => {
|
||||
|
|
@ -223,6 +250,7 @@ export const CliLogsRichView = ({
|
|||
)}
|
||||
onScroll={(e) => {
|
||||
const el = e.currentTarget;
|
||||
stickToEdgeRef.current = computeShouldStickToEdge(el);
|
||||
onScroll?.({
|
||||
scrollTop: el.scrollTop,
|
||||
scrollHeight: el.scrollHeight,
|
||||
|
|
@ -254,6 +282,7 @@ export const CliLogsRichView = ({
|
|||
className={cn('cli-logs-compact max-h-[400px] space-y-1 overflow-y-auto', className)}
|
||||
onScroll={(e) => {
|
||||
const el = e.currentTarget;
|
||||
stickToEdgeRef.current = computeShouldStickToEdge(el);
|
||||
onScroll?.({
|
||||
scrollTop: el.scrollTop,
|
||||
scrollHeight: el.scrollHeight,
|
||||
|
|
|
|||
|
|
@ -287,13 +287,40 @@ export const ActivityTimeline = ({
|
|||
? item.group.thoughts[0].leadSessionId
|
||||
: item.message.leadSessionId;
|
||||
|
||||
// Pin the newest thought group (if first) so it stays at the top and doesn't jump.
|
||||
const pinnedThoughtGroup = timelineItems[0]?.type === 'lead-thoughts' ? timelineItems[0] : null;
|
||||
const startIndex = pinnedThoughtGroup ? 1 : 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
{timelineItems.map((item, index) => {
|
||||
{/* Pinned (newest) thought group — always at top */}
|
||||
{pinnedThoughtGroup &&
|
||||
(() => {
|
||||
const { group } = pinnedThoughtGroup;
|
||||
const firstThought = group.thoughts[0];
|
||||
const info = memberInfo.get(firstThought.from);
|
||||
const itemKey = `thoughts-${firstThought.messageId ?? pinnedThoughtGroup.originalIndices[0]}`;
|
||||
return (
|
||||
<LeadThoughtsGroupRow
|
||||
key={itemKey}
|
||||
group={group}
|
||||
memberColor={info?.color}
|
||||
canBeLive={true}
|
||||
isNew={newItemKeys.has(itemKey)}
|
||||
onVisible={onMessageVisible}
|
||||
zebraShade={zebraShadeSet.has(0)}
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Remaining items */}
|
||||
{timelineItems.slice(startIndex).map((item, index) => {
|
||||
const realIndex = index + startIndex;
|
||||
|
||||
// Session boundary separator (messages sorted desc — new on top)
|
||||
let sessionSeparator: React.JSX.Element | null = null;
|
||||
if (index > 0) {
|
||||
const prevSessionId = getItemSessionId(timelineItems[index - 1]);
|
||||
if (realIndex > 0) {
|
||||
const prevSessionId = getItemSessionId(timelineItems[realIndex - 1]);
|
||||
const currSessionId = getItemSessionId(item);
|
||||
if (prevSessionId && currSessionId && prevSessionId !== currSessionId) {
|
||||
sessionSeparator = (
|
||||
|
|
@ -322,9 +349,10 @@ export const ActivityTimeline = ({
|
|||
<LeadThoughtsGroupRow
|
||||
group={group}
|
||||
memberColor={info?.color}
|
||||
canBeLive={false}
|
||||
isNew={newItemKeys.has(itemKey)}
|
||||
onVisible={onMessageVisible}
|
||||
zebraShade={zebraShadeSet.has(index)}
|
||||
zebraShade={zebraShadeSet.has(realIndex)}
|
||||
/>
|
||||
</React.Fragment>
|
||||
);
|
||||
|
|
@ -350,7 +378,7 @@ export const ActivityTimeline = ({
|
|||
recipientColor={recipientColor}
|
||||
isUnread={isUnread}
|
||||
isNew={newItemKeys.has(messageKey)}
|
||||
zebraShade={zebraShadeSet.has(index)}
|
||||
zebraShade={zebraShadeSet.has(realIndex)}
|
||||
memberColorMap={colorMap}
|
||||
onMemberNameClick={onMemberClick ? handleMemberNameClick : undefined}
|
||||
onCreateTask={onCreateTaskFromMessage}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer';
|
||||
import { MemberBadge } from '@renderer/components/team/MemberBadge';
|
||||
import {
|
||||
CARD_BG,
|
||||
|
|
@ -75,6 +76,8 @@ interface LeadThoughtsGroupRowProps {
|
|||
memberColor?: string;
|
||||
isNew?: boolean;
|
||||
onVisible?: (message: InboxMessage) => void;
|
||||
/** When false, the live indicator is always off (for historical thought groups). */
|
||||
canBeLive?: boolean;
|
||||
/** When true, apply a subtle lighter background for zebra-striped lists. */
|
||||
zebraShade?: boolean;
|
||||
}
|
||||
|
|
@ -102,6 +105,7 @@ export const LeadThoughtsGroupRow = ({
|
|||
memberColor,
|
||||
isNew,
|
||||
onVisible,
|
||||
canBeLive,
|
||||
zebraShade,
|
||||
}: LeadThoughtsGroupRowProps): React.JSX.Element => {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
|
@ -130,11 +134,12 @@ export const LeadThoughtsGroupRow = ({
|
|||
// Live = process alive AND (lead is in active turn OR context recently updated OR fresh thought)
|
||||
const computeIsLive = useCallback(
|
||||
() =>
|
||||
canBeLive !== false &&
|
||||
isTeamAlive &&
|
||||
(leadActivity === 'active' ||
|
||||
(leadContextUpdatedAt ? isRecentTimestamp(leadContextUpdatedAt) : false) ||
|
||||
isRecentTimestamp(newest.timestamp)),
|
||||
[isTeamAlive, leadActivity, leadContextUpdatedAt, newest.timestamp]
|
||||
[canBeLive, isTeamAlive, leadActivity, leadContextUpdatedAt, newest.timestamp]
|
||||
);
|
||||
const [isLive, setIsLive] = useState(computeIsLive);
|
||||
|
||||
|
|
@ -168,13 +173,16 @@ export const LeadThoughtsGroupRow = ({
|
|||
return () => observer.disconnect();
|
||||
}, [onVisible, thoughts]);
|
||||
|
||||
// Auto-scroll to bottom when new thoughts arrive
|
||||
// Stable ref for auto-scroll trigger: track content changes (new thoughts + text growth)
|
||||
const newestTextLength = newest.text.length;
|
||||
|
||||
// Auto-scroll to bottom when new thoughts arrive or text grows
|
||||
useEffect(() => {
|
||||
if (isUserScrolledUpRef.current) return;
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
el.scrollTop = el.scrollHeight;
|
||||
}, [thoughts.length]);
|
||||
}, [thoughts.length, newestTextLength]);
|
||||
|
||||
const handleScroll = useCallback(() => {
|
||||
const el = scrollRef.current;
|
||||
|
|
@ -195,7 +203,6 @@ export const LeadThoughtsGroupRow = ({
|
|||
backgroundColor: zebraShade ? CARD_BG_ZEBRA : CARD_BG,
|
||||
border: CARD_BORDER_STYLE,
|
||||
borderLeft: `3px solid ${colors.border}`,
|
||||
opacity: isLive ? undefined : 0.75,
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
|
|
@ -236,9 +243,13 @@ export const LeadThoughtsGroupRow = ({
|
|||
<span className="shrink-0 font-mono" style={{ color: CARD_ICON_MUTED }}>
|
||||
{formatTimeWithSec(thought.timestamp)}
|
||||
</span>
|
||||
<span className="flex-1 leading-relaxed" style={{ color: CARD_TEXT_LIGHT }}>
|
||||
{thought.text.length > 300 ? thought.text.slice(0, 297) + '...' : thought.text}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1 [&_>div>div]:p-0" style={{ color: CARD_TEXT_LIGHT }}>
|
||||
<MarkdownViewer
|
||||
content={thought.text.replace(/\n/g, ' \n')}
|
||||
maxHeight="max-h-none"
|
||||
bare
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -61,17 +61,40 @@ export const MemberLogsTab = ({
|
|||
() => (taskWorkIntervals ? JSON.stringify(taskWorkIntervals) : ''),
|
||||
[taskWorkIntervals]
|
||||
);
|
||||
const isMountedRef = useRef(true);
|
||||
const hasLoadedRef = useRef(false);
|
||||
|
||||
const [logs, setLogs] = useState<MemberLogSummary[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const refreshCountRef = useRef(0);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [expandedId, setExpandedId] = useState<string | null>(null);
|
||||
const expandedIdRef = useRef<string | null>(null);
|
||||
const [detailChunks, setDetailChunks] = useState<EnhancedChunk[] | null>(null);
|
||||
const [detailLoading, setDetailLoading] = useState(false);
|
||||
const [previewChunks, setPreviewChunks] = useState<EnhancedChunk[] | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
isMountedRef.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
expandedIdRef.current = expandedId;
|
||||
}, [expandedId]);
|
||||
|
||||
const beginRefreshing = useCallback((): void => {
|
||||
refreshCountRef.current += 1;
|
||||
if (isMountedRef.current) setRefreshing(true);
|
||||
}, []);
|
||||
|
||||
const endRefreshing = useCallback((): void => {
|
||||
refreshCountRef.current = Math.max(0, refreshCountRef.current - 1);
|
||||
if (isMountedRef.current) setRefreshing(refreshCountRef.current > 0);
|
||||
}, []);
|
||||
|
||||
const getRowId = useCallback((log: MemberLogSummary): string => {
|
||||
return log.kind === 'subagent'
|
||||
? `subagent:${log.sessionId}:${log.subagentId}`
|
||||
|
|
@ -173,6 +196,7 @@ export const MemberLogsTab = ({
|
|||
const shouldAutoRefresh = taskId != null && taskStatus === 'in_progress';
|
||||
|
||||
const load = async (): Promise<void> => {
|
||||
let didBeginRefreshing = false;
|
||||
try {
|
||||
if (taskId == null && !memberName) {
|
||||
if (!cancelled) setLogs([]);
|
||||
|
|
@ -181,7 +205,8 @@ export const MemberLogsTab = ({
|
|||
if (!hasLoadedRef.current) {
|
||||
setLoading(true);
|
||||
} else {
|
||||
setRefreshing(true);
|
||||
beginRefreshing();
|
||||
didBeginRefreshing = true;
|
||||
}
|
||||
setError(null);
|
||||
|
||||
|
|
@ -193,10 +218,22 @@ export const MemberLogsTab = ({
|
|||
intervals: taskWorkIntervals,
|
||||
})
|
||||
: await api.teams.getMemberLogs(teamName, memberName!);
|
||||
const nextLogs = Array.isArray(result) ? [...result] : [];
|
||||
|
||||
if (!cancelled) {
|
||||
setLogs(Array.isArray(result) ? [...result] : []);
|
||||
setLogs(nextLogs);
|
||||
hasLoadedRef.current = true;
|
||||
}
|
||||
|
||||
// Keep expanded session details in sync with the same refresh
|
||||
// cadence as the summary (counts/titles) while "Updating..." is shown.
|
||||
if (!cancelled && didBeginRefreshing) {
|
||||
try {
|
||||
await refreshExpandedDetailFromLogs(nextLogs);
|
||||
} catch {
|
||||
// Keep last successful detail view; avoid flicker on transient failures.
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (!cancelled) {
|
||||
setError(e instanceof Error ? e.message : 'Unknown error');
|
||||
|
|
@ -204,7 +241,7 @@ export const MemberLogsTab = ({
|
|||
} finally {
|
||||
if (!cancelled) {
|
||||
setLoading(false);
|
||||
setRefreshing(false);
|
||||
if (didBeginRefreshing) endRefreshing();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
@ -232,6 +269,26 @@ export const MemberLogsTab = ({
|
|||
[]
|
||||
);
|
||||
|
||||
const refreshExpandedDetailFromLogs = useCallback(
|
||||
async (nextLogs: MemberLogSummary[]): Promise<void> => {
|
||||
const rowId = expandedIdRef.current;
|
||||
if (!rowId) return;
|
||||
if (!isMountedRef.current) return;
|
||||
|
||||
const nextExpanded = nextLogs.find((log) => getRowId(log) === rowId);
|
||||
if (!nextExpanded) return;
|
||||
|
||||
const shouldAutoRefreshSummary = taskId != null && taskStatus === 'in_progress';
|
||||
if (!shouldAutoRefreshSummary && !nextExpanded.isOngoing) return;
|
||||
|
||||
const next = await fetchDetailForLog(nextExpanded);
|
||||
if (!isMountedRef.current) return;
|
||||
// Ensure new reference so memoized transforms update.
|
||||
setDetailChunks(next ? [...next] : null);
|
||||
},
|
||||
[fetchDetailForLog, getRowId, taskId, taskStatus]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!shouldShowPreview) {
|
||||
setPreviewChunks(null);
|
||||
|
|
@ -268,12 +325,15 @@ export const MemberLogsTab = ({
|
|||
|
||||
let cancelled = false;
|
||||
const interval = setInterval(async () => {
|
||||
beginRefreshing();
|
||||
try {
|
||||
const next = await fetchDetailForLog(previewLog);
|
||||
if (cancelled) return;
|
||||
setPreviewChunks(next ? [...next] : null);
|
||||
} catch {
|
||||
// keep last successful preview
|
||||
} finally {
|
||||
endRefreshing();
|
||||
}
|
||||
}, 5000);
|
||||
|
||||
|
|
@ -281,16 +341,27 @@ export const MemberLogsTab = ({
|
|||
cancelled = true;
|
||||
clearInterval(interval);
|
||||
};
|
||||
}, [fetchDetailForLog, previewLog, shouldShowPreview, taskStatus]);
|
||||
}, [
|
||||
beginRefreshing,
|
||||
endRefreshing,
|
||||
fetchDetailForLog,
|
||||
previewLog,
|
||||
shouldShowPreview,
|
||||
taskStatus,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
const shouldAutoRefreshSummary = taskId != null && taskStatus === 'in_progress';
|
||||
if (!expandedLogSummary) return;
|
||||
if (!shouldAutoRefreshSummary && !expandedLogSummary.isOngoing) return;
|
||||
// When task logs are auto-refreshing, the summary refresh loop also refreshes
|
||||
// expanded details to keep everything in sync (and avoid duplicate requests).
|
||||
if (shouldAutoRefreshSummary) return;
|
||||
if (!expandedLogSummary.isOngoing) return;
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
const interval = setInterval(async () => {
|
||||
const refreshDetail = async (): Promise<void> => {
|
||||
beginRefreshing();
|
||||
try {
|
||||
const next = await fetchDetailForLog(expandedLogSummary);
|
||||
if (cancelled) return;
|
||||
|
|
@ -298,14 +369,18 @@ export const MemberLogsTab = ({
|
|||
setDetailChunks(next ? [...next] : null);
|
||||
} catch {
|
||||
// Keep last successful data; avoid flicker during transient errors.
|
||||
} finally {
|
||||
endRefreshing();
|
||||
}
|
||||
}, 5000);
|
||||
};
|
||||
|
||||
const interval = setInterval(() => void refreshDetail(), 5000);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearInterval(interval);
|
||||
};
|
||||
}, [expandedLogSummary, fetchDetailForLog, taskId, taskStatus]);
|
||||
}, [beginRefreshing, endRefreshing, expandedLogSummary, fetchDetailForLog, taskId, taskStatus]);
|
||||
|
||||
const handleExpand = useCallback(
|
||||
async (log: MemberLogSummary) => {
|
||||
|
|
|
|||
|
|
@ -414,7 +414,7 @@ export const MessageComposer = ({
|
|||
|
||||
<MentionableTextarea
|
||||
id={`compose-${teamName}`}
|
||||
placeholder="Write a message... (Enter to send)"
|
||||
placeholder="Write a message... (Enter to send, Shift+Enter for new line)"
|
||||
value={draft.value}
|
||||
onValueChange={draft.setValue}
|
||||
suggestions={mentionSuggestions}
|
||||
|
|
|
|||
|
|
@ -31,23 +31,25 @@ const DialogContent = React.forwardRef<
|
|||
>(({ className, children, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'fixed left-1/2 top-1/2 z-50 grid w-full max-w-lg -translate-x-1/2 -translate-y-1/2 gap-4 border border-[var(--color-border)] bg-[var(--color-surface)] p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
|
||||
'max-h-[90vh] min-h-0 overflow-y-auto overflow-x-hidden',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="sticky top-0 z-10 -mr-6 -mt-6 h-0 w-full overflow-visible">
|
||||
<DialogPrimitive.Close className="absolute right-0 top-0 rounded-sm p-1.5 opacity-70 transition-opacity hover:opacity-100 focus:outline-none focus:ring-1 focus:ring-[var(--color-border-emphasis)] disabled:pointer-events-none">
|
||||
<div className="pointer-events-none fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<div className="pointer-events-auto relative">
|
||||
<DialogPrimitive.Close className="absolute -right-4 -top-4 z-10 rounded-full bg-[var(--color-surface-raised)] p-1.5 opacity-70 shadow-lg ring-1 ring-[var(--color-border)] transition-opacity hover:opacity-100 focus:outline-none focus:ring-1 focus:ring-[var(--color-border-emphasis)] disabled:pointer-events-none">
|
||||
<X className="size-4 text-[var(--color-text-muted)]" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'grid w-full max-w-lg gap-4 border border-[var(--color-border)] bg-[var(--color-surface)] p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 sm:rounded-lg',
|
||||
'max-h-[90vh] min-h-0 overflow-y-auto overflow-x-hidden',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</DialogPrimitive.Content>
|
||||
</div>
|
||||
{children}
|
||||
</DialogPrimitive.Content>
|
||||
</div>
|
||||
</DialogPortal>
|
||||
));
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName;
|
||||
|
|
|
|||
|
|
@ -208,6 +208,8 @@ export interface SendMessageRequest {
|
|||
from?: string;
|
||||
attachments?: AttachmentPayload[];
|
||||
source?: InboxMessage['source'];
|
||||
/** Lead session ID for session boundary detection. */
|
||||
leadSessionId?: string;
|
||||
}
|
||||
|
||||
export interface SendMessageResult {
|
||||
|
|
|
|||
Loading…
Reference in a new issue