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)); messages.sort((a, b) => Date.parse(b.timestamp) - Date.parse(a.timestamp));
let metaMembers: TeamConfig['members'] = []; let metaMembers: TeamConfig['members'] = [];

View file

@ -99,6 +99,8 @@ export class TeamInboxReader {
summary: typeof row.summary === 'string' ? row.summary : undefined, summary: typeof row.summary === 'string' ? row.summary : undefined,
color: typeof row.color === 'string' ? row.color : undefined, color: typeof row.color === 'string' ? row.color : undefined,
messageId: typeof row.messageId === 'string' ? row.messageId : 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) => { const hasSendMessageToUser = (content ?? []).some((part) => {
if (!part || typeof part !== 'object') return false; if (!part || typeof part !== 'object') return false;
if (part.type !== 'tool_use' || part.name !== 'SendMessage') 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; if (!input || typeof input !== 'object') return false;
return (input as Record<string, unknown>).recipient === 'user'; return (input as Record<string, unknown>).recipient === 'user';
}); });
@ -2885,7 +2885,7 @@ export class TeamProvisioningService {
.filter((part) => part.type === 'text' && typeof part.text === 'string') .filter((part) => part.type === 'text' && typeof part.text === 'string')
.map((part) => part.text as string); .map((part) => part.text as string);
if (textParts.length > 0) { 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") // 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". // rather than stderr or a result.subtype=error. Detect early to avoid false "ready".
this.handleAuthFailureInOutput(run, text, 'assistant'); this.handleAuthFailureInOutput(run, text, 'assistant');
@ -2908,7 +2908,7 @@ export class TeamProvisioningService {
clearTimeout(capture.idleHandle); clearTimeout(capture.idleHandle);
} }
capture.idleHandle = setTimeout(() => { capture.idleHandle = setTimeout(() => {
const combined = capture.textParts.join('').trim(); const combined = capture.textParts.join('\n').trim();
capture.resolveOnce(combined); capture.resolveOnce(combined);
}, capture.idleMs); }, capture.idleMs);
} }
@ -2918,7 +2918,7 @@ export class TeamProvisioningService {
// SendMessage and avoid duplicating it as a separate lead text entry. // SendMessage and avoid duplicating it as a separate lead text entry.
if (!run.silentUserDmForward && !hasSendMessageToUser) { if (!run.silentUserDmForward && !hasSendMessageToUser) {
run.directReplyParts.push(text); run.directReplyParts.push(text);
const raw = run.directReplyParts.join(''); const raw = run.directReplyParts.join('\n');
const cleanText = stripAgentBlocks(raw).trim(); const cleanText = stripAgentBlocks(raw).trim();
if (cleanText.length >= TeamProvisioningService.LEAD_TEXT_MIN_LENGTH) { if (cleanText.length >= TeamProvisioningService.LEAD_TEXT_MIN_LENGTH) {
const leadName = const leadName =
@ -2927,11 +2927,13 @@ export class TeamProvisioningService {
if (!run.leadTurnMessageTimestamp) { if (!run.leadTurnMessageTimestamp) {
run.leadTurnMessageTimestamp = nowIso(); 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 messageId = `lead-turn-${run.runId}-${run.leadTurnSeq}`;
const leadMsg: InboxMessage = { const leadMsg: InboxMessage = {
from: leadName, from: leadName,
text: cleanText, text: cleanText,
timestamp: run.leadTurnMessageTimestamp, timestamp: currentTimestamp,
read: true, read: true,
summary: cleanText.length > 60 ? cleanText.slice(0, 57) + '...' : cleanText, summary: cleanText.length > 60 ? cleanText.slice(0, 57) + '...' : cleanText,
messageId, messageId,
@ -3098,11 +3100,11 @@ export class TeamProvisioningService {
} }
if (run.leadRelayCapture) { if (run.leadRelayCapture) {
const capture = run.leadRelayCapture; const capture = run.leadRelayCapture;
const combined = capture.textParts.join('').trim(); const combined = capture.textParts.join('\n').trim();
capture.resolveOnce(combined); capture.resolveOnce(combined);
} else if (run.provisioningComplete && run.directReplyParts.length > 0) { } else if (run.provisioningComplete && run.directReplyParts.length > 0) {
// Finalize the current live lead turn message (single messageId per turn). // 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 = []; run.directReplyParts = [];
const leadName = const leadName =
run.request.members.find((m) => m.role?.toLowerCase().includes('lead'))?.name || 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)]"> <div className="rounded border border-[var(--color-border)] bg-[var(--color-surface)]">
<button <button
type="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} onClick={onToggle}
> >
<ChevronRight <ChevronRight
@ -121,14 +121,19 @@ const StreamGroup = ({
<Bot size={13} className="shrink-0 text-[var(--color-text-muted)]" /> <Bot size={13} className="shrink-0 text-[var(--color-text-muted)]" />
<span className="min-w-0 truncate text-[11px] text-[var(--color-text-secondary)]"> <span className="min-w-0 truncate text-[11px] text-[var(--color-text-secondary)]">
{searchQueryOverride && searchQueryOverride.trim().length > 0 {searchQueryOverride && searchQueryOverride.trim().length > 0
? highlightQueryInText(group.summary, searchQueryOverride, `${group.id}:group-summary`, { ? highlightQueryInText(
forceAllActive: true, group.summary,
}) searchQueryOverride,
`${group.id}:group-summary`,
{
forceAllActive: true,
}
)
: group.summary} : group.summary}
</span> </span>
</button> </button>
{isExpanded && ( {isExpanded && (
<div className="border-t border-[var(--color-border)] p-2"> <div className="border-t border-[var(--color-border)] p-1.5">
<DisplayItemList <DisplayItemList
items={group.items} items={group.items}
onItemClick={handleItemClick} onItemClick={handleItemClick}
@ -218,7 +223,11 @@ export const CliLogsRichView = ({
)} )}
onScroll={(e) => { onScroll={(e) => {
const el = e.currentTarget; 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 ? ( {hasContent ? (
@ -242,10 +251,14 @@ export const CliLogsRichView = ({
scrollRef.current = el; scrollRef.current = el;
containerRefCallback?.(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) => { onScroll={(e) => {
const el = e.currentTarget; 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) => {visibleGroups.map((group) =>

View file

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

View file

@ -3,11 +3,13 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { MemberBadge } from '@renderer/components/team/MemberBadge'; import { MemberBadge } from '@renderer/components/team/MemberBadge';
import { import {
CARD_BG, CARD_BG,
CARD_BG_ZEBRA,
CARD_BORDER_STYLE, CARD_BORDER_STYLE,
CARD_ICON_MUTED, CARD_ICON_MUTED,
CARD_TEXT_LIGHT, CARD_TEXT_LIGHT,
} from '@renderer/constants/cssVariables'; } from '@renderer/constants/cssVariables';
import { getTeamColorSet } from '@renderer/constants/teamColors'; import { getTeamColorSet } from '@renderer/constants/teamColors';
import { useStore } from '@renderer/store';
import type { InboxMessage } from '@shared/types'; import type { InboxMessage } from '@shared/types';
@ -65,7 +67,7 @@ export function groupTimelineItems(messages: InboxMessage[]): TimelineItem[] {
} }
const VIEWPORT_THRESHOLD = 0.15; const VIEWPORT_THRESHOLD = 0.15;
const LIVE_WINDOW_MS = 10_000; const LIVE_WINDOW_MS = 5_000;
const AUTO_SCROLL_THRESHOLD = 30; const AUTO_SCROLL_THRESHOLD = 30;
interface LeadThoughtsGroupRowProps { interface LeadThoughtsGroupRowProps {
@ -73,6 +75,8 @@ interface LeadThoughtsGroupRowProps {
memberColor?: string; memberColor?: string;
isNew?: boolean; isNew?: boolean;
onVisible?: (message: InboxMessage) => void; onVisible?: (message: InboxMessage) => void;
/** When true, apply a subtle lighter background for zebra-striped lists. */
zebraShade?: boolean;
} }
function formatTime(timestamp: string): string { function formatTime(timestamp: string): string {
@ -98,10 +102,20 @@ export const LeadThoughtsGroupRow = ({
memberColor, memberColor,
isNew, isNew,
onVisible, onVisible,
zebraShade,
}: LeadThoughtsGroupRowProps): React.JSX.Element => { }: LeadThoughtsGroupRowProps): React.JSX.Element => {
const ref = useRef<HTMLDivElement>(null); const ref = useRef<HTMLDivElement>(null);
const scrollRef = useRef<HTMLDivElement>(null); const scrollRef = useRef<HTMLDivElement>(null);
const isUserScrolledUpRef = useRef(false); 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 colors = getTeamColorSet(memberColor ?? '');
const { thoughts } = group; const { thoughts } = group;
@ -113,8 +127,15 @@ export const LeadThoughtsGroupRow = ({
// Chronological order for rendering (oldest at top, newest at bottom) // Chronological order for rendering (oldest at top, newest at bottom)
const chronologicalThoughts = useMemo(() => [...thoughts].reverse(), [thoughts]); const chronologicalThoughts = useMemo(() => [...thoughts].reverse(), [thoughts]);
// Live indicator: newest thought is recent (actively streaming) // Live = process alive AND (lead is in active turn OR context recently updated OR fresh thought)
const computeIsLive = useCallback(() => isRecentTimestamp(newest.timestamp), [newest.timestamp]); const computeIsLive = useCallback(
() =>
isTeamAlive &&
(leadActivity === 'active' ||
(leadContextUpdatedAt ? isRecentTimestamp(leadContextUpdatedAt) : false) ||
isRecentTimestamp(newest.timestamp)),
[isTeamAlive, leadActivity, leadContextUpdatedAt, newest.timestamp]
);
const [isLive, setIsLive] = useState(computeIsLive); const [isLive, setIsLive] = useState(computeIsLive);
useEffect(() => { useEffect(() => {
@ -170,7 +191,7 @@ export const LeadThoughtsGroupRow = ({
<article <article
className="group rounded-md [overflow:clip]" className="group rounded-md [overflow:clip]"
style={{ style={{
backgroundColor: 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, 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 { buildReplyBlock } from '@renderer/utils/agentMessageFormatting';
import { removeChipTokenFromText } from '@renderer/utils/chipUtils'; import { removeChipTokenFromText } from '@renderer/utils/chipUtils';
import { formatAgentRole } from '@renderer/utils/formatAgentRole'; import { formatAgentRole } from '@renderer/utils/formatAgentRole';
import { getModifierKeyName } from '@renderer/utils/keyboardUtils';
import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
import { AlertCircle, ImagePlus, Send, X } from 'lucide-react'; 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 { buildReplyBlock, parseMessageReply } from '@renderer/utils/agentMessageFormatting';
import { isImageMimeType } from '@renderer/utils/attachmentUtils'; import { isImageMimeType } from '@renderer/utils/attachmentUtils';
import { formatAgentRole } from '@renderer/utils/formatAgentRole'; import { formatAgentRole } from '@renderer/utils/formatAgentRole';
import { getModifierKeyName } from '@renderer/utils/keyboardUtils';
import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
import { stripAgentBlocks } from '@shared/constants/agentBlocks'; import { stripAgentBlocks } from '@shared/constants/agentBlocks';
import { formatDistanceToNow } from 'date-fns'; 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 { api } from '@renderer/api';
import { MemberExecutionLog } from '@renderer/components/team/members/MemberExecutionLog'; import { MemberExecutionLog } from '@renderer/components/team/members/MemberExecutionLog';
import { import {
SubagentRecentMessagesPreview,
type SubagentPreviewMessage, type SubagentPreviewMessage,
SubagentRecentMessagesPreview,
} from '@renderer/components/team/members/SubagentRecentMessagesPreview'; } from '@renderer/components/team/members/SubagentRecentMessagesPreview';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { enhanceAIGroup } from '@renderer/utils/aiGroupEnhancer';
import { formatDuration } from '@renderer/utils/formatters'; import { formatDuration } from '@renderer/utils/formatters';
import { transformChunksToConversation } from '@renderer/utils/groupTransformer';
import { import {
AlertCircle, AlertCircle,
ChevronDown, ChevronDown,
@ -18,9 +20,6 @@ import {
MessageSquare, MessageSquare,
} from 'lucide-react'; } from 'lucide-react';
import { enhanceAIGroup } from '@renderer/utils/aiGroupEnhancer';
import { transformChunksToConversation } from '@renderer/utils/groupTransformer';
import type { EnhancedChunk } from '@renderer/types/data'; import type { EnhancedChunk } from '@renderer/types/data';
import type { MemberLogSummary } from '@shared/types'; import type { MemberLogSummary } from '@shared/types';
@ -225,7 +224,7 @@ export const MemberLogsTab = ({
async (log: MemberLogSummary): Promise<EnhancedChunk[] | null> => { async (log: MemberLogSummary): Promise<EnhancedChunk[] | null> => {
if (log.kind === 'subagent') { if (log.kind === 'subagent') {
const d = await api.getSubagentDetail(log.projectId, log.sessionId, log.subagentId); 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); const d = await api.getSessionDetail(log.projectId, log.sessionId);
return (d?.chunks ?? null) as unknown as EnhancedChunk[] | null; return (d?.chunks ?? null) as unknown as EnhancedChunk[] | null;

View file

@ -734,6 +734,21 @@ body {
background-color: #12131a; 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 { :root.light .checkerboard-bg {
background-image: background-image:
linear-gradient(45deg, #e2e8f0 25%, transparent 25%), linear-gradient(45deg, #e2e8f0 25%, transparent 25%),