feat: enhance message handling and UI components for improved user experience

- Implemented lead session ID propagation in TeamDataService to enrich inbox messages without existing leadSessionId.
- Added source and leadSessionId fields to message payloads in TeamInboxReader for better tracking.
- Updated CSS for compact CLI logs display, improving item density in the UI.
- Refactored CliLogsRichView and LeadThoughtsGroupRow to support new UI features and enhance message visibility.
- Adjusted ActivityTimeline to include zebra striping for better readability of messages.
This commit is contained in:
iliya 2026-03-05 23:39:50 +02:00
parent a846c3949a
commit 7afe5d4d59
10 changed files with 103 additions and 27 deletions

View file

@ -297,6 +297,31 @@ export class TeamDataService {
});
}
// Enrich inbox messages without leadSessionId by propagating from neighboring
// messages that have it (lead_session, user_sent). Sort chronologically (asc),
// sweep forward, then sweep backward so orphans at the start also get a session.
if (config.leadSessionId || messages.some((m) => m.leadSessionId)) {
messages.sort((a, b) => Date.parse(a.timestamp) - Date.parse(b.timestamp));
// Forward pass: propagate leadSessionId from earlier messages to later ones
let currentSessionId: string | undefined;
for (const msg of messages) {
if (msg.leadSessionId) {
currentSessionId = msg.leadSessionId;
} else if (currentSessionId) {
msg.leadSessionId = currentSessionId;
}
}
// Backward pass: fill messages before the first known session
currentSessionId = undefined;
for (let i = messages.length - 1; i >= 0; i--) {
if (messages[i].leadSessionId) {
currentSessionId = messages[i].leadSessionId;
} else if (currentSessionId) {
messages[i].leadSessionId = currentSessionId;
}
}
}
messages.sort((a, b) => Date.parse(b.timestamp) - Date.parse(a.timestamp));
let metaMembers: TeamConfig['members'] = [];

View file

@ -99,6 +99,8 @@ export class TeamInboxReader {
summary: typeof row.summary === 'string' ? row.summary : undefined,
color: typeof row.color === 'string' ? row.color : undefined,
messageId: typeof row.messageId === 'string' ? row.messageId : undefined,
source: typeof row.source === 'string' ? (row.source as InboxMessage['source']) : undefined,
leadSessionId: typeof row.leadSessionId === 'string' ? row.leadSessionId : undefined,
});
}

View file

@ -2876,7 +2876,7 @@ export class TeamProvisioningService {
const hasSendMessageToUser = (content ?? []).some((part) => {
if (!part || typeof part !== 'object') return false;
if (part.type !== 'tool_use' || part.name !== 'SendMessage') return false;
const input = (part as Record<string, unknown>).input;
const input = part.input;
if (!input || typeof input !== 'object') return false;
return (input as Record<string, unknown>).recipient === 'user';
});
@ -2885,7 +2885,7 @@ export class TeamProvisioningService {
.filter((part) => part.type === 'text' && typeof part.text === 'string')
.map((part) => part.text as string);
if (textParts.length > 0) {
const text = textParts.join('');
const text = textParts.join('\n');
// Auth failures sometimes show up as assistant text (e.g. "401", "Please run /login")
// rather than stderr or a result.subtype=error. Detect early to avoid false "ready".
this.handleAuthFailureInOutput(run, text, 'assistant');
@ -2908,7 +2908,7 @@ export class TeamProvisioningService {
clearTimeout(capture.idleHandle);
}
capture.idleHandle = setTimeout(() => {
const combined = capture.textParts.join('').trim();
const combined = capture.textParts.join('\n').trim();
capture.resolveOnce(combined);
}, capture.idleMs);
}
@ -2918,7 +2918,7 @@ export class TeamProvisioningService {
// SendMessage and avoid duplicating it as a separate lead text entry.
if (!run.silentUserDmForward && !hasSendMessageToUser) {
run.directReplyParts.push(text);
const raw = run.directReplyParts.join('');
const raw = run.directReplyParts.join('\n');
const cleanText = stripAgentBlocks(raw).trim();
if (cleanText.length >= TeamProvisioningService.LEAD_TEXT_MIN_LENGTH) {
const leadName =
@ -2927,11 +2927,13 @@ export class TeamProvisioningService {
if (!run.leadTurnMessageTimestamp) {
run.leadTurnMessageTimestamp = nowIso();
}
// Update timestamp on each text block so the live indicator stays fresh
const currentTimestamp = nowIso();
const messageId = `lead-turn-${run.runId}-${run.leadTurnSeq}`;
const leadMsg: InboxMessage = {
from: leadName,
text: cleanText,
timestamp: run.leadTurnMessageTimestamp,
timestamp: currentTimestamp,
read: true,
summary: cleanText.length > 60 ? cleanText.slice(0, 57) + '...' : cleanText,
messageId,
@ -3098,11 +3100,11 @@ export class TeamProvisioningService {
}
if (run.leadRelayCapture) {
const capture = run.leadRelayCapture;
const combined = capture.textParts.join('').trim();
const combined = capture.textParts.join('\n').trim();
capture.resolveOnce(combined);
} else if (run.provisioningComplete && run.directReplyParts.length > 0) {
// Finalize the current live lead turn message (single messageId per turn).
const rawReply = run.directReplyParts.join('').trim();
const rawReply = run.directReplyParts.join('\n').trim();
run.directReplyParts = [];
const leadName =
run.request.members.find((m) => m.role?.toLowerCase().includes('lead'))?.name ||

View file

@ -108,7 +108,7 @@ const StreamGroup = ({
<div className="rounded border border-[var(--color-border)] bg-[var(--color-surface)]">
<button
type="button"
className="flex w-full items-center gap-2 px-2.5 py-1.5 text-left transition-colors hover:bg-[var(--color-surface-raised)]"
className="flex w-full items-center gap-1.5 px-2 py-1 text-left transition-colors hover:bg-[var(--color-surface-raised)]"
onClick={onToggle}
>
<ChevronRight
@ -121,14 +121,19 @@ const StreamGroup = ({
<Bot size={13} className="shrink-0 text-[var(--color-text-muted)]" />
<span className="min-w-0 truncate text-[11px] text-[var(--color-text-secondary)]">
{searchQueryOverride && searchQueryOverride.trim().length > 0
? highlightQueryInText(group.summary, searchQueryOverride, `${group.id}:group-summary`, {
forceAllActive: true,
})
? highlightQueryInText(
group.summary,
searchQueryOverride,
`${group.id}:group-summary`,
{
forceAllActive: true,
}
)
: group.summary}
</span>
</button>
{isExpanded && (
<div className="border-t border-[var(--color-border)] p-2">
<div className="border-t border-[var(--color-border)] p-1.5">
<DisplayItemList
items={group.items}
onItemClick={handleItemClick}
@ -218,7 +223,11 @@ export const CliLogsRichView = ({
)}
onScroll={(e) => {
const el = e.currentTarget;
onScroll?.({ scrollTop: el.scrollTop, scrollHeight: el.scrollHeight, clientHeight: el.clientHeight });
onScroll?.({
scrollTop: el.scrollTop,
scrollHeight: el.scrollHeight,
clientHeight: el.clientHeight,
});
}}
>
{hasContent ? (
@ -242,10 +251,14 @@ export const CliLogsRichView = ({
scrollRef.current = el;
containerRefCallback?.(el);
}}
className={cn('max-h-[400px] space-y-1.5 overflow-y-auto', className)}
className={cn('cli-logs-compact max-h-[400px] space-y-1 overflow-y-auto', className)}
onScroll={(e) => {
const el = e.currentTarget;
onScroll?.({ scrollTop: el.scrollTop, scrollHeight: el.scrollHeight, clientHeight: el.clientHeight });
onScroll?.({
scrollTop: el.scrollTop,
scrollHeight: el.scrollHeight,
clientHeight: el.clientHeight,
});
}}
>
{visibleGroups.map((group) =>

View file

@ -5,8 +5,8 @@ import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
import { ActivityItem, isNoiseMessage } from './ActivityItem';
import { groupTimelineItems, isLeadThought, LeadThoughtsGroupRow } from './LeadThoughtsGroup';
import type { InboxMessage, ResolvedTeamMember } from '@shared/types';
import type { TimelineItem } from './LeadThoughtsGroup';
import type { InboxMessage, ResolvedTeamMember } from '@shared/types';
interface ActivityTimelineProps {
messages: InboxMessage[];
@ -324,6 +324,7 @@ export const ActivityTimeline = ({
memberColor={info?.color}
isNew={newItemKeys.has(itemKey)}
onVisible={onMessageVisible}
zebraShade={zebraShadeSet.has(index)}
/>
</React.Fragment>
);

View file

@ -3,11 +3,13 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { MemberBadge } from '@renderer/components/team/MemberBadge';
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 type { InboxMessage } from '@shared/types';
@ -65,7 +67,7 @@ export function groupTimelineItems(messages: InboxMessage[]): TimelineItem[] {
}
const VIEWPORT_THRESHOLD = 0.15;
const LIVE_WINDOW_MS = 10_000;
const LIVE_WINDOW_MS = 5_000;
const AUTO_SCROLL_THRESHOLD = 30;
interface LeadThoughtsGroupRowProps {
@ -73,6 +75,8 @@ interface LeadThoughtsGroupRowProps {
memberColor?: string;
isNew?: boolean;
onVisible?: (message: InboxMessage) => void;
/** When true, apply a subtle lighter background for zebra-striped lists. */
zebraShade?: boolean;
}
function formatTime(timestamp: string): string {
@ -98,10 +102,20 @@ export const LeadThoughtsGroupRow = ({
memberColor,
isNew,
onVisible,
zebraShade,
}: LeadThoughtsGroupRowProps): React.JSX.Element => {
const ref = useRef<HTMLDivElement>(null);
const scrollRef = useRef<HTMLDivElement>(null);
const isUserScrolledUpRef = useRef(false);
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;
@ -113,8 +127,15 @@ export const LeadThoughtsGroupRow = ({
// Chronological order for rendering (oldest at top, newest at bottom)
const chronologicalThoughts = useMemo(() => [...thoughts].reverse(), [thoughts]);
// Live indicator: newest thought is recent (actively streaming)
const computeIsLive = useCallback(() => isRecentTimestamp(newest.timestamp), [newest.timestamp]);
// Live = process alive AND (lead is in active turn OR context recently updated OR fresh thought)
const computeIsLive = useCallback(
() =>
isTeamAlive &&
(leadActivity === 'active' ||
(leadContextUpdatedAt ? isRecentTimestamp(leadContextUpdatedAt) : false) ||
isRecentTimestamp(newest.timestamp)),
[isTeamAlive, leadActivity, leadContextUpdatedAt, newest.timestamp]
);
const [isLive, setIsLive] = useState(computeIsLive);
useEffect(() => {
@ -170,7 +191,7 @@ export const LeadThoughtsGroupRow = ({
<article
className="group rounded-md [overflow:clip]"
style={{
backgroundColor: CARD_BG,
backgroundColor: zebraShade ? CARD_BG_ZEBRA : CARD_BG,
border: CARD_BORDER_STYLE,
borderLeft: `3px solid ${colors.border}`,
opacity: isLive ? undefined : 0.75,

View file

@ -25,7 +25,6 @@ import { chipToken, serializeChipsWithText } from '@renderer/types/inlineChip';
import { buildReplyBlock } from '@renderer/utils/agentMessageFormatting';
import { removeChipTokenFromText } from '@renderer/utils/chipUtils';
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
import { getModifierKeyName } from '@renderer/utils/keyboardUtils';
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
import { AlertCircle, ImagePlus, Send, X } from 'lucide-react';

View file

@ -13,7 +13,6 @@ import { useStore } from '@renderer/store';
import { buildReplyBlock, parseMessageReply } from '@renderer/utils/agentMessageFormatting';
import { isImageMimeType } from '@renderer/utils/attachmentUtils';
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
import { getModifierKeyName } from '@renderer/utils/keyboardUtils';
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
import { stripAgentBlocks } from '@shared/constants/agentBlocks';
import { formatDistanceToNow } from 'date-fns';

View file

@ -3,11 +3,13 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { api } from '@renderer/api';
import { MemberExecutionLog } from '@renderer/components/team/members/MemberExecutionLog';
import {
SubagentRecentMessagesPreview,
type SubagentPreviewMessage,
SubagentRecentMessagesPreview,
} from '@renderer/components/team/members/SubagentRecentMessagesPreview';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { enhanceAIGroup } from '@renderer/utils/aiGroupEnhancer';
import { formatDuration } from '@renderer/utils/formatters';
import { transformChunksToConversation } from '@renderer/utils/groupTransformer';
import {
AlertCircle,
ChevronDown,
@ -18,9 +20,6 @@ import {
MessageSquare,
} from 'lucide-react';
import { enhanceAIGroup } from '@renderer/utils/aiGroupEnhancer';
import { transformChunksToConversation } from '@renderer/utils/groupTransformer';
import type { EnhancedChunk } from '@renderer/types/data';
import type { MemberLogSummary } from '@shared/types';
@ -225,7 +224,7 @@ export const MemberLogsTab = ({
async (log: MemberLogSummary): Promise<EnhancedChunk[] | null> => {
if (log.kind === 'subagent') {
const d = await api.getSubagentDetail(log.projectId, log.sessionId, log.subagentId);
return (d?.chunks ?? null) as EnhancedChunk[] | null;
return d?.chunks ?? null;
}
const d = await api.getSessionDetail(log.projectId, log.sessionId);
return (d?.chunks ?? null) as unknown as EnhancedChunk[] | null;

View file

@ -734,6 +734,21 @@ body {
background-color: #12131a;
}
/* CLI Logs compact density — reduces item height inside CliLogsRichView */
.cli-logs-compact [role='button'] {
padding-top: 0.2rem;
padding-bottom: 0.2rem;
gap: 0.375rem;
}
.cli-logs-compact [role='button'] .text-sm {
font-size: 0.75rem;
line-height: 1rem;
}
.cli-logs-compact [role='button'] .text-xs {
font-size: 0.625rem;
line-height: 0.875rem;
}
:root.light .checkerboard-bg {
background-image:
linear-gradient(45deg, #e2e8f0 25%, transparent 25%),