agent-ecosystem/src/renderer/components/team/activity/LeadThoughtsGroup.tsx
iliya 9bfcbb182c feat: enhance UI and functionality in team dialogs and components
- Improved lightbox toolbar button hit targets for better accessibility.
- Updated ActivityItem and ActivityTimeline components to support managed collapse states for messages.
- Refactored message collapsing logic to allow for user-controlled expansion in various components.
- Enhanced CreateTeamDialog and LaunchTeamDialog with improved loading indicators and layout adjustments.
- Increased maximum message length in SendMessageDialog to accommodate larger inputs.
- Added icons and visual enhancements in ProjectPathSelector and EffortLevelSelector for better user experience.
2026-03-06 23:21:56 +02:00

723 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { ChevronDown, ChevronRight, ChevronUp } from 'lucide-react';
import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer';
import { MemberBadge } from '@renderer/components/team/MemberBadge';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import {
CARD_BG,
CARD_BG_ZEBRA,
CARD_BORDER_STYLE,
CARD_ICON_MUTED,
CARD_TEXT_LIGHT,
} from '@renderer/constants/cssVariables';
import { getTeamColorSet } from '@renderer/constants/teamColors';
import { useStore } from '@renderer/store';
import { formatToolSummary, parseToolSummary } from '@shared/utils/toolSummary';
import { isManagedCollapseState } from './collapseState';
import type { ActivityCollapseState } from './collapseState';
import type { InboxMessage, ToolCallMeta } from '@shared/types';
export interface LeadThoughtGroup {
type: 'lead-thoughts';
thoughts: InboxMessage[];
}
/**
* Check if a message is an intermediate lead "thought" (assistant text) rather than
* an official message (SendMessage, direct reply, inbox, etc.).
*/
export function isLeadThought(msg: InboxMessage): boolean {
if (msg.source === 'lead_session') return true;
if (msg.source === 'lead_process') return true;
return false;
}
export type TimelineItem =
| { type: 'message'; message: InboxMessage; originalIndex: number }
| { type: 'lead-thoughts'; group: LeadThoughtGroup; originalIndices: number[] };
/**
* Group consecutive lead thoughts into compact blocks.
* Even a single thought gets its own group (rendered as LeadThoughtsGroupRow).
*/
export function groupTimelineItems(messages: InboxMessage[]): TimelineItem[] {
const result: TimelineItem[] = [];
let pendingThoughts: InboxMessage[] = [];
let pendingIndices: number[] = [];
const hasSameLeadSession = (a: InboxMessage, b: InboxMessage): boolean =>
(a.leadSessionId ?? null) === (b.leadSessionId ?? null);
const flushThoughts = (): void => {
if (pendingThoughts.length === 0) return;
result.push({
type: 'lead-thoughts',
group: { type: 'lead-thoughts', thoughts: pendingThoughts },
originalIndices: pendingIndices,
});
pendingThoughts = [];
pendingIndices = [];
};
for (let i = 0; i < messages.length; i++) {
const msg = messages[i];
if (isLeadThought(msg)) {
const previousThought = pendingThoughts[pendingThoughts.length - 1];
if (previousThought && !hasSameLeadSession(previousThought, msg)) {
flushThoughts();
}
pendingThoughts.push(msg);
pendingIndices.push(i);
} else {
flushThoughts();
result.push({ type: 'message', message: msg, originalIndex: i });
}
}
flushThoughts();
return result;
}
const VIEWPORT_THRESHOLD = 0.15;
const LIVE_WINDOW_MS = 5_000;
const COLLAPSED_THOUGHTS_HEIGHT = 200;
const AUTO_SCROLL_THRESHOLD = 30;
const THOUGHT_HEIGHT_ANIMATION_MS = 220;
interface LeadThoughtsGroupRowProps {
group: LeadThoughtGroup;
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;
/** Explicit collapse state for timeline-controlled collapsed mode. */
collapseState?: ActivityCollapseState;
}
function formatTime(timestamp: string): string {
const d = new Date(timestamp);
if (Number.isNaN(d.getTime())) return timestamp;
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
}
function formatTimeWithSec(timestamp: string): string {
const d = new Date(timestamp);
if (Number.isNaN(d.getTime())) return timestamp;
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
}
function isRecentTimestamp(timestamp: string): boolean {
const t = Date.parse(timestamp);
if (Number.isNaN(t)) return false;
return Date.now() - t <= LIVE_WINDOW_MS;
}
const ToolSummaryTooltipContent = ({
toolCalls,
toolSummary,
}: Readonly<{
toolCalls?: ToolCallMeta[];
toolSummary?: string;
}>): JSX.Element => {
if (toolCalls && toolCalls.length > 0) {
return (
<div className="flex max-h-[300px] flex-col gap-0.5 overflow-y-auto">
<div className="mb-0.5 text-[10px] text-text-secondary">
{toolCalls.length} {toolCalls.length === 1 ? 'tool call' : 'tool calls'}
</div>
{toolCalls.map((tc, i) => {
const isAgent = tc.name === 'Agent' || tc.name === 'TaskCreate';
return (
<div key={i} className={isAgent ? 'mt-0.5' : 'flex items-baseline gap-2'}>
<span className={`shrink-0 font-semibold ${isAgent ? 'text-violet-400' : ''}`}>
{isAgent ? '🤖 ' : ''}
{tc.name}
</span>
{tc.preview && (
<span
className={`text-text-secondary ${isAgent ? 'mt-0.5 block text-[10px]' : 'truncate'}`}
>
{tc.preview}
</span>
)}
</div>
);
})}
</div>
);
}
if (toolSummary) {
const parsed = parseToolSummary(toolSummary);
if (parsed) {
const sorted = Object.entries(parsed.byName).sort((a, b) => b[1] - a[1]);
return (
<div className="flex flex-col gap-0.5">
<div className="mb-0.5 text-[10px] text-text-secondary">
{parsed.total} {parsed.total === 1 ? 'tool call' : 'tool calls'}
</div>
{sorted.map(([name, count]) => (
<div key={name} className="flex justify-between gap-3">
<span>{name}</span>
<span className="text-text-secondary">×{count}</span>
</div>
))}
</div>
);
}
}
return <span>{toolSummary ?? ''}</span>;
};
interface LeadThoughtItemProps {
thought: InboxMessage;
showDivider: boolean;
shouldAnimate: boolean;
}
const LeadThoughtItem = ({
thought,
showDivider,
shouldAnimate,
}: LeadThoughtItemProps): JSX.Element => {
const wrapperRef = useRef<HTMLDivElement>(null);
const contentRef = useRef<HTMLDivElement>(null);
const previousHeightRef = useRef<number | null>(null);
const animationFrameRef = useRef<number | null>(null);
const cleanupTimerRef = useRef<number | null>(null);
const clearPendingAnimation = useCallback(() => {
if (animationFrameRef.current !== null) {
cancelAnimationFrame(animationFrameRef.current);
animationFrameRef.current = null;
}
if (cleanupTimerRef.current !== null) {
window.clearTimeout(cleanupTimerRef.current);
cleanupTimerRef.current = null;
}
}, []);
const resetWrapperStyles = useCallback(() => {
const wrapper = wrapperRef.current;
if (!wrapper) return;
wrapper.style.height = 'auto';
wrapper.style.opacity = '1';
wrapper.style.overflow = 'visible';
wrapper.style.transition = '';
}, []);
useLayoutEffect(() => {
const wrapper = wrapperRef.current;
const content = contentRef.current;
if (!wrapper || !content) return;
const animateHeight = (
targetHeight: number,
startHeight: number,
startOpacity: number
): void => {
clearPendingAnimation();
wrapper.style.transition = 'none';
wrapper.style.overflow = 'hidden';
wrapper.style.height = `${Math.max(startHeight, 0)}px`;
wrapper.style.opacity = `${startOpacity}`;
void wrapper.offsetHeight;
animationFrameRef.current = requestAnimationFrame(() => {
wrapper.style.transition = `height ${THOUGHT_HEIGHT_ANIMATION_MS}ms ease, opacity ${THOUGHT_HEIGHT_ANIMATION_MS}ms ease`;
wrapper.style.height = `${Math.max(targetHeight, 0)}px`;
wrapper.style.opacity = '1';
});
cleanupTimerRef.current = window.setTimeout(() => {
resetWrapperStyles();
cleanupTimerRef.current = null;
}, THOUGHT_HEIGHT_ANIMATION_MS + 40);
};
const syncHeight = (nextHeight: number, animateFromZero: boolean): void => {
const previousHeight = previousHeightRef.current;
previousHeightRef.current = nextHeight;
if (!shouldAnimate) {
resetWrapperStyles();
return;
}
if (previousHeight === null) {
if (nextHeight > 0 && animateFromZero) {
animateHeight(nextHeight, 0, 0);
} else {
resetWrapperStyles();
}
return;
}
if (Math.abs(nextHeight - previousHeight) < 1) return;
const renderedHeight = wrapper.getBoundingClientRect().height;
animateHeight(nextHeight, renderedHeight > 0 ? renderedHeight : previousHeight, 1);
};
syncHeight(content.getBoundingClientRect().height, true);
const observer = new ResizeObserver((entries) => {
const nextHeight = entries[0]?.contentRect.height ?? content.getBoundingClientRect().height;
syncHeight(nextHeight, false);
});
observer.observe(content);
return () => {
observer.disconnect();
clearPendingAnimation();
resetWrapperStyles();
};
}, [clearPendingAnimation, resetWrapperStyles, shouldAnimate]);
useEffect(
() => () => {
clearPendingAnimation();
},
[clearPendingAnimation]
);
return (
<div ref={wrapperRef}>
<div ref={contentRef}>
{showDivider && (
<div className="mx-auto flex w-2/5 items-center justify-center gap-[5px] py-px">
<hr
className="flex-1 border-0"
style={{
height: '1px',
backgroundColor: 'var(--color-border-emphasis)',
}}
/>
<span className="shrink-0 font-mono text-[9px]" style={{ color: CARD_ICON_MUTED }}>
{formatTimeWithSec(thought.timestamp)}
</span>
<hr
className="flex-1 border-0"
style={{
height: '1px',
backgroundColor: 'var(--color-border-emphasis)',
}}
/>
</div>
)}
<div className="flex text-[11px]">
<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>
{thought.toolSummary && (
<Tooltip>
<TooltipTrigger asChild>
<div
className="mb-[7px] cursor-default pb-0.5 pl-3 pr-1 font-mono text-[9px]"
style={{ color: CARD_ICON_MUTED }}
>
🔧 {thought.toolSummary}
</div>
</TooltipTrigger>
<TooltipContent
side="top"
align="start"
className="max-w-[420px] font-mono text-[11px]"
>
<ToolSummaryTooltipContent
toolCalls={thought.toolCalls}
toolSummary={thought.toolSummary}
/>
</TooltipContent>
</Tooltip>
)}
</div>
</div>
);
};
export const LeadThoughtsGroupRow = ({
group,
memberColor,
isNew,
onVisible,
canBeLive,
zebraShade,
collapseState,
}: LeadThoughtsGroupRowProps): React.JSX.Element => {
const ref = useRef<HTMLDivElement>(null);
const scrollRef = useRef<HTMLDivElement>(null);
const contentRef = useRef<HTMLDivElement>(null);
const isUserScrolledUpRef = useRef(false);
const distanceFromBottomRef = useRef(0);
const scrollSyncFrameRef = useRef<number | null>(null);
const isTeamAlive = useStore((s) => s.selectedTeamData?.isAlive ?? false);
const leadActivity = useStore((s) => {
const teamName = s.selectedTeamName;
return teamName ? s.leadActivityByTeam[teamName] : undefined;
});
const leadContextUpdatedAt = useStore((s) => {
const teamName = s.selectedTeamName;
return teamName ? s.leadContextByTeam[teamName]?.updatedAt : undefined;
});
const colors = getTeamColorSet(memberColor ?? '');
const { thoughts } = group;
// thoughts is newest-first; first=newest, last=oldest
const newest = thoughts[0];
const oldest = thoughts[thoughts.length - 1];
const leadName = newest.from;
// Chronological order for rendering (oldest at top, newest at bottom)
const chronologicalThoughts = useMemo(() => [...thoughts].reverse(), [thoughts]);
// Aggregate tool usage across all thoughts in this group
const totalToolSummary = useMemo(() => {
const merged: Record<string, number> = {};
let total = 0;
for (const t of thoughts) {
const parsed = parseToolSummary(t.toolSummary);
if (!parsed) continue;
total += parsed.total;
for (const [name, count] of Object.entries(parsed.byName)) {
merged[name] = (merged[name] ?? 0) + count;
}
}
if (total === 0) return null;
return formatToolSummary({ total, byName: merged });
}, [thoughts]);
// Aggregate all toolCalls across thoughts for header tooltip
const allToolCalls = useMemo(() => {
const calls: ToolCallMeta[] = [];
for (const t of thoughts) {
if (t.toolCalls) calls.push(...t.toolCalls);
}
return calls.length > 0 ? calls : undefined;
}, [thoughts]);
// 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)),
[canBeLive, isTeamAlive, leadActivity, leadContextUpdatedAt, newest.timestamp]
);
const [isLive, setIsLive] = useState(computeIsLive);
const [expanded, setExpanded] = useState(false);
const [needsTruncation, setNeedsTruncation] = useState(false);
const isManaged = isManagedCollapseState(collapseState);
const isBodyVisible = isManaged ? !collapseState.isCollapsed : true;
const canToggleBodyVisibility = isManaged && collapseState.canToggle;
const handleBodyToggle = canToggleBodyVisibility
? (): void => {
collapseState.onToggle?.();
}
: undefined;
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect -- intentional immediate sync to avoid 1s stale gap
setIsLive(computeIsLive());
const id = window.setInterval(() => setIsLive(computeIsLive()), 1000);
return () => window.clearInterval(id);
}, [computeIsLive]);
// Track how many thoughts have been reported as visible so far.
const reportedCountRef = useRef(0);
useEffect(() => {
if (!onVisible) return;
const el = ref.current;
if (!el) return;
const observer = new IntersectionObserver(
([entry]) => {
if (!entry?.isIntersecting) return;
const alreadyReported = reportedCountRef.current;
if (alreadyReported >= thoughts.length) return;
for (let i = alreadyReported; i < thoughts.length; i++) {
onVisible(thoughts[i]);
}
reportedCountRef.current = thoughts.length;
},
{ threshold: VIEWPORT_THRESHOLD, rootMargin: '0px' }
);
observer.observe(el);
return () => observer.disconnect();
}, [onVisible, thoughts]);
const clearPendingScrollSync = useCallback(() => {
if (scrollSyncFrameRef.current !== null) {
cancelAnimationFrame(scrollSyncFrameRef.current);
scrollSyncFrameRef.current = null;
}
}, []);
const queueScrollSync = useCallback(
(mode: 'bottom' | 'preserve') => {
clearPendingScrollSync();
scrollSyncFrameRef.current = requestAnimationFrame(() => {
scrollSyncFrameRef.current = requestAnimationFrame(() => {
const scrollEl = scrollRef.current;
if (!scrollEl || expanded || !isBodyVisible) {
scrollSyncFrameRef.current = null;
return;
}
const nextScrollTop =
mode === 'bottom'
? scrollEl.scrollHeight - scrollEl.clientHeight
: scrollEl.scrollHeight - scrollEl.clientHeight - distanceFromBottomRef.current;
scrollEl.scrollTop = Math.max(0, nextScrollTop);
if (mode === 'bottom') {
distanceFromBottomRef.current = 0;
isUserScrolledUpRef.current = false;
}
scrollSyncFrameRef.current = null;
});
});
},
[clearPendingScrollSync, expanded, isBodyVisible]
);
const syncScrollableBody = useCallback(
(forceScrollToBottom = false) => {
const scrollEl = scrollRef.current;
const contentEl = contentRef.current;
if (!scrollEl || !contentEl) return;
const nextNeedsTruncation = contentEl.scrollHeight > COLLAPSED_THOUGHTS_HEIGHT + 1;
setNeedsTruncation((prev) => (prev === nextNeedsTruncation ? prev : nextNeedsTruncation));
if (expanded || !isBodyVisible) return;
if (!nextNeedsTruncation) {
clearPendingScrollSync();
distanceFromBottomRef.current = 0;
isUserScrolledUpRef.current = false;
return;
}
if (forceScrollToBottom || !isUserScrolledUpRef.current) {
queueScrollSync('bottom');
return;
}
queueScrollSync('preserve');
},
[clearPendingScrollSync, expanded, isBodyVisible, queueScrollSync]
);
useEffect(() => {
if (!isBodyVisible) return;
const contentEl = contentRef.current;
if (!contentEl) return;
syncScrollableBody(true);
const observer = new ResizeObserver(() => {
syncScrollableBody();
});
observer.observe(contentEl);
return () => observer.disconnect();
}, [isBodyVisible, syncScrollableBody]);
useEffect(
() => () => {
clearPendingScrollSync();
},
[clearPendingScrollSync]
);
useEffect(() => {
if (isBodyVisible) return;
clearPendingScrollSync();
}, [clearPendingScrollSync, isBodyVisible]);
const handleScroll = useCallback(() => {
if (expanded) return;
const el = scrollRef.current;
if (!el) return;
const distanceFromBottom = Math.max(0, el.scrollHeight - el.scrollTop - el.clientHeight);
distanceFromBottomRef.current = distanceFromBottom;
isUserScrolledUpRef.current = distanceFromBottom > AUTO_SCROLL_THRESHOLD;
}, [expanded]);
const handleCollapse = useCallback(() => {
isUserScrolledUpRef.current = false;
distanceFromBottomRef.current = 0;
setExpanded(false);
requestAnimationFrame(() => {
requestAnimationFrame(() => {
const scrollEl = scrollRef.current;
if (scrollEl) {
scrollEl.scrollTop = scrollEl.scrollHeight;
}
ref.current?.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
});
});
}, []);
return (
<div
ref={ref}
className={isNew ? 'message-enter-animate min-h-px' : 'min-h-px'}
style={{ overflowAnchor: 'none' }}
>
<article
className="group rounded-md [overflow:clip]"
style={{
backgroundColor: zebraShade ? CARD_BG_ZEBRA : CARD_BG,
border: CARD_BORDER_STYLE,
borderLeft: `3px solid ${colors.border}`,
}}
>
{/* Header */}
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions -- role=button + tabIndex + onKeyDown below; nested tooltips prevent native button */}
<div
role={canToggleBodyVisibility ? 'button' : undefined}
tabIndex={canToggleBodyVisibility ? 0 : undefined}
className={[
'flex select-none items-center gap-2 px-3 py-1.5',
canToggleBodyVisibility ? 'cursor-pointer' : '',
].join(' ')}
onClick={handleBodyToggle}
onKeyDown={
canToggleBodyVisibility
? (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleBodyToggle?.();
}
}
: undefined
}
>
{/* Chevron for collapse mode */}
{canToggleBodyVisibility ? (
<ChevronRight
className="size-3 shrink-0 transition-transform duration-150"
style={{
color: CARD_ICON_MUTED,
transform: isBodyVisible ? 'rotate(90deg)' : undefined,
}}
/>
) : null}
{/* Live / offline indicator */}
{isLive ? (
<span className="pointer-events-none relative inline-flex size-2 shrink-0">
<span className="absolute inline-flex size-full animate-ping rounded-full bg-emerald-400 opacity-50" />
<span className="relative inline-flex size-2 rounded-full bg-emerald-400" />
</span>
) : (
<span className="inline-flex size-2 shrink-0 rounded-full bg-zinc-500" />
)}
<MemberBadge name={leadName} color={memberColor} hideAvatar />
<span className="text-[10px]" style={{ color: CARD_ICON_MUTED }}>
{thoughts.length} thoughts
</span>
<span className="text-[10px]" style={{ color: CARD_ICON_MUTED }}>
{formatTime(oldest.timestamp) === formatTime(newest.timestamp)
? formatTime(oldest.timestamp)
: `${formatTime(oldest.timestamp)}${formatTime(newest.timestamp)}`}
</span>
{totalToolSummary && (
<Tooltip>
<TooltipTrigger asChild>
<span className="cursor-default text-[10px]" style={{ color: CARD_ICON_MUTED }}>
{totalToolSummary}
</span>
</TooltipTrigger>
<TooltipContent side="bottom" className="max-w-[420px] font-mono text-[11px]">
<ToolSummaryTooltipContent
toolCalls={allToolCalls}
toolSummary={totalToolSummary}
/>
</TooltipContent>
</Tooltip>
)}
</div>
{/* Scrollable body — live thoughts follow bottom unless user scrolls up */}
{isBodyVisible ? (
<div
ref={scrollRef}
className="border-t"
style={{
borderColor: 'var(--color-border-subtle)',
maxHeight: expanded || !needsTruncation ? 'none' : `${COLLAPSED_THOUGHTS_HEIGHT}px`,
overflowY: expanded ? 'visible' : needsTruncation ? 'auto' : 'hidden',
scrollbarWidth: expanded || !needsTruncation ? undefined : 'thin',
scrollbarColor:
expanded || !needsTruncation ? undefined : 'var(--scrollbar-thumb) transparent',
overflowAnchor: 'none',
}}
onScroll={handleScroll}
>
<div ref={contentRef}>
{chronologicalThoughts.map((thought, idx) => (
<LeadThoughtItem
key={thought.messageId ?? idx}
thought={thought}
showDivider={idx > 0}
shouldAnimate={isLive && idx === chronologicalThoughts.length - 1}
/>
))}
</div>
</div>
) : null}
</article>
{isBodyVisible && !expanded && needsTruncation ? (
<div
className="pointer-events-none flex justify-center pt-1"
style={{ transform: 'translateY(-20px)' }}
>
<button
type="button"
className="pointer-events-auto flex items-center gap-1 rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] px-2.5 py-1 text-[11px] text-[var(--color-text-secondary)] shadow-sm transition-colors hover:bg-[var(--color-surface-raised)] hover:text-[var(--color-text)]"
onClick={(e) => {
e.stopPropagation();
setExpanded(true);
}}
>
<ChevronDown size={12} />
Show more
</button>
</div>
) : null}
{isBodyVisible && expanded && needsTruncation ? (
<div
className="pointer-events-none sticky bottom-0 z-10 flex justify-center pb-1 pt-2"
style={{ transform: 'translateY(-20px)' }}
>
<button
type="button"
className="pointer-events-auto flex items-center gap-1 rounded-md border border-[var(--color-border)] bg-[var(--color-surface-raised)] px-2.5 py-1 text-[11px] text-[var(--color-text-muted)] shadow-sm transition-colors hover:bg-[var(--color-surface)] hover:text-[var(--color-text-secondary)]"
onClick={(e) => {
e.stopPropagation();
handleCollapse();
}}
>
<ChevronUp size={12} />
Show less
</button>
</div>
) : null}
</div>
);
};