refactor: enhance TeamMemberLogsFinder and UI components for improved member display and caching

- Increased FILE_MENTIONS_CACHE_MAX from 1000 to 10,000 to accommodate larger datasets.
- Introduced DISCOVERY_CACHE_TTL to optimize project session discovery by caching results.
- Updated findMemberLogs method to accept an optional mtimeSinceMs parameter for filtering logs based on modification time.
- Added lastOutputPreview to MemberLogSummary for displaying the last assistant output in logs.
- Implemented displayMemberName utility function to standardize member name display across various components.
- Updated multiple components to utilize displayMemberName for consistent member name rendering.
This commit is contained in:
iliya 2026-03-14 21:25:01 +02:00
parent 85fa86f1be
commit 92dd8f445f
18 changed files with 408 additions and 139 deletions

View file

@ -23,11 +23,14 @@ const ATTRIBUTION_SCAN_LINES = 50;
/** Grace before task creation — logs cannot reference a task before it exists. */
const TASK_SINCE_GRACE_MS = 2 * 60 * 1000;
const FILE_MENTIONS_CACHE_MAX = 1000;
const FILE_MENTIONS_CACHE_MAX = 10_000;
/** Max concurrent file reads during parallel scan phases. */
const SCAN_CONCURRENCY = 15;
/** TTL for discoverProjectSessions cache — avoids re-reading config/dirs within rapid successive calls. */
const DISCOVERY_CACHE_TTL = 5_000;
/** Signal sources for subagent member attribution, ordered by reliability. */
type AttributionSignalSource = 'process_team' | 'routing_sender' | 'teammate_id' | 'text_mention';
@ -54,6 +57,7 @@ interface StreamedMetadata {
firstTimestamp: string | null;
lastTimestamp: string | null;
messageCount: number;
lastOutputPreview: string | null;
}
/** Result of attributing a subagent file to a team member. */
@ -79,6 +83,10 @@ function trimTrailingSlashes(value: string): string {
export class TeamMemberLogsFinder {
private readonly fileMentionsCache = new Map<string, boolean>();
private readonly discoveryCache = new Map<
string,
{ result: NonNullable<Awaited<ReturnType<TeamMemberLogsFinder['discoverProjectSessions']>>>; expiresAt: number }
>();
constructor(
private readonly configReader: TeamConfigReader = new TeamConfigReader(),
@ -86,7 +94,11 @@ export class TeamMemberLogsFinder {
private readonly membersMetaStore: TeamMembersMetaStore = new TeamMembersMetaStore()
) {}
async findMemberLogs(teamName: string, memberName: string): Promise<MemberLogSummary[]> {
async findMemberLogs(
teamName: string,
memberName: string,
mtimeSinceMs?: number | null
): Promise<MemberLogSummary[]> {
const discovery = await this.discoverMemberFiles(teamName, memberName);
if (!discovery) return [];
@ -118,6 +130,15 @@ export class TeamMemberLogsFinder {
const idx = nextIdx++;
const c = candidates[idx];
try {
// Skip files older than the caller's time window (cheap fs.stat, no file read)
if (mtimeSinceMs != null) {
try {
const stat = await fs.stat(c.filePath);
if (stat.mtimeMs < mtimeSinceMs) continue;
} catch {
continue;
}
}
const summary = await this.parseSubagentSummary(
c.filePath,
projectId,
@ -254,7 +275,7 @@ export class TeamMemberLogsFinder {
normalizedOwner.length > 0 &&
!isLeadOwner;
if (includeOwnerSessions) {
const ownerLogs = await this.findMemberLogs(teamName, normalizedOwner);
const ownerLogs = await this.findMemberLogs(teamName, normalizedOwner, sinceMs);
const TASK_LOG_INTERVAL_GRACE_MS = 10_000;
const fallbackRecentMs = 30 * 60_000; // if caller doesn't supply intervals/since, avoid pulling in old owner history
@ -444,7 +465,7 @@ export class TeamMemberLogsFinder {
!isLeadOwner;
if (includeOwnerSessions) {
const ownerLogs = await this.findMemberLogs(teamName, normalizedOwner);
const ownerLogs = await this.findMemberLogs(teamName, normalizedOwner, sinceMs);
const TASK_LOG_INTERVAL_GRACE_MS = 10_000;
const fallbackRecentMs = 30 * 60_000;
const now = Date.now();
@ -613,6 +634,12 @@ export class TeamMemberLogsFinder {
sessionIds: string[];
knownMembers: Set<string>;
} | null> {
// Check discovery cache — avoids re-reading config/dirs within rapid successive calls
const cached = this.discoveryCache.get(teamName);
if (cached && cached.expiresAt > Date.now()) {
return cached.result;
}
const config = await this.configReader.getConfig(teamName);
if (!config?.projectPath) {
logger.debug(`No projectPath for team "${teamName}"`);
@ -716,7 +743,12 @@ export class TeamMemberLogsFinder {
// best-effort
}
return { projectDir, projectId, config, sessionIds, knownMembers };
const discovery = { projectDir, projectId, config, sessionIds, knownMembers };
this.discoveryCache.set(teamName, {
result: discovery,
expiresAt: Date.now() + DISCOVERY_CACHE_TTL,
});
return discovery;
}
private async discoverMemberFiles(
@ -1062,6 +1094,7 @@ export class TeamMemberLogsFinder {
messageCount: metadata.messageCount,
isOngoing,
filePath,
lastOutputPreview: metadata.lastOutputPreview ?? undefined,
};
}
@ -1308,17 +1341,19 @@ export class TeamMemberLogsFinder {
messageCount: metadata.messageCount,
isOngoing,
filePath: jsonlPath,
lastOutputPreview: metadata.lastOutputPreview ?? undefined,
};
}
/**
* Stream entire JSONL file collecting only timestamps and message count.
* Lightweight uses regex to extract timestamp without full JSON parse.
* Stream entire JSONL file collecting timestamps, message count, and last assistant output.
* Lightweight uses regex to extract fields without full JSON parse.
*/
private async streamFileMetadata(filePath: string): Promise<StreamedMetadata> {
let firstTimestamp: string | null = null;
let lastTimestamp: string | null = null;
let messageCount = 0;
let lastOutputPreview: string | null = null;
try {
const stream = createReadStream(filePath, { encoding: 'utf8' });
@ -1331,12 +1366,17 @@ export class TeamMemberLogsFinder {
messageCount++;
// Fast timestamp extraction without full JSON parse.
// ISO prefix anchor avoids false positives from "timestamp" inside string values.
const ts = this.extractTimestampFromLine(trimmed);
if (ts) {
if (!firstTimestamp) firstTimestamp = ts;
lastTimestamp = ts;
}
// Track last assistant text output (cheap regex, overwrites on each match).
if (trimmed.includes('"role":"assistant"') || trimmed.includes('"role": "assistant"')) {
const preview = TeamMemberLogsFinder.extractAssistantPreview(trimmed);
if (preview) lastOutputPreview = preview;
}
}
rl.close();
stream.destroy();
@ -1344,7 +1384,7 @@ export class TeamMemberLogsFinder {
// ignore — return whatever we collected so far
}
return { firstTimestamp, lastTimestamp, messageCount };
return { firstTimestamp, lastTimestamp, messageCount, lastOutputPreview };
}
private extractTimestampFromLine(line: string): string | null {
@ -1352,6 +1392,34 @@ export class TeamMemberLogsFinder {
return tsMatch?.[1] ?? null;
}
/**
* Extract a short text preview from an assistant message line.
* Looks for the first text block content via regex (avoids full JSON parse).
*/
private static extractAssistantPreview(line: string): string | null {
// Match {"type":"text","text":"..."} blocks
const textMatch = /"type"\s*:\s*"text"[^}]*"text"\s*:\s*"([^"]{1,200})/.exec(line);
if (textMatch?.[1]) {
const raw = textMatch[1]
.replace(/\\n/g, ' ')
.replace(/\\t/g, ' ')
.replace(/\s+/g, ' ')
.trim();
return raw.length > 120 ? raw.slice(0, 120) + '...' : raw;
}
// Fallback: top-level string content
const contentMatch = /"content"\s*:\s*"([^"]{1,200})/.exec(line);
if (contentMatch?.[1]) {
const raw = contentMatch[1]
.replace(/\\n/g, ' ')
.replace(/\\t/g, ' ')
.replace(/\s+/g, ' ')
.trim();
return raw.length > 120 ? raw.slice(0, 120) + '...' : raw;
}
return null;
}
private async probeFirstTimestamp(
filePath: string,
maxLines = ATTRIBUTION_SCAN_LINES

View file

@ -88,6 +88,49 @@ function allowCustomProtocols(url: string): string {
return defaultUrlTransform(url);
}
/**
* Set of standard HTML element tag names.
* Used to filter out non-HTML XML-like tags (e.g. `<your-name>`, `<info_for_agent>`)
* that appear in agent messages and cause React "unrecognized tag" warnings.
*/
const STANDARD_HTML_TAGS = new Set([
'a', 'abbr', 'address', 'area', 'article', 'aside', 'audio',
'b', 'base', 'bdi', 'bdo', 'blockquote', 'body', 'br', 'button',
'canvas', 'caption', 'cite', 'code', 'col', 'colgroup',
'data', 'datalist', 'dd', 'del', 'details', 'dfn', 'dialog', 'div', 'dl', 'dt',
'em', 'embed',
'fieldset', 'figcaption', 'figure', 'footer', 'form',
'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'head', 'header', 'hgroup', 'hr', 'html',
'i', 'iframe', 'img', 'input', 'ins',
'kbd',
'label', 'legend', 'li', 'link',
'main', 'map', 'mark', 'menu', 'meta', 'meter',
'nav', 'noscript',
'object', 'ol', 'optgroup', 'option', 'output',
'p', 'picture', 'pre', 'progress',
'q',
'rp', 'rt', 'ruby',
's', 'samp', 'script', 'search', 'section', 'select', 'slot', 'small', 'source', 'span',
'strong', 'style', 'sub', 'summary', 'sup',
'table', 'tbody', 'td', 'template', 'textarea', 'tfoot', 'th', 'thead', 'time', 'title', 'tr', 'track',
'u', 'ul',
'var', 'video',
'wbr',
// SVG elements commonly used inline
'svg', 'path', 'circle', 'rect', 'line', 'polyline', 'polygon', 'g', 'defs', 'use',
'text', 'tspan', 'clippath', 'mask', 'pattern', 'image', 'foreignobject',
]);
/**
* Filter for react-markdown's `allowElement` prop.
* Returns false for non-standard HTML tags (e.g. `<your-name>`, `<info_for_agent>`),
* which causes react-markdown to render their text content instead of the element.
* This prevents React "unrecognized tag" warnings from XML-like tags in agent messages.
*/
function isAllowedElement(element: { tagName: string }): boolean {
return STANDARD_HTML_TAGS.has(element.tagName.toLowerCase());
}
/** Resolve a relative path to an absolute path given a base directory */
function resolveRelativePath(relativeSrc: string, baseDir: string): string {
const cleaned = relativeSrc.startsWith('./') ? relativeSrc.slice(2) : relativeSrc;
@ -768,6 +811,7 @@ export const MarkdownViewer: React.FC<MarkdownViewerProps> = ({
rehypePlugins={disableHighlight ? REHYPE_PLUGINS_NO_HIGHLIGHT : REHYPE_PLUGINS}
components={components}
urlTransform={allowCustomProtocols}
allowElement={isAllowedElement}
>
{content}
</ReactMarkdown>

View file

@ -5,7 +5,7 @@ import {
getThemedText,
} from '@renderer/constants/teamColors';
import { useTheme } from '@renderer/hooks/useTheme';
import { agentAvatarUrl } from '@renderer/utils/memberHelpers';
import { agentAvatarUrl, displayMemberName } from '@renderer/utils/memberHelpers';
import { MemberHoverCard } from './members/MemberHoverCard';
@ -62,7 +62,7 @@ export const MemberBadge = ({
className={`rounded ${paddingClass} ${textClass} font-medium tracking-wide`}
style={badgeStyle}
>
{name === 'team-lead' ? 'lead' : name}
{displayMemberName(name)}
</span>
);

View file

@ -2,7 +2,11 @@ import { CARD_BG, CARD_BORDER_STYLE, CARD_ICON_MUTED } from '@renderer/constants
import { getTeamColorSet, getThemedBadge } from '@renderer/constants/teamColors';
import { useTheme } from '@renderer/hooks/useTheme';
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
import { agentAvatarUrl, buildMemberColorMap } from '@renderer/utils/memberHelpers';
import {
agentAvatarUrl,
buildMemberColorMap,
displayMemberName,
} from '@renderer/utils/memberHelpers';
import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
import type { ResolvedTeamMember, TeamTaskWithKanban } from '@shared/types';
@ -107,7 +111,7 @@ export const ActiveTasksBlock = ({
}}
onClick={() => onMemberClick(member)}
>
{member.name}
{displayMemberName(member.name)}
</button>
) : (
<span
@ -118,7 +122,7 @@ export const ActiveTasksBlock = ({
border: `1px solid ${colors.border}40`,
}}
>
{member.name}
{displayMemberName(member.name)}
</span>
)}
{roleLabel ? (

View file

@ -2,7 +2,11 @@ import { CARD_BG, CARD_BORDER_STYLE, CARD_ICON_MUTED } from '@renderer/constants
import { getTeamColorSet, getThemedBadge } from '@renderer/constants/teamColors';
import { useTheme } from '@renderer/hooks/useTheme';
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
import { agentAvatarUrl, buildMemberColorMap } from '@renderer/utils/memberHelpers';
import {
agentAvatarUrl,
buildMemberColorMap,
displayMemberName,
} from '@renderer/utils/memberHelpers';
import { nameColorSet } from '@renderer/utils/projectColor';
import { formatDistanceToNowStrict } from 'date-fns';
import { Users } from 'lucide-react';
@ -99,7 +103,7 @@ export const PendingRepliesBlock = ({
onClick={() => onMemberClick(member)}
title="Open member"
>
{member.name}
{displayMemberName(member.name)}
</button>
) : (
<span
@ -110,7 +114,7 @@ export const PendingRepliesBlock = ({
border: `1px solid ${colors.border}40`,
}}
>
{member.name}
{displayMemberName(member.name)}
</span>
)}
{roleLabel ? (

View file

@ -703,7 +703,7 @@ export const TaskDetailDialog = ({
) : null}
{/* Sections container with uniform spacing */}
<div className="space-y-1">
<div className="min-w-0 space-y-1">
{/* Description */}
<CollapsibleTeamSection
title="Description"
@ -985,7 +985,7 @@ export const TaskDetailDialog = ({
</span>
) : null
}
contentClassName="pl-2.5"
contentClassName="pl-2.5 overflow-visible"
headerClassName="-mx-6 w-[calc(100%+3rem)]"
headerContentClassName="pl-6"
defaultOpen={false}

View file

@ -6,6 +6,8 @@ import { Popover, PopoverContent, PopoverTrigger } from '@renderer/components/ui
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { Crown, Filter } from 'lucide-react';
import { displayMemberName } from '@renderer/utils/memberHelpers';
import type { Session } from '@renderer/types/data';
import type { KanbanColumnId, ResolvedTeamMember } from '@shared/types';
@ -156,7 +158,7 @@ export const KanbanFilterPopover = ({
checked={filter.selectedOwners.has(member.name)}
onCheckedChange={() => handleOwnerToggle(member.name)}
/>
{member.name}
{displayMemberName(member.name)}
</label>
))}
{/* eslint-disable-next-line jsx-a11y/label-has-associated-control -- Radix Checkbox renders a button, not a native input */}

View file

@ -5,6 +5,7 @@ import { useTheme } from '@renderer/hooks/useTheme';
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
import {
agentAvatarUrl,
displayMemberName,
getSpawnAwareDotClass,
getSpawnAwarePresenceLabel,
getSpawnCardClass,
@ -130,7 +131,7 @@ export const MemberCard = ({
/>
</div>
<div className="flex min-w-0 flex-1 items-center gap-1.5 truncate text-sm">
<span className="shrink-0 font-medium text-[var(--color-text)]">{member.name}</span>
<span className="shrink-0 font-medium text-[var(--color-text)]">{displayMemberName(member.name)}</span>
{member.gitBranch ? (
<span className="flex shrink-0 items-center gap-0.5 text-[10px] text-[var(--color-text-muted)]">
<GitBranch size={10} />
@ -187,7 +188,7 @@ export const MemberCard = ({
</TooltipTrigger>
<TooltipContent side="bottom">{spawnError ?? 'Spawn failed'}</TooltipContent>
</Tooltip>
) : (
) : !activityTask ? (
<Badge
variant="secondary"
className={`shrink-0 px-1.5 py-0.5 text-[10px] font-normal leading-none ${isRemoved ? 'bg-zinc-600 text-zinc-300' : 'text-[var(--color-text-muted)]'}`}
@ -195,7 +196,7 @@ export const MemberCard = ({
>
{isRemoved ? 'removed' : presenceLabel}
</Badge>
)}
) : null}
<div
className="shrink-0"
title={totalTasks > 0 ? `${completed}/${totalTasks} completed` : undefined}

View file

@ -4,7 +4,12 @@ import { Badge } from '@renderer/components/ui/badge';
import { DialogDescription, DialogTitle } from '@renderer/components/ui/dialog';
import { getTeamColorSet } from '@renderer/constants/teamColors';
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
import { agentAvatarUrl, getMemberDotClass, getPresenceLabel } from '@renderer/utils/memberHelpers';
import {
agentAvatarUrl,
displayMemberName,
getMemberDotClass,
getPresenceLabel,
} from '@renderer/utils/memberHelpers';
import { Pencil } from 'lucide-react';
import { MemberRoleEditor } from './MemberRoleEditor';
@ -60,7 +65,7 @@ export const MemberDetailHeader = ({
</div>
<div className="min-w-0 flex-1">
<DialogTitle className="truncate" style={{ color: colors.text }}>
{member.name}
{displayMemberName(member.name)}
</DialogTitle>
<DialogDescription asChild className="mt-1 flex items-center gap-2">
<div>

View file

@ -27,6 +27,12 @@ export const MemberExecutionLog = ({
}: MemberExecutionLogProps): React.JSX.Element => {
const conversation = useMemo(() => transformChunksToConversation(chunks, [], false), [chunks]);
// Show newest groups first — most recent activity is most relevant in execution logs.
const orderedItems = useMemo(
() => [...conversation.items].reverse(),
[conversation.items]
);
// Store collapsed groups instead of expanded: by default, everything is expanded.
// This avoids resetting state in an effect when conversation changes.
const [collapsedGroupIds, setCollapsedGroupIds] = useState<Set<string>>(new Set());
@ -34,7 +40,7 @@ export const MemberExecutionLog = ({
new Map()
);
if (!conversation.items.length) {
if (!orderedItems.length) {
return (
<div className="py-6 text-center text-xs text-[var(--color-text-muted)]">
Nothing to display
@ -44,7 +50,7 @@ export const MemberExecutionLog = ({
return (
<div className="min-w-0 space-y-6 overflow-hidden">
{conversation.items.map((item) => {
{orderedItems.map((item) => {
if (item.type === 'system') {
return <SystemChatGroup key={item.group.id} systemGroup={item.group} />;
}

View file

@ -9,7 +9,12 @@ import {
import { useTheme } from '@renderer/hooks/useTheme';
import { useStore } from '@renderer/store';
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
import { agentAvatarUrl, getMemberDotClass, getPresenceLabel } from '@renderer/utils/memberHelpers';
import {
agentAvatarUrl,
displayMemberName,
getMemberDotClass,
getPresenceLabel,
} from '@renderer/utils/memberHelpers';
import { ExternalLink } from 'lucide-react';
import { CurrentTaskIndicator } from './CurrentTaskIndicator';
@ -105,7 +110,7 @@ export const MemberHoverCard = ({
className="truncate text-sm font-semibold"
style={{ color: getThemedText(colors, isLight) }}
>
{member.name}
{displayMemberName(member.name)}
</span>
<Badge
variant="secondary"

View file

@ -11,12 +11,15 @@ import { asEnhancedChunkArray } from '@renderer/types/data';
import { enhanceAIGroup } from '@renderer/utils/aiGroupEnhancer';
import { formatDuration } from '@renderer/utils/formatters';
import { transformChunksToConversation } from '@renderer/utils/groupTransformer';
import { getMemberColorByName } from '@shared/constants/memberColors';
import { getTeamColorSet } from '@renderer/constants/teamColors';
import {
AlertCircle,
ChevronDown,
ChevronRight,
Clock,
FileText,
Info,
Loader2,
MessageSquare,
} from 'lucide-react';
@ -516,10 +519,22 @@ const LogCard = ({
detailLoading,
onToggle,
}: LogCardProps): React.JSX.Element => {
const timeAgo = formatRelativeTime(log.startTime);
const createdAgo = formatRelativeTime(log.startTime);
const lastActivityTime = useMemo(() => {
const startMs = new Date(log.startTime).getTime();
if (!Number.isFinite(startMs) || log.durationMs <= 0) return null;
return new Date(startMs + log.durationMs).toISOString();
}, [log.startTime, log.durationMs]);
const updatedAgo = lastActivityTime ? formatRelativeTime(lastActivityTime) : null;
const memberColorCss = useMemo(() => {
if (!log.memberName) return null;
const colorName = getMemberColorByName(log.memberName);
return getTeamColorSet(colorName).text;
}, [log.memberName]);
return (
<div className="min-w-0 rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] [overflow:clip]">
<div className="min-w-0 rounded-md border border-[var(--color-border)] bg-[var(--color-surface)]">
<Tooltip>
<TooltipTrigger asChild>
<button
@ -531,15 +546,51 @@ const LogCard = ({
) : (
<ChevronRight size={12} className="shrink-0 text-[var(--color-text-muted)]" />
)}
{memberColorCss && (
<span
className="size-2 shrink-0 rounded-full"
style={{ backgroundColor: memberColorCss }}
/>
)}
<div className="min-w-0 flex-1 overflow-hidden">
<div className="truncate text-[var(--color-text)]" title={log.description}>
{log.description}
<div className="flex items-center gap-1.5">
<span className="truncate text-[var(--color-text)]" title={log.description}>
{log.description}
</span>
{log.kind === 'lead_session' && (
<Tooltip>
<TooltipTrigger asChild>
<span
className="shrink-0 cursor-help text-[var(--color-text-muted)]"
onClick={(e) => e.stopPropagation()}
>
<Info size={11} />
</span>
</TooltipTrigger>
<TooltipContent side="top" className="max-w-[240px] text-center">
Full team lead session logs useful for global orchestration context, not
specific to this agent
</TooltipContent>
</Tooltip>
)}
</div>
<div className="mt-0.5 flex items-center gap-3 text-[10px] text-[var(--color-text-muted)]">
<span className="flex items-center gap-1">
<Clock size={10} />
{timeAgo}
</span>
{updatedAgo && updatedAgo !== createdAgo ? (
<>
<span className="flex items-center gap-1">
<Clock size={10} />
{updatedAgo}
</span>
<span style={{ opacity: 0.4 }}>
started {createdAgo}
</span>
</>
) : (
<span className="flex items-center gap-1">
<Clock size={10} />
{createdAgo}
</span>
)}
{log.durationMs > 0 && <span>{formatDuration(log.durationMs)}</span>}
<span className="flex items-center gap-1">
<MessageSquare size={10} />
@ -549,6 +600,11 @@ const LogCard = ({
<span className="rounded-full bg-green-500/20 px-1.5 text-green-400">active</span>
)}
</div>
{log.lastOutputPreview && !expanded && (
<div className="mt-1 truncate text-[10px] text-[var(--color-text-muted)]" style={{ opacity: 0.6 }}>
{log.lastOutputPreview}
</div>
)}
</div>
</button>
</TooltipTrigger>

View file

@ -1,6 +1,7 @@
import { useState } from 'react';
import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer';
import { displayMemberName } from '@renderer/utils/memberHelpers';
import { format } from 'date-fns';
import { ChevronDown, ChevronUp } from 'lucide-react';
@ -44,13 +45,16 @@ export const SubagentRecentMessagesPreview = ({
<div className="mb-3 rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] p-2">
<div className="mb-2 flex items-center gap-2">
<div className="min-w-0 truncate text-[11px] text-[var(--color-text-muted)]">
Latest messages{memberName ? `${memberName}` : ''}
Latest messages{memberName ? `${displayMemberName(memberName)}` : ''}
</div>
</div>
<div className={`${expandedAll ? 'max-h-none' : 'max-h-[200px]'} overflow-y-auto pr-1`}>
{messages.map((m, index) => (
<div key={m.id} className="py-1.5">
<div
key={m.id}
className={`rounded px-2 py-1.5 ${index % 2 === 0 ? 'bg-white/[0.02]' : ''}`}
>
<div className="flex items-start gap-2">
<div className="min-w-0 flex-1 text-xs text-[var(--color-text)]">
<MarkdownViewer
@ -64,10 +68,6 @@ export const SubagentRecentMessagesPreview = ({
{format(m.timestamp, 'h:mm:ss a')}
</div>
</div>
{index < messages.length - 1 ? (
<hr className="mt-2 border-[var(--color-border)]" />
) : null}
</div>
))}

View file

@ -13,6 +13,7 @@ import { getFileHunkCount, REVIEW_INSTANT_APPLY } from '@renderer/store/slices/c
import { buildSelectionAction } from '@renderer/utils/buildSelectionAction';
import { buildSelectionInfo, SELECTION_DEBOUNCE_MS } from '@renderer/utils/codemirrorSelectionInfo';
import { sortItemsAsTree } from '@renderer/utils/fileTreeBuilder';
import { displayMemberName } from '@renderer/utils/memberHelpers';
import { type TaskChangeRequestOptions } from '@renderer/utils/taskChangeRequest';
import { ChevronDown, Clock, X } from 'lucide-react';
@ -1065,7 +1066,7 @@ export const ChangeReviewDialog = ({
}, [activeChangeSet, activeFilePath]);
const title = useMemo(() => {
if (mode === 'agent') return `Changes by ${memberName ?? 'unknown'}`;
if (mode === 'agent') return `Changes by ${displayMemberName(memberName ?? 'unknown')}`;
const task = taskId ? globalTasks.find((t) => t.id === taskId) : undefined;
const shortId = task?.displayId ?? taskId?.slice(0, 8) ?? '?';
const subject = task?.subject;

View file

@ -5,7 +5,7 @@
* Used by TeammateMessageItem and SubagentItem when displaying team members.
*/
import { MEMBER_COLOR_PALETTE } from '@shared/constants/memberColors';
import { MEMBER_COLOR_HUE, MEMBER_COLOR_PALETTE } from '@shared/constants/memberColors';
export interface TeamColorSet {
/** Border accent color */
@ -135,16 +135,21 @@ function hsla(hue: number, saturation: number, lightness: number, alpha = 1): st
}
function buildGeneratedMemberColorSet(colorName: string): TeamColorSet | null {
const paletteIndex = MEMBER_COLOR_PALETTE.indexOf(
colorName as (typeof MEMBER_COLOR_PALETTE)[number]
);
if (paletteIndex === -1) {
return null;
const hue = MEMBER_COLOR_HUE[colorName];
if (hue === undefined) {
// Also accept palette names not in the hue map (shouldn't happen, but safe fallback)
const paletteIndex = MEMBER_COLOR_PALETTE.indexOf(
colorName as (typeof MEMBER_COLOR_PALETTE)[number]
);
if (paletteIndex === -1) return null;
// Fall back to index-based hue (legacy behavior)
return buildColorSetFromHue(Math.round((paletteIndex / MEMBER_COLOR_PALETTE.length) * 360));
}
// Spread the extended member palette across the hue wheel so distinct palette
// names stay visually distinct instead of collapsing back into 8 base colors.
const hue = Math.round((paletteIndex / MEMBER_COLOR_PALETTE.length) * 360);
return buildColorSetFromHue(hue);
}
function buildColorSetFromHue(hue: number): TeamColorSet {
const saturation = 72;
return {

View file

@ -13,6 +13,15 @@ import type {
TeamTaskStatus,
} from '@shared/types';
/**
* UI display name for a team member.
* "team-lead" "lead"; everything else passes through unchanged.
* Data layer (store, IPC, backend) must keep the original name untouched.
*/
export function displayMemberName(name: string): string {
return name === 'team-lead' ? 'lead' : name;
}
export function agentAvatarUrl(name: string, size = 64): string {
return `https://robohash.org/${encodeURIComponent(name)}?size=${size}x${size}`;
}
@ -159,35 +168,33 @@ interface MemberColorInput {
/**
* Build a consistent namecolorName map for all members.
* Deduplicates colors: first member (alphabetically) keeps its stored color,
* subsequent collisions get the next unused palette color.
* Also maps "user" to a reserved color.
* Active members receive colors sequentially from MEMBER_COLOR_PALETTE,
* which is pre-ordered for maximum visual contrast between consecutive entries.
* If a member has a stored color that hasn't been assigned yet, it is used instead.
* Maps "user" to a reserved color.
*/
export function buildMemberColorMap(members: MemberColorInput[]): Map<string, string> {
const map = new Map<string, string>();
const active = members.filter((m) => !m.removedAt);
const removed = members.filter((m) => m.removedAt);
const usedColors = new Set<string>();
let nextPaletteIdx = 0;
const paletteSize = MEMBER_COLOR_PALETTE.length;
for (const member of active) {
let color = member.color ? normalizeMemberColorName(member.color) : undefined;
if (!color || usedColors.has(color)) {
// Deterministic fallback: hash the member name to a palette color.
// If that color is already taken, linear-probe for the next free one.
color = getMemberColorByName(member.name);
if (usedColors.has(color)) {
const startIdx = MEMBER_COLOR_PALETTE.indexOf(
color as (typeof MEMBER_COLOR_PALETTE)[number]
);
for (let offset = 1; offset < paletteSize; offset++) {
const candidate = MEMBER_COLOR_PALETTE[(startIdx + offset) % paletteSize];
if (!usedColors.has(candidate)) {
color = candidate;
break;
}
}
// Assign the next unused color from the pre-ordered palette.
while (
nextPaletteIdx < MEMBER_COLOR_PALETTE.length &&
usedColors.has(MEMBER_COLOR_PALETTE[nextPaletteIdx])
) {
nextPaletteIdx++;
}
color =
nextPaletteIdx < MEMBER_COLOR_PALETTE.length
? MEMBER_COLOR_PALETTE[nextPaletteIdx]
: MEMBER_COLOR_PALETTE[active.indexOf(member) % MEMBER_COLOR_PALETTE.length];
nextPaletteIdx++;
}
map.set(member.name, color);
usedColors.add(color);

View file

@ -1,80 +1,139 @@
/**
* Default color palette for team members.
* Intentionally excludes purple-family tones for member UI.
* Pre-ordered color palette for team members.
* Colors are arranged so that consecutive entries are maximally distant
* on the hue wheel the first N members always get visually distinct colors.
* Generated via greedy max-min-distance algorithm over hue angles.
* Intentionally excludes purple-family tones.
*/
export const MEMBER_COLOR_PALETTE = [
// ── Primary & classic ──
'blue',
'green',
'yellow',
'cyan',
'red',
'orange',
'pink',
// ── First 10: maximum contrast (>40° hue gap between any pair) ──
'blue', // 0°
'saffron', // 177°
'turquoise', // 268°
'brick', // 85°
'apricot', // 131°
'indigo', // 314°
'forest', // 223°
'pink', // 39°
'crimson', // 59°
'tangerine', // 105°
// ── Red family ──
'rose',
'coral',
'crimson',
'scarlet',
'tomato',
'salmon',
'brick',
'ruby',
// ── Next 14: still good separation ──
'gold', // 151°
'emerald', // 203°
'cerulean', // 288°
'denim', // 334°
'cyan', // 20°
'sage', // 242°
'tomato', // 72°
'rust', // 118°
'mustard', // 164°
'canary', // 190°
'teal', // 255°
'arctic', // 301°
'royal', // 347°
'green', // 7°
// ── Orange / warm family ──
'amber',
'tangerine',
'peach',
'rust',
'copper',
'apricot',
'bronze',
'sienna',
// ── Yellow / gold family ──
'gold',
'lemon',
'mustard',
'honey',
'saffron',
'marigold',
'canary',
'sunflower',
// ── Green family ──
'emerald',
'lime',
'mint',
'forest',
'olive',
'jade',
'sage',
'chartreuse',
// ── Cyan / teal family ──
'teal',
'aqua',
'turquoise',
'sky',
'azure',
'cerulean',
'seafoam',
'arctic',
// ── Blue / indigo family ──
'cobalt',
'indigo',
'sapphire',
'periwinkle',
'denim',
'steel',
'royal',
'cornflower',
// ── Remaining: fill the hue gaps progressively ──
'rose', // 46°
'ruby', // 92°
'sienna', // 144°
'mint', // 216°
'sky', // 275°
'sapphire', // 321°
'yellow', // 13°
'red', // 26°
'orange', // 33°
'coral', // 52°
'scarlet', // 65°
'salmon', // 79°
'amber', // 98°
'peach', // 111°
'copper', // 124°
'bronze', // 137°
'lemon', // 157°
'honey', // 170°
'marigold', // 183°
'sunflower', // 196°
'lime', // 209°
'olive', // 229°
'jade', // 236°
'chartreuse', // 249°
'aqua', // 262°
'azure', // 281°
'seafoam', // 295°
'cobalt', // 308°
'periwinkle', // 327°
'steel', // 340°
'cornflower', // 353°
] as const;
export type MemberColorName = (typeof MEMBER_COLOR_PALETTE)[number];
/**
* Fixed hue angle (0-359) for each palette color name.
* This is independent of array order colors keep their visual identity
* regardless of how MEMBER_COLOR_PALETTE is sorted.
* Spread evenly across 360° so every name has a unique hue.
*/
export const MEMBER_COLOR_HUE: Record<string, number> = {
blue: 0,
green: 7,
yellow: 13,
cyan: 20,
red: 26,
orange: 33,
pink: 39,
rose: 46,
coral: 52,
crimson: 59,
scarlet: 65,
tomato: 72,
salmon: 79,
brick: 85,
ruby: 92,
amber: 98,
tangerine: 105,
peach: 111,
rust: 118,
copper: 124,
apricot: 131,
bronze: 137,
sienna: 144,
gold: 151,
lemon: 157,
mustard: 164,
honey: 170,
saffron: 177,
marigold: 183,
canary: 190,
sunflower: 196,
emerald: 203,
lime: 209,
mint: 216,
forest: 223,
olive: 229,
jade: 236,
sage: 242,
chartreuse: 249,
teal: 255,
aqua: 262,
turquoise: 268,
sky: 275,
azure: 281,
cerulean: 288,
seafoam: 295,
arctic: 301,
cobalt: 308,
indigo: 314,
sapphire: 321,
periwinkle: 327,
denim: 334,
steel: 340,
royal: 347,
cornflower: 353,
};
const DISALLOWED_MEMBER_COLORS = new Set([
'purple',
'violet',

View file

@ -609,6 +609,8 @@ export interface MemberLogSummaryBase {
isOngoing: boolean;
/** Absolute path to JSONL file when known (avoids redundant findMemberLogPaths scan). */
filePath?: string;
/** Short preview of the last assistant output (truncated). */
lastOutputPreview?: string;
}
export interface MemberSubagentLogSummary extends MemberLogSummaryBase {