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:
iliya 2026-03-06 01:24:09 +02:00
parent 410cb8a10f
commit 297780e3a8
13 changed files with 231 additions and 48 deletions

View file

@ -430,7 +430,7 @@ async function handleGetData(
const merged: typeof data.messages = []; const merged: typeof data.messages = [];
const seen = new Set<string>(); const seen = new Set<string>();
for (const msg of [...data.messages, ...live]) { 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 ?? '')}`; const fp = `${msg.from}\0${normalizeText(msg.text ?? '')}`;
// Skip if same text already exists from any source (inbox, lead_session, etc.) // Skip if same text already exists from any source (inbox, lead_session, etc.)
if (existingTextFingerprints.has(fp)) { if (existingTextFingerprints.has(fp)) {

View file

@ -865,6 +865,7 @@ function sendInboxMessage(paths, teamName, flags) {
summary, summary,
messageId, messageId,
}; };
if (flags.source) payload.source = flags.source;
var lastErr; var lastErr;
for (var attempt = 0; attempt < 8; attempt++) { for (var attempt = 0; attempt < 8; attempt++) {
@ -915,6 +916,7 @@ function reviewApprove(paths, teamName, taskId, flags) {
text: inboxText, text: inboxText,
summary: 'Approved #' + String(taskId), summary: 'Approved #' + String(taskId),
from, from,
source: 'system_notification',
}); });
} }
@ -957,6 +959,7 @@ function reviewRequestChanges(paths, teamName, taskId, flags) {
text: inboxText, text: inboxText,
summary: 'Fix request for #' + String(taskId), summary: 'Fix request for #' + String(taskId),
from, from,
source: 'system_notification',
}); });
} }
@ -1282,6 +1285,7 @@ async function main() {
text: parts.join('\n'), text: parts.join('\n'),
summary: 'New task #' + String(task.id) + ' assigned', summary: 'New task #' + String(task.id) + ' assigned',
from, 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() : ''), 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), summary: 'Comment on #' + String(result.taskId),
from: from, from: from,
source: 'system_notification',
}); });
} catch (e) { /* best-effort */ } } catch (e) { /* best-effort */ }
} }
@ -1396,6 +1401,7 @@ async function main() {
text: parts.join('\n'), text: parts.join('\n'),
summary: 'Task #' + String(task.id) + ' assigned', summary: 'Task #' + String(task.id) + ' assigned',
from, from,
source: 'system_notification',
}); });
} }
return; return;
@ -1493,7 +1499,10 @@ async function main() {
if (domain === 'message') { if (domain === 'message') {
if (action === 'send') { 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'); process.stdout.write(JSON.stringify(result, null, 2) + '\n');
return; return;
} }

View file

@ -283,6 +283,7 @@ export class TeamDataService {
// Dedup: if a lead_process message text is also present in lead_session, prefer lead_session. // 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. // 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) { if (leadTexts.length > 0 && sentMessages.length > 0) {
const normalizeText = (text: string): string => text.trim().replace(/\r\n/g, '\n'); const normalizeText = (text: string): string => text.trim().replace(/\r\n/g, '\n');
const leadSessionFingerprints = new Set<string>(); const leadSessionFingerprints = new Set<string>();
@ -292,6 +293,8 @@ export class TeamDataService {
} }
messages = messages.filter((m) => { messages = messages.filter((m) => {
if (m.source !== 'lead_process') return true; 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 ?? '')}`; const fp = `${m.from}\0${normalizeText(m.text ?? '')}`;
return !leadSessionFingerprints.has(fp); return !leadSessionFingerprints.has(fp);
}); });
@ -311,8 +314,11 @@ export class TeamDataService {
msg.leadSessionId = currentSessionId; msg.leadSessionId = currentSessionId;
} }
} }
// Backward pass: fill messages before the first known session // Backward pass: fill messages before the first known session.
currentSessionId = undefined; // 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--) { for (let i = messages.length - 1; i >= 0; i--) {
if (messages[i].leadSessionId) { if (messages[i].leadSessionId) {
currentSessionId = messages[i].leadSessionId; currentSessionId = messages[i].leadSessionId;
@ -1117,6 +1123,17 @@ export class TeamDataService {
} }
async sendMessage(teamName: string, request: SendMessageRequest): Promise<SendMessageResult> { 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); return this.inboxWriter.sendMessage(teamName, request);
} }

View file

@ -30,6 +30,7 @@ export class TeamInboxWriter {
messageId, messageId,
attachments: attachmentMeta?.length ? attachmentMeta : undefined, attachments: attachmentMeta?.length ? attachmentMeta : undefined,
...(request.source && { source: request.source }), ...(request.source && { source: request.source }),
...(request.leadSessionId && { leadSessionId: request.leadSessionId }),
}; };
await withInboxLock(inboxPath, async () => { await withInboxLock(inboxPath, async () => {

View file

@ -79,8 +79,6 @@ const PREFLIGHT_PING_ARGS = [
'haiku', 'haiku',
'--max-turns', '--max-turns',
'1', '1',
'--max-budget-usd',
'0.05',
'--no-session-persistence', '--no-session-persistence',
] as const; ] as const;
const PREFLIGHT_EXPECTED = 'PONG'; const PREFLIGHT_EXPECTED = 'PONG';
@ -2797,6 +2795,16 @@ export class TeamProvisioningService {
} }
pushLiveLeadProcessMessage(teamName: string, message: InboxMessage): void { 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 MAX = 100;
const list = this.liveLeadProcessMessages.get(teamName) ?? []; const list = this.liveLeadProcessMessages.get(teamName) ?? [];
const id = typeof message.messageId === 'string' ? message.messageId.trim() : ''; const id = typeof message.messageId === 'string' ? message.messageId.trim() : '';

View file

@ -330,7 +330,8 @@ export const ClaudeLogsSection = ({ teamName }: ClaudeLogsSectionProps): React.J
badge={badge} badge={badge}
headerExtra={headerExtra} headerExtra={headerExtra}
defaultOpen 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"> <div className="flex items-center justify-between gap-2 pb-2">
<span className="text-[11px] text-[var(--color-text-muted)]"> <span className="text-[11px] text-[var(--color-text-muted)]">

View file

@ -17,9 +17,11 @@ import { Bot, ChevronRight } from 'lucide-react';
import type { StreamJsonGroup } from '@renderer/utils/streamJsonParser'; import type { StreamJsonGroup } from '@renderer/utils/streamJsonParser';
type CliLogsOrder = 'oldest-first' | 'newest-first';
interface CliLogsRichViewProps { interface CliLogsRichViewProps {
cliLogsTail: string; cliLogsTail: string;
order?: 'oldest-first' | 'newest-first'; order?: CliLogsOrder;
onScroll?: (params: { scrollTop: number; scrollHeight: number; clientHeight: number }) => void; onScroll?: (params: { scrollTop: number; scrollHeight: number; clientHeight: number }) => void;
containerRefCallback?: (el: HTMLDivElement | null) => void; containerRefCallback?: (el: HTMLDivElement | null) => void;
/** Optional local search query override for inline highlighting */ /** Optional local search query override for inline highlighting */
@ -156,6 +158,8 @@ export const CliLogsRichView = ({
className, className,
}: CliLogsRichViewProps): React.JSX.Element => { }: CliLogsRichViewProps): React.JSX.Element => {
const scrollRef = useRef<HTMLDivElement | null>(null); 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) // Tracks groups manually collapsed by user (default: all auto-expanded)
const [collapsedGroupIds, setCollapsedGroupIds] = useState<Set<string>>(new Set()); const [collapsedGroupIds, setCollapsedGroupIds] = useState<Set<string>>(new Set());
const [expandedItemIds, setExpandedItemIds] = useState<Set<string>>(new Set()); const [expandedItemIds, setExpandedItemIds] = useState<Set<string>>(new Set());
@ -173,15 +177,38 @@ export const CliLogsRichView = ({
return expanded; return expanded;
}, [groups, collapsedGroupIds]); }, [groups, collapsedGroupIds]);
// Auto-scroll to bottom on new content const computeShouldStickToEdge = useCallback(
useEffect(() => { (el: HTMLDivElement): boolean => {
if (scrollRef.current) { // Small threshold makes it feel "sticky" but still allows reading slightly away from the edge
const thresholdPx = 16;
if (order === 'newest-first') { if (order === 'newest-first') {
scrollRef.current.scrollTop = 0; return el.scrollTop <= thresholdPx;
} else {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
} }
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]); }, [cliLogsTail, order]);
const handleGroupToggle = useCallback((groupId: string) => { const handleGroupToggle = useCallback((groupId: string) => {
@ -223,6 +250,7 @@ export const CliLogsRichView = ({
)} )}
onScroll={(e) => { onScroll={(e) => {
const el = e.currentTarget; const el = e.currentTarget;
stickToEdgeRef.current = computeShouldStickToEdge(el);
onScroll?.({ onScroll?.({
scrollTop: el.scrollTop, scrollTop: el.scrollTop,
scrollHeight: el.scrollHeight, 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)} className={cn('cli-logs-compact max-h-[400px] space-y-1 overflow-y-auto', className)}
onScroll={(e) => { onScroll={(e) => {
const el = e.currentTarget; const el = e.currentTarget;
stickToEdgeRef.current = computeShouldStickToEdge(el);
onScroll?.({ onScroll?.({
scrollTop: el.scrollTop, scrollTop: el.scrollTop,
scrollHeight: el.scrollHeight, scrollHeight: el.scrollHeight,

View file

@ -287,13 +287,40 @@ export const ActivityTimeline = ({
? item.group.thoughts[0].leadSessionId ? item.group.thoughts[0].leadSessionId
: item.message.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 ( return (
<div className="space-y-1"> <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) // Session boundary separator (messages sorted desc — new on top)
let sessionSeparator: React.JSX.Element | null = null; let sessionSeparator: React.JSX.Element | null = null;
if (index > 0) { if (realIndex > 0) {
const prevSessionId = getItemSessionId(timelineItems[index - 1]); const prevSessionId = getItemSessionId(timelineItems[realIndex - 1]);
const currSessionId = getItemSessionId(item); const currSessionId = getItemSessionId(item);
if (prevSessionId && currSessionId && prevSessionId !== currSessionId) { if (prevSessionId && currSessionId && prevSessionId !== currSessionId) {
sessionSeparator = ( sessionSeparator = (
@ -322,9 +349,10 @@ export const ActivityTimeline = ({
<LeadThoughtsGroupRow <LeadThoughtsGroupRow
group={group} group={group}
memberColor={info?.color} memberColor={info?.color}
canBeLive={false}
isNew={newItemKeys.has(itemKey)} isNew={newItemKeys.has(itemKey)}
onVisible={onMessageVisible} onVisible={onMessageVisible}
zebraShade={zebraShadeSet.has(index)} zebraShade={zebraShadeSet.has(realIndex)}
/> />
</React.Fragment> </React.Fragment>
); );
@ -350,7 +378,7 @@ export const ActivityTimeline = ({
recipientColor={recipientColor} recipientColor={recipientColor}
isUnread={isUnread} isUnread={isUnread}
isNew={newItemKeys.has(messageKey)} isNew={newItemKeys.has(messageKey)}
zebraShade={zebraShadeSet.has(index)} zebraShade={zebraShadeSet.has(realIndex)}
memberColorMap={colorMap} memberColorMap={colorMap}
onMemberNameClick={onMemberClick ? handleMemberNameClick : undefined} onMemberNameClick={onMemberClick ? handleMemberNameClick : undefined}
onCreateTask={onCreateTaskFromMessage} onCreateTask={onCreateTaskFromMessage}

View file

@ -1,5 +1,6 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer';
import { MemberBadge } from '@renderer/components/team/MemberBadge'; import { MemberBadge } from '@renderer/components/team/MemberBadge';
import { import {
CARD_BG, CARD_BG,
@ -75,6 +76,8 @@ interface LeadThoughtsGroupRowProps {
memberColor?: string; memberColor?: string;
isNew?: boolean; isNew?: boolean;
onVisible?: (message: InboxMessage) => void; 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. */ /** When true, apply a subtle lighter background for zebra-striped lists. */
zebraShade?: boolean; zebraShade?: boolean;
} }
@ -102,6 +105,7 @@ export const LeadThoughtsGroupRow = ({
memberColor, memberColor,
isNew, isNew,
onVisible, onVisible,
canBeLive,
zebraShade, zebraShade,
}: LeadThoughtsGroupRowProps): React.JSX.Element => { }: LeadThoughtsGroupRowProps): React.JSX.Element => {
const ref = useRef<HTMLDivElement>(null); 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) // Live = process alive AND (lead is in active turn OR context recently updated OR fresh thought)
const computeIsLive = useCallback( const computeIsLive = useCallback(
() => () =>
canBeLive !== false &&
isTeamAlive && isTeamAlive &&
(leadActivity === 'active' || (leadActivity === 'active' ||
(leadContextUpdatedAt ? isRecentTimestamp(leadContextUpdatedAt) : false) || (leadContextUpdatedAt ? isRecentTimestamp(leadContextUpdatedAt) : false) ||
isRecentTimestamp(newest.timestamp)), isRecentTimestamp(newest.timestamp)),
[isTeamAlive, leadActivity, leadContextUpdatedAt, newest.timestamp] [canBeLive, isTeamAlive, leadActivity, leadContextUpdatedAt, newest.timestamp]
); );
const [isLive, setIsLive] = useState(computeIsLive); const [isLive, setIsLive] = useState(computeIsLive);
@ -168,13 +173,16 @@ export const LeadThoughtsGroupRow = ({
return () => observer.disconnect(); return () => observer.disconnect();
}, [onVisible, thoughts]); }, [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(() => { useEffect(() => {
if (isUserScrolledUpRef.current) return; if (isUserScrolledUpRef.current) return;
const el = scrollRef.current; const el = scrollRef.current;
if (!el) return; if (!el) return;
el.scrollTop = el.scrollHeight; el.scrollTop = el.scrollHeight;
}, [thoughts.length]); }, [thoughts.length, newestTextLength]);
const handleScroll = useCallback(() => { const handleScroll = useCallback(() => {
const el = scrollRef.current; const el = scrollRef.current;
@ -195,7 +203,6 @@ export const LeadThoughtsGroupRow = ({
backgroundColor: zebraShade ? CARD_BG_ZEBRA : CARD_BG, backgroundColor: zebraShade ? CARD_BG_ZEBRA : CARD_BG,
border: CARD_BORDER_STYLE, border: CARD_BORDER_STYLE,
borderLeft: `3px solid ${colors.border}`, borderLeft: `3px solid ${colors.border}`,
opacity: isLive ? undefined : 0.75,
}} }}
> >
{/* Header */} {/* Header */}
@ -236,9 +243,13 @@ export const LeadThoughtsGroupRow = ({
<span className="shrink-0 font-mono" style={{ color: CARD_ICON_MUTED }}> <span className="shrink-0 font-mono" style={{ color: CARD_ICON_MUTED }}>
{formatTimeWithSec(thought.timestamp)} {formatTimeWithSec(thought.timestamp)}
</span> </span>
<span className="flex-1 leading-relaxed" style={{ color: CARD_TEXT_LIGHT }}> <div className="min-w-0 flex-1 [&_>div>div]:p-0" style={{ color: CARD_TEXT_LIGHT }}>
{thought.text.length > 300 ? thought.text.slice(0, 297) + '...' : thought.text} <MarkdownViewer
</span> content={thought.text.replace(/\n/g, ' \n')}
maxHeight="max-h-none"
bare
/>
</div>
</div> </div>
))} ))}
</div> </div>

View file

@ -61,17 +61,40 @@ export const MemberLogsTab = ({
() => (taskWorkIntervals ? JSON.stringify(taskWorkIntervals) : ''), () => (taskWorkIntervals ? JSON.stringify(taskWorkIntervals) : ''),
[taskWorkIntervals] [taskWorkIntervals]
); );
const isMountedRef = useRef(true);
const hasLoadedRef = useRef(false); const hasLoadedRef = useRef(false);
const [logs, setLogs] = useState<MemberLogSummary[]>([]); const [logs, setLogs] = useState<MemberLogSummary[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false); const [refreshing, setRefreshing] = useState(false);
const refreshCountRef = useRef(0);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [expandedId, setExpandedId] = 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 [detailChunks, setDetailChunks] = useState<EnhancedChunk[] | null>(null);
const [detailLoading, setDetailLoading] = useState(false); const [detailLoading, setDetailLoading] = useState(false);
const [previewChunks, setPreviewChunks] = useState<EnhancedChunk[] | null>(null); 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 => { const getRowId = useCallback((log: MemberLogSummary): string => {
return log.kind === 'subagent' return log.kind === 'subagent'
? `subagent:${log.sessionId}:${log.subagentId}` ? `subagent:${log.sessionId}:${log.subagentId}`
@ -173,6 +196,7 @@ export const MemberLogsTab = ({
const shouldAutoRefresh = taskId != null && taskStatus === 'in_progress'; const shouldAutoRefresh = taskId != null && taskStatus === 'in_progress';
const load = async (): Promise<void> => { const load = async (): Promise<void> => {
let didBeginRefreshing = false;
try { try {
if (taskId == null && !memberName) { if (taskId == null && !memberName) {
if (!cancelled) setLogs([]); if (!cancelled) setLogs([]);
@ -181,7 +205,8 @@ export const MemberLogsTab = ({
if (!hasLoadedRef.current) { if (!hasLoadedRef.current) {
setLoading(true); setLoading(true);
} else { } else {
setRefreshing(true); beginRefreshing();
didBeginRefreshing = true;
} }
setError(null); setError(null);
@ -193,10 +218,22 @@ export const MemberLogsTab = ({
intervals: taskWorkIntervals, intervals: taskWorkIntervals,
}) })
: await api.teams.getMemberLogs(teamName, memberName!); : await api.teams.getMemberLogs(teamName, memberName!);
const nextLogs = Array.isArray(result) ? [...result] : [];
if (!cancelled) { if (!cancelled) {
setLogs(Array.isArray(result) ? [...result] : []); setLogs(nextLogs);
hasLoadedRef.current = true; 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) { } catch (e) {
if (!cancelled) { if (!cancelled) {
setError(e instanceof Error ? e.message : 'Unknown error'); setError(e instanceof Error ? e.message : 'Unknown error');
@ -204,7 +241,7 @@ export const MemberLogsTab = ({
} finally { } finally {
if (!cancelled) { if (!cancelled) {
setLoading(false); 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(() => { useEffect(() => {
if (!shouldShowPreview) { if (!shouldShowPreview) {
setPreviewChunks(null); setPreviewChunks(null);
@ -268,12 +325,15 @@ export const MemberLogsTab = ({
let cancelled = false; let cancelled = false;
const interval = setInterval(async () => { const interval = setInterval(async () => {
beginRefreshing();
try { try {
const next = await fetchDetailForLog(previewLog); const next = await fetchDetailForLog(previewLog);
if (cancelled) return; if (cancelled) return;
setPreviewChunks(next ? [...next] : null); setPreviewChunks(next ? [...next] : null);
} catch { } catch {
// keep last successful preview // keep last successful preview
} finally {
endRefreshing();
} }
}, 5000); }, 5000);
@ -281,16 +341,27 @@ export const MemberLogsTab = ({
cancelled = true; cancelled = true;
clearInterval(interval); clearInterval(interval);
}; };
}, [fetchDetailForLog, previewLog, shouldShowPreview, taskStatus]); }, [
beginRefreshing,
endRefreshing,
fetchDetailForLog,
previewLog,
shouldShowPreview,
taskStatus,
]);
useEffect(() => { useEffect(() => {
const shouldAutoRefreshSummary = taskId != null && taskStatus === 'in_progress'; const shouldAutoRefreshSummary = taskId != null && taskStatus === 'in_progress';
if (!expandedLogSummary) return; 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; let cancelled = false;
const interval = setInterval(async () => { const refreshDetail = async (): Promise<void> => {
beginRefreshing();
try { try {
const next = await fetchDetailForLog(expandedLogSummary); const next = await fetchDetailForLog(expandedLogSummary);
if (cancelled) return; if (cancelled) return;
@ -298,14 +369,18 @@ export const MemberLogsTab = ({
setDetailChunks(next ? [...next] : null); setDetailChunks(next ? [...next] : null);
} catch { } catch {
// Keep last successful data; avoid flicker during transient errors. // Keep last successful data; avoid flicker during transient errors.
} finally {
endRefreshing();
} }
}, 5000); };
const interval = setInterval(() => void refreshDetail(), 5000);
return () => { return () => {
cancelled = true; cancelled = true;
clearInterval(interval); clearInterval(interval);
}; };
}, [expandedLogSummary, fetchDetailForLog, taskId, taskStatus]); }, [beginRefreshing, endRefreshing, expandedLogSummary, fetchDetailForLog, taskId, taskStatus]);
const handleExpand = useCallback( const handleExpand = useCallback(
async (log: MemberLogSummary) => { async (log: MemberLogSummary) => {

View file

@ -414,7 +414,7 @@ export const MessageComposer = ({
<MentionableTextarea <MentionableTextarea
id={`compose-${teamName}`} 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} value={draft.value}
onValueChange={draft.setValue} onValueChange={draft.setValue}
suggestions={mentionSuggestions} suggestions={mentionSuggestions}

View file

@ -31,23 +31,25 @@ const DialogContent = React.forwardRef<
>(({ className, children, ...props }, ref) => ( >(({ className, children, ...props }, ref) => (
<DialogPortal> <DialogPortal>
<DialogOverlay /> <DialogOverlay />
<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 <DialogPrimitive.Content
ref={ref} ref={ref}
className={cn( 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', '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', 'max-h-[90vh] min-h-0 overflow-y-auto overflow-x-hidden',
className className
)} )}
{...props} {...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">
<X className="size-4 text-[var(--color-text-muted)]" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</div>
{children} {children}
</DialogPrimitive.Content> </DialogPrimitive.Content>
</div>
</div>
</DialogPortal> </DialogPortal>
)); ));
DialogContent.displayName = DialogPrimitive.Content.displayName; DialogContent.displayName = DialogPrimitive.Content.displayName;

View file

@ -208,6 +208,8 @@ export interface SendMessageRequest {
from?: string; from?: string;
attachments?: AttachmentPayload[]; attachments?: AttachmentPayload[];
source?: InboxMessage['source']; source?: InboxMessage['source'];
/** Lead session ID for session boundary detection. */
leadSessionId?: string;
} }
export interface SendMessageResult { export interface SendMessageResult {