fix(team): polish task log rendering
This commit is contained in:
parent
f7e14e2b9a
commit
8f1ee5603c
2 changed files with 163 additions and 21 deletions
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue