fix(team): polish task log rendering

This commit is contained in:
777genius 2026-05-01 20:27:08 +03:00
parent f7e14e2b9a
commit 8f1ee5603c
2 changed files with 163 additions and 21 deletions

View file

@ -4,7 +4,10 @@ import { DisplayItemList } from '@renderer/components/chat/DisplayItemList';
import { LastOutputDisplay } from '@renderer/components/chat/LastOutputDisplay'; import { LastOutputDisplay } from '@renderer/components/chat/LastOutputDisplay';
import { SystemChatGroup } from '@renderer/components/chat/SystemChatGroup'; import { SystemChatGroup } from '@renderer/components/chat/SystemChatGroup';
import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer'; 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 { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { getTeamColorSet, getThemedBorder } from '@renderer/constants/teamColors';
import { useTheme } from '@renderer/hooks/useTheme';
import { enhanceAIGroup } from '@renderer/utils/aiGroupEnhancer'; import { enhanceAIGroup } from '@renderer/utils/aiGroupEnhancer';
import { transformChunksToConversation } from '@renderer/utils/groupTransformer'; import { transformChunksToConversation } from '@renderer/utils/groupTransformer';
import { extractAgentBlockContents, stripAgentBlocks } from '@shared/constants/agentBlocks'; import { extractAgentBlockContents, stripAgentBlocks } from '@shared/constants/agentBlocks';
@ -17,6 +20,8 @@ import type { AIGroup, UserGroup } from '@renderer/types/groups';
interface MemberExecutionLogProps { interface MemberExecutionLogProps {
chunks: EnhancedChunk[]; chunks: EnhancedChunk[];
memberName?: string; memberName?: string;
memberColor?: string;
teamName?: string;
} }
type ExpandedItemIdsByGroup = Map<string, Set<string>>; type ExpandedItemIdsByGroup = Map<string, Set<string>>;
@ -24,6 +29,8 @@ type ExpandedItemIdsByGroup = Map<string, Set<string>>;
export const MemberExecutionLog = ({ export const MemberExecutionLog = ({
chunks, chunks,
memberName, memberName,
memberColor,
teamName,
}: MemberExecutionLogProps): React.JSX.Element => { }: MemberExecutionLogProps): React.JSX.Element => {
const conversation = useMemo(() => transformChunksToConversation(chunks, [], false), [chunks]); const conversation = useMemo(() => transformChunksToConversation(chunks, [], false), [chunks]);
@ -60,6 +67,8 @@ export const MemberExecutionLog = ({
key={item.group.id} key={item.group.id}
group={item.group} group={item.group}
memberName={memberName} memberName={memberName}
memberColor={memberColor}
teamName={teamName}
expanded={!collapsedGroupIds.has(item.group.id)} expanded={!collapsedGroupIds.has(item.group.id)}
expandedItemIds={expandedItemIdsByGroup.get(item.group.id) ?? new Set()} expandedItemIds={expandedItemIdsByGroup.get(item.group.id) ?? new Set()}
onToggleExpanded={() => { onToggleExpanded={() => {
@ -151,6 +160,8 @@ const UserLogItem = ({ group }: { group: UserGroup }): React.JSX.Element => {
interface AIExecutionGroupProps { interface AIExecutionGroupProps {
group: AIGroup; group: AIGroup;
memberName?: string; memberName?: string;
memberColor?: string;
teamName?: string;
expanded: boolean; expanded: boolean;
expandedItemIds: Set<string>; expandedItemIds: Set<string>;
onToggleExpanded: () => void; onToggleExpanded: () => void;
@ -160,11 +171,14 @@ interface AIExecutionGroupProps {
const AIExecutionGroup = ({ const AIExecutionGroup = ({
group, group,
memberName, memberName,
memberColor,
teamName,
expanded, expanded,
expandedItemIds, expandedItemIds,
onToggleExpanded, onToggleExpanded,
onToggleItem, onToggleItem,
}: AIExecutionGroupProps): React.JSX.Element => { }: AIExecutionGroupProps): React.JSX.Element => {
const { isLight } = useTheme();
const enhanced = useMemo(() => { const enhanced = useMemo(() => {
if (!memberName) { if (!memberName) {
return enhanceAIGroup(group); return enhanceAIGroup(group);
@ -175,13 +189,18 @@ const AIExecutionGroup = ({
); );
return enhanceAIGroup({ ...group, processes: filteredProcesses }); return enhanceAIGroup({ ...group, processes: filteredProcesses });
}, [group, memberName]); }, [group, memberName]);
const groupLabel = memberName?.trim() ? `${memberName.trim()} turn` : 'Agent turn'; const normalizedMemberName = memberName?.trim();
const groupLabel = normalizedMemberName ? `${normalizedMemberName} turn` : 'Agent turn';
const hasToggleContent = enhanced.displayItems.length > 0; const hasToggleContent = enhanced.displayItems.length > 0;
const visibleLastOutput = const visibleLastOutput =
enhanced.lastOutput?.type === 'tool_result' && hasToggleContent ? null : enhanced.lastOutput; enhanced.lastOutput?.type === 'tool_result' && hasToggleContent ? null : enhanced.lastOutput;
const groupColors = getTeamColorSet(memberColor ?? '');
const borderColor = normalizedMemberName
? getThemedBorder(groupColors, isLight)
: 'var(--chat-ai-border)';
return ( return (
<div className="space-y-3 border-l-2 pl-3" style={{ borderColor: 'var(--chat-ai-border)' }}> <div className="space-y-3 border-l-2 pl-3" style={{ borderColor }}>
{hasToggleContent ? ( {hasToggleContent ? (
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
@ -191,10 +210,27 @@ const AIExecutionGroup = ({
onClick={onToggleExpanded} onClick={onToggleExpanded}
aria-expanded={expanded} aria-expanded={expanded}
> >
<Bot className="size-4 shrink-0 text-[var(--color-text-secondary)]" /> {normalizedMemberName ? (
<span className="shrink-0 text-xs font-semibold text-[var(--color-text-secondary)]"> <>
{groupLabel} <MemberBadge
</span> name={normalizedMemberName}
color={memberColor}
teamName={teamName}
size="sm"
disableHoverCard
/>
<span className="shrink-0 text-xs font-semibold text-[var(--color-text-secondary)]">
turn
</span>
</>
) : (
<>
<Bot className="size-4 shrink-0 text-[var(--color-text-secondary)]" />
<span className="shrink-0 text-xs font-semibold text-[var(--color-text-secondary)]">
{groupLabel}
</span>
</>
)}
<span className="min-w-0 flex-1 truncate text-xs text-[var(--color-text-muted)]"> <span className="min-w-0 flex-1 truncate text-xs text-[var(--color-text-muted)]">
{enhanced.itemsSummary} {enhanced.itemsSummary}
</span> </span>

View file

@ -1,12 +1,25 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { api } from '@renderer/api'; import { api } from '@renderer/api';
import { MemberBadge } from '@renderer/components/team/MemberBadge';
import { MemberExecutionLog } from '@renderer/components/team/members/MemberExecutionLog'; import { MemberExecutionLog } from '@renderer/components/team/members/MemberExecutionLog';
import {
getTeamColorSet,
getThemedBadge,
getThemedBorder,
getThemedText,
} from '@renderer/constants/teamColors';
import { useTheme } from '@renderer/hooks/useTheme';
import { useStore } from '@renderer/store';
import { selectResolvedMembersForTeamName } from '@renderer/store/slices/teamSlice';
import { asEnhancedChunkArray } from '@renderer/types/data'; import { asEnhancedChunkArray } from '@renderer/types/data';
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
import { isLeadMember } from '@shared/utils/leadDetection';
import { AlertCircle, Clock, FileText, Loader2 } from 'lucide-react'; import { AlertCircle, Clock, FileText, Loader2 } from 'lucide-react';
import type { import type {
BoardTaskLogActor, BoardTaskLogActor,
ResolvedTeamMember,
BoardTaskLogSegment, BoardTaskLogSegment,
BoardTaskLogStreamResponse, BoardTaskLogStreamResponse,
} from '@shared/types'; } from '@shared/types';
@ -87,12 +100,51 @@ function describeStreamSource(stream: BoardTaskLogStreamResponse | null): string
return 'Task-scoped transcript logs rendered with the same execution-log components used in Logs.'; return 'Task-scoped transcript logs rendered with the same execution-log components used in Logs.';
} }
interface ParticipantVisual {
name: string;
color?: string;
}
function buildParticipantVisualMap(
stream: BoardTaskLogStreamResponse | null,
members: readonly ResolvedTeamMember[],
memberColorMap: ReadonlyMap<string, string>
): Map<string, ParticipantVisual> {
const visuals = new Map<string, ParticipantVisual>();
const leadMember = members.find((member) => isLeadMember(member));
for (const participant of stream?.participants ?? []) {
const matchingSegment = stream?.segments.find(
(segment) => segment.participantKey === participant.key
);
const name =
matchingSegment?.actor.memberName ??
(participant.isLead ? leadMember?.name : undefined) ??
participant.label;
visuals.set(participant.key, {
name,
color: memberColorMap.get(name) ?? memberColorMap.get(participant.label),
});
}
for (const segment of stream?.segments ?? []) {
if (visuals.has(segment.participantKey)) {
continue;
}
const name = segment.actor.memberName ?? actorLabel(segment.actor);
visuals.set(segment.participantKey, {
name,
color: memberColorMap.get(name),
});
}
return visuals;
}
const SegmentMarker = ({ segment }: { segment: BoardTaskLogSegment }): React.JSX.Element => { const SegmentMarker = ({ segment }: { segment: BoardTaskLogSegment }): React.JSX.Element => {
return ( return (
<div className="mb-2 flex items-center gap-2 text-[10px] text-[var(--color-text-muted)]"> <div className="mb-2 flex items-center gap-1.5 text-[10px] text-[var(--color-text-muted)]">
<span className="rounded-full border border-[var(--color-border)] px-2 py-0.5 font-medium text-[var(--color-text-secondary)]">
{actorLabel(segment.actor)}
</span>
<span className="flex items-center gap-1"> <span className="flex items-center gap-1">
<Clock size={10} /> <Clock size={10} />
{formatRelativeTime(segment.endTimestamp)} {formatRelativeTime(segment.endTimestamp)}
@ -104,18 +156,68 @@ const SegmentMarker = ({ segment }: { segment: BoardTaskLogSegment }): React.JSX
const SegmentBlock = ({ const SegmentBlock = ({
segment, segment,
showHeader, showHeader,
teamName,
visual,
}: { }: {
segment: BoardTaskLogSegment; segment: BoardTaskLogSegment;
showHeader: boolean; showHeader: boolean;
teamName: string;
visual?: ParticipantVisual;
}): React.JSX.Element => { }): React.JSX.Element => {
return ( return (
<div className="min-w-0 overflow-hidden"> <div className="min-w-0 overflow-hidden">
{showHeader ? <SegmentMarker segment={segment} /> : null} {showHeader ? <SegmentMarker segment={segment} /> : null}
<MemberExecutionLog chunks={segment.chunks} memberName={segment.actor.memberName} /> <MemberExecutionLog
chunks={segment.chunks}
memberName={segment.actor.memberName}
memberColor={visual?.color}
teamName={teamName}
/>
</div> </div>
); );
}; };
const ParticipantFilterChip = ({
label,
selected,
visual,
teamName,
onClick,
}: {
label: string;
selected: boolean;
visual?: ParticipantVisual;
teamName: string;
onClick: () => void;
}): React.JSX.Element => {
const { isLight } = useTheme();
const colors = getTeamColorSet(visual?.color ?? '');
const borderColor = selected ? getThemedBorder(colors, isLight) : 'var(--color-border)';
const backgroundColor = selected ? getThemedBadge(colors, isLight) : 'transparent';
const textColor = selected ? getThemedText(colors, isLight) : 'var(--color-text-muted)';
return (
<button
type="button"
className="rounded-full border px-2 py-1 text-[11px] transition-colors hover:text-[var(--color-text)]"
style={{ borderColor, backgroundColor, color: textColor }}
onClick={onClick}
>
{visual ? (
<MemberBadge
name={visual.name}
color={visual.color}
teamName={teamName}
size="xs"
disableHoverCard
/>
) : (
label
)}
</button>
);
};
export const TaskLogStreamSection = ({ export const TaskLogStreamSection = ({
teamName, teamName,
taskId, taskId,
@ -126,6 +228,7 @@ export const TaskLogStreamSection = ({
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [selectedParticipantKey, setSelectedParticipantKey] = useState<'all' | string>('all'); const [selectedParticipantKey, setSelectedParticipantKey] = useState<'all' | string>('all');
const teamMembers = useStore((s) => selectResolvedMembersForTeamName(s, teamName));
const requestSeqRef = useRef(0); const requestSeqRef = useRef(0);
const streamRef = useRef<BoardTaskLogStreamResponse | null>(null); const streamRef = useRef<BoardTaskLogStreamResponse | null>(null);
const reloadTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); const reloadTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
@ -277,6 +380,11 @@ export const TaskLogStreamSection = ({
}, [liveEnabled, loadStream, taskId, teamName]); }, [liveEnabled, loadStream, taskId, teamName]);
const participants = stream?.participants ?? []; const participants = stream?.participants ?? [];
const memberColorMap = useMemo(() => buildMemberColorMap(teamMembers), [teamMembers]);
const participantVisuals = useMemo(
() => buildParticipantVisualMap(stream, teamMembers, memberColorMap),
[memberColorMap, stream, teamMembers]
);
const showChips = participants.length > 1; const showChips = participants.length > 1;
const streamDescription = useMemo(() => describeStreamSource(stream), [stream]); const streamDescription = useMemo(() => describeStreamSource(stream), [stream]);
const visibleSegments = useMemo(() => { const visibleSegments = useMemo(() => {
@ -340,18 +448,14 @@ export const TaskLogStreamSection = ({
All All
</button> </button>
{participants.map((participant) => ( {participants.map((participant) => (
<button <ParticipantFilterChip
key={participant.key} key={participant.key}
type="button" label={participant.label}
className={`rounded-full border px-2.5 py-1 text-[11px] transition-colors ${ selected={selectedParticipantKey === participant.key}
selectedParticipantKey === participant.key visual={participantVisuals.get(participant.key)}
? 'bg-[var(--color-accent)]/10 border-[var(--color-accent)] text-[var(--color-text)]' teamName={teamName}
: 'border-[var(--color-border)] text-[var(--color-text-muted)] hover:text-[var(--color-text)]'
}`}
onClick={() => setSelectedParticipantKey(participant.key)} onClick={() => setSelectedParticipantKey(participant.key)}
> />
{participant.label}
</button>
))} ))}
</div> </div>
) : null} ) : null}
@ -372,6 +476,8 @@ export const TaskLogStreamSection = ({
key={buildStableSegmentRenderKey(segment)} key={buildStableSegmentRenderKey(segment)}
segment={segment} segment={segment}
showHeader={showSegmentHeaders} showHeader={showSegmentHeaders}
teamName={teamName}
visual={participantVisuals.get(segment.participantKey)}
/> />
))} ))}
</div> </div>