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. */ /** Grace before task creation — logs cannot reference a task before it exists. */
const TASK_SINCE_GRACE_MS = 2 * 60 * 1000; 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. */ /** Max concurrent file reads during parallel scan phases. */
const SCAN_CONCURRENCY = 15; 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. */ /** Signal sources for subagent member attribution, ordered by reliability. */
type AttributionSignalSource = 'process_team' | 'routing_sender' | 'teammate_id' | 'text_mention'; type AttributionSignalSource = 'process_team' | 'routing_sender' | 'teammate_id' | 'text_mention';
@ -54,6 +57,7 @@ interface StreamedMetadata {
firstTimestamp: string | null; firstTimestamp: string | null;
lastTimestamp: string | null; lastTimestamp: string | null;
messageCount: number; messageCount: number;
lastOutputPreview: string | null;
} }
/** Result of attributing a subagent file to a team member. */ /** Result of attributing a subagent file to a team member. */
@ -79,6 +83,10 @@ function trimTrailingSlashes(value: string): string {
export class TeamMemberLogsFinder { export class TeamMemberLogsFinder {
private readonly fileMentionsCache = new Map<string, boolean>(); private readonly fileMentionsCache = new Map<string, boolean>();
private readonly discoveryCache = new Map<
string,
{ result: NonNullable<Awaited<ReturnType<TeamMemberLogsFinder['discoverProjectSessions']>>>; expiresAt: number }
>();
constructor( constructor(
private readonly configReader: TeamConfigReader = new TeamConfigReader(), private readonly configReader: TeamConfigReader = new TeamConfigReader(),
@ -86,7 +94,11 @@ export class TeamMemberLogsFinder {
private readonly membersMetaStore: TeamMembersMetaStore = new TeamMembersMetaStore() 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); const discovery = await this.discoverMemberFiles(teamName, memberName);
if (!discovery) return []; if (!discovery) return [];
@ -118,6 +130,15 @@ export class TeamMemberLogsFinder {
const idx = nextIdx++; const idx = nextIdx++;
const c = candidates[idx]; const c = candidates[idx];
try { 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( const summary = await this.parseSubagentSummary(
c.filePath, c.filePath,
projectId, projectId,
@ -254,7 +275,7 @@ export class TeamMemberLogsFinder {
normalizedOwner.length > 0 && normalizedOwner.length > 0 &&
!isLeadOwner; !isLeadOwner;
if (includeOwnerSessions) { 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 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 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; !isLeadOwner;
if (includeOwnerSessions) { 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 TASK_LOG_INTERVAL_GRACE_MS = 10_000;
const fallbackRecentMs = 30 * 60_000; const fallbackRecentMs = 30 * 60_000;
const now = Date.now(); const now = Date.now();
@ -613,6 +634,12 @@ export class TeamMemberLogsFinder {
sessionIds: string[]; sessionIds: string[];
knownMembers: Set<string>; knownMembers: Set<string>;
} | null> { } | 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); const config = await this.configReader.getConfig(teamName);
if (!config?.projectPath) { if (!config?.projectPath) {
logger.debug(`No projectPath for team "${teamName}"`); logger.debug(`No projectPath for team "${teamName}"`);
@ -716,7 +743,12 @@ export class TeamMemberLogsFinder {
// best-effort // 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( private async discoverMemberFiles(
@ -1062,6 +1094,7 @@ export class TeamMemberLogsFinder {
messageCount: metadata.messageCount, messageCount: metadata.messageCount,
isOngoing, isOngoing,
filePath, filePath,
lastOutputPreview: metadata.lastOutputPreview ?? undefined,
}; };
} }
@ -1308,17 +1341,19 @@ export class TeamMemberLogsFinder {
messageCount: metadata.messageCount, messageCount: metadata.messageCount,
isOngoing, isOngoing,
filePath: jsonlPath, filePath: jsonlPath,
lastOutputPreview: metadata.lastOutputPreview ?? undefined,
}; };
} }
/** /**
* Stream entire JSONL file collecting only timestamps and message count. * Stream entire JSONL file collecting timestamps, message count, and last assistant output.
* Lightweight uses regex to extract timestamp without full JSON parse. * Lightweight uses regex to extract fields without full JSON parse.
*/ */
private async streamFileMetadata(filePath: string): Promise<StreamedMetadata> { private async streamFileMetadata(filePath: string): Promise<StreamedMetadata> {
let firstTimestamp: string | null = null; let firstTimestamp: string | null = null;
let lastTimestamp: string | null = null; let lastTimestamp: string | null = null;
let messageCount = 0; let messageCount = 0;
let lastOutputPreview: string | null = null;
try { try {
const stream = createReadStream(filePath, { encoding: 'utf8' }); const stream = createReadStream(filePath, { encoding: 'utf8' });
@ -1331,12 +1366,17 @@ export class TeamMemberLogsFinder {
messageCount++; messageCount++;
// Fast timestamp extraction without full JSON parse. // Fast timestamp extraction without full JSON parse.
// ISO prefix anchor avoids false positives from "timestamp" inside string values.
const ts = this.extractTimestampFromLine(trimmed); const ts = this.extractTimestampFromLine(trimmed);
if (ts) { if (ts) {
if (!firstTimestamp) firstTimestamp = ts; if (!firstTimestamp) firstTimestamp = ts;
lastTimestamp = 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(); rl.close();
stream.destroy(); stream.destroy();
@ -1344,7 +1384,7 @@ export class TeamMemberLogsFinder {
// ignore — return whatever we collected so far // ignore — return whatever we collected so far
} }
return { firstTimestamp, lastTimestamp, messageCount }; return { firstTimestamp, lastTimestamp, messageCount, lastOutputPreview };
} }
private extractTimestampFromLine(line: string): string | null { private extractTimestampFromLine(line: string): string | null {
@ -1352,6 +1392,34 @@ export class TeamMemberLogsFinder {
return tsMatch?.[1] ?? null; 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( private async probeFirstTimestamp(
filePath: string, filePath: string,
maxLines = ATTRIBUTION_SCAN_LINES maxLines = ATTRIBUTION_SCAN_LINES

View file

@ -88,6 +88,49 @@ function allowCustomProtocols(url: string): string {
return defaultUrlTransform(url); 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 */ /** Resolve a relative path to an absolute path given a base directory */
function resolveRelativePath(relativeSrc: string, baseDir: string): string { function resolveRelativePath(relativeSrc: string, baseDir: string): string {
const cleaned = relativeSrc.startsWith('./') ? relativeSrc.slice(2) : relativeSrc; 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} rehypePlugins={disableHighlight ? REHYPE_PLUGINS_NO_HIGHLIGHT : REHYPE_PLUGINS}
components={components} components={components}
urlTransform={allowCustomProtocols} urlTransform={allowCustomProtocols}
allowElement={isAllowedElement}
> >
{content} {content}
</ReactMarkdown> </ReactMarkdown>

View file

@ -5,7 +5,7 @@ import {
getThemedText, getThemedText,
} from '@renderer/constants/teamColors'; } from '@renderer/constants/teamColors';
import { useTheme } from '@renderer/hooks/useTheme'; import { useTheme } from '@renderer/hooks/useTheme';
import { agentAvatarUrl } from '@renderer/utils/memberHelpers'; import { agentAvatarUrl, displayMemberName } from '@renderer/utils/memberHelpers';
import { MemberHoverCard } from './members/MemberHoverCard'; import { MemberHoverCard } from './members/MemberHoverCard';
@ -62,7 +62,7 @@ export const MemberBadge = ({
className={`rounded ${paddingClass} ${textClass} font-medium tracking-wide`} className={`rounded ${paddingClass} ${textClass} font-medium tracking-wide`}
style={badgeStyle} style={badgeStyle}
> >
{name === 'team-lead' ? 'lead' : name} {displayMemberName(name)}
</span> </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 { getTeamColorSet, getThemedBadge } from '@renderer/constants/teamColors';
import { useTheme } from '@renderer/hooks/useTheme'; import { useTheme } from '@renderer/hooks/useTheme';
import { formatAgentRole } from '@renderer/utils/formatAgentRole'; 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 { formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
import type { ResolvedTeamMember, TeamTaskWithKanban } from '@shared/types'; import type { ResolvedTeamMember, TeamTaskWithKanban } from '@shared/types';
@ -107,7 +111,7 @@ export const ActiveTasksBlock = ({
}} }}
onClick={() => onMemberClick(member)} onClick={() => onMemberClick(member)}
> >
{member.name} {displayMemberName(member.name)}
</button> </button>
) : ( ) : (
<span <span
@ -118,7 +122,7 @@ export const ActiveTasksBlock = ({
border: `1px solid ${colors.border}40`, border: `1px solid ${colors.border}40`,
}} }}
> >
{member.name} {displayMemberName(member.name)}
</span> </span>
)} )}
{roleLabel ? ( {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 { getTeamColorSet, getThemedBadge } from '@renderer/constants/teamColors';
import { useTheme } from '@renderer/hooks/useTheme'; import { useTheme } from '@renderer/hooks/useTheme';
import { formatAgentRole } from '@renderer/utils/formatAgentRole'; 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 { nameColorSet } from '@renderer/utils/projectColor';
import { formatDistanceToNowStrict } from 'date-fns'; import { formatDistanceToNowStrict } from 'date-fns';
import { Users } from 'lucide-react'; import { Users } from 'lucide-react';
@ -99,7 +103,7 @@ export const PendingRepliesBlock = ({
onClick={() => onMemberClick(member)} onClick={() => onMemberClick(member)}
title="Open member" title="Open member"
> >
{member.name} {displayMemberName(member.name)}
</button> </button>
) : ( ) : (
<span <span
@ -110,7 +114,7 @@ export const PendingRepliesBlock = ({
border: `1px solid ${colors.border}40`, border: `1px solid ${colors.border}40`,
}} }}
> >
{member.name} {displayMemberName(member.name)}
</span> </span>
)} )}
{roleLabel ? ( {roleLabel ? (

View file

@ -703,7 +703,7 @@ export const TaskDetailDialog = ({
) : null} ) : null}
{/* Sections container with uniform spacing */} {/* Sections container with uniform spacing */}
<div className="space-y-1"> <div className="min-w-0 space-y-1">
{/* Description */} {/* Description */}
<CollapsibleTeamSection <CollapsibleTeamSection
title="Description" title="Description"
@ -985,7 +985,7 @@ export const TaskDetailDialog = ({
</span> </span>
) : null ) : null
} }
contentClassName="pl-2.5" contentClassName="pl-2.5 overflow-visible"
headerClassName="-mx-6 w-[calc(100%+3rem)]" headerClassName="-mx-6 w-[calc(100%+3rem)]"
headerContentClassName="pl-6" headerContentClassName="pl-6"
defaultOpen={false} 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 { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { Crown, Filter } from 'lucide-react'; import { Crown, Filter } from 'lucide-react';
import { displayMemberName } from '@renderer/utils/memberHelpers';
import type { Session } from '@renderer/types/data'; import type { Session } from '@renderer/types/data';
import type { KanbanColumnId, ResolvedTeamMember } from '@shared/types'; import type { KanbanColumnId, ResolvedTeamMember } from '@shared/types';
@ -156,7 +158,7 @@ export const KanbanFilterPopover = ({
checked={filter.selectedOwners.has(member.name)} checked={filter.selectedOwners.has(member.name)}
onCheckedChange={() => handleOwnerToggle(member.name)} onCheckedChange={() => handleOwnerToggle(member.name)}
/> />
{member.name} {displayMemberName(member.name)}
</label> </label>
))} ))}
{/* eslint-disable-next-line jsx-a11y/label-has-associated-control -- Radix Checkbox renders a button, not a native input */} {/* 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 { formatAgentRole } from '@renderer/utils/formatAgentRole';
import { import {
agentAvatarUrl, agentAvatarUrl,
displayMemberName,
getSpawnAwareDotClass, getSpawnAwareDotClass,
getSpawnAwarePresenceLabel, getSpawnAwarePresenceLabel,
getSpawnCardClass, getSpawnCardClass,
@ -130,7 +131,7 @@ export const MemberCard = ({
/> />
</div> </div>
<div className="flex min-w-0 flex-1 items-center gap-1.5 truncate text-sm"> <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 ? ( {member.gitBranch ? (
<span className="flex shrink-0 items-center gap-0.5 text-[10px] text-[var(--color-text-muted)]"> <span className="flex shrink-0 items-center gap-0.5 text-[10px] text-[var(--color-text-muted)]">
<GitBranch size={10} /> <GitBranch size={10} />
@ -187,7 +188,7 @@ export const MemberCard = ({
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="bottom">{spawnError ?? 'Spawn failed'}</TooltipContent> <TooltipContent side="bottom">{spawnError ?? 'Spawn failed'}</TooltipContent>
</Tooltip> </Tooltip>
) : ( ) : !activityTask ? (
<Badge <Badge
variant="secondary" 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)]'}`} 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} {isRemoved ? 'removed' : presenceLabel}
</Badge> </Badge>
)} ) : null}
<div <div
className="shrink-0" className="shrink-0"
title={totalTasks > 0 ? `${completed}/${totalTasks} completed` : undefined} 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 { DialogDescription, DialogTitle } from '@renderer/components/ui/dialog';
import { getTeamColorSet } from '@renderer/constants/teamColors'; import { getTeamColorSet } from '@renderer/constants/teamColors';
import { formatAgentRole } from '@renderer/utils/formatAgentRole'; 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 { Pencil } from 'lucide-react';
import { MemberRoleEditor } from './MemberRoleEditor'; import { MemberRoleEditor } from './MemberRoleEditor';
@ -60,7 +65,7 @@ export const MemberDetailHeader = ({
</div> </div>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<DialogTitle className="truncate" style={{ color: colors.text }}> <DialogTitle className="truncate" style={{ color: colors.text }}>
{member.name} {displayMemberName(member.name)}
</DialogTitle> </DialogTitle>
<DialogDescription asChild className="mt-1 flex items-center gap-2"> <DialogDescription asChild className="mt-1 flex items-center gap-2">
<div> <div>

View file

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

View file

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

View file

@ -11,12 +11,15 @@ import { asEnhancedChunkArray } from '@renderer/types/data';
import { enhanceAIGroup } from '@renderer/utils/aiGroupEnhancer'; 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 { transformChunksToConversation } from '@renderer/utils/groupTransformer';
import { getMemberColorByName } from '@shared/constants/memberColors';
import { getTeamColorSet } from '@renderer/constants/teamColors';
import { import {
AlertCircle, AlertCircle,
ChevronDown, ChevronDown,
ChevronRight, ChevronRight,
Clock, Clock,
FileText, FileText,
Info,
Loader2, Loader2,
MessageSquare, MessageSquare,
} from 'lucide-react'; } from 'lucide-react';
@ -516,10 +519,22 @@ const LogCard = ({
detailLoading, detailLoading,
onToggle, onToggle,
}: LogCardProps): React.JSX.Element => { }: 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 ( 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> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<button <button
@ -531,15 +546,51 @@ const LogCard = ({
) : ( ) : (
<ChevronRight size={12} className="shrink-0 text-[var(--color-text-muted)]" /> <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="min-w-0 flex-1 overflow-hidden">
<div className="truncate text-[var(--color-text)]" title={log.description}> <div className="flex items-center gap-1.5">
{log.description} <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>
<div className="mt-0.5 flex items-center gap-3 text-[10px] text-[var(--color-text-muted)]"> <div className="mt-0.5 flex items-center gap-3 text-[10px] text-[var(--color-text-muted)]">
<span className="flex items-center gap-1"> {updatedAgo && updatedAgo !== createdAgo ? (
<Clock size={10} /> <>
{timeAgo} <span className="flex items-center gap-1">
</span> <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>} {log.durationMs > 0 && <span>{formatDuration(log.durationMs)}</span>}
<span className="flex items-center gap-1"> <span className="flex items-center gap-1">
<MessageSquare size={10} /> <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> <span className="rounded-full bg-green-500/20 px-1.5 text-green-400">active</span>
)} )}
</div> </div>
{log.lastOutputPreview && !expanded && (
<div className="mt-1 truncate text-[10px] text-[var(--color-text-muted)]" style={{ opacity: 0.6 }}>
{log.lastOutputPreview}
</div>
)}
</div> </div>
</button> </button>
</TooltipTrigger> </TooltipTrigger>

View file

@ -1,6 +1,7 @@
import { useState } from 'react'; import { useState } from 'react';
import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer'; import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer';
import { displayMemberName } from '@renderer/utils/memberHelpers';
import { format } from 'date-fns'; import { format } from 'date-fns';
import { ChevronDown, ChevronUp } from 'lucide-react'; 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-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="mb-2 flex items-center gap-2">
<div className="min-w-0 truncate text-[11px] text-[var(--color-text-muted)]"> <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> </div>
<div className={`${expandedAll ? 'max-h-none' : 'max-h-[200px]'} overflow-y-auto pr-1`}> <div className={`${expandedAll ? 'max-h-none' : 'max-h-[200px]'} overflow-y-auto pr-1`}>
{messages.map((m, index) => ( {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="flex items-start gap-2">
<div className="min-w-0 flex-1 text-xs text-[var(--color-text)]"> <div className="min-w-0 flex-1 text-xs text-[var(--color-text)]">
<MarkdownViewer <MarkdownViewer
@ -64,10 +68,6 @@ export const SubagentRecentMessagesPreview = ({
{format(m.timestamp, 'h:mm:ss a')} {format(m.timestamp, 'h:mm:ss a')}
</div> </div>
</div> </div>
{index < messages.length - 1 ? (
<hr className="mt-2 border-[var(--color-border)]" />
) : null}
</div> </div>
))} ))}

View file

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

View file

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

View file

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

View file

@ -1,80 +1,139 @@
/** /**
* Default color palette for team members. * Pre-ordered color palette for team members.
* Intentionally excludes purple-family tones for member UI. * 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 = [ export const MEMBER_COLOR_PALETTE = [
// ── Primary & classic ── // ── First 10: maximum contrast (>40° hue gap between any pair) ──
'blue', 'blue', // 0°
'green', 'saffron', // 177°
'yellow', 'turquoise', // 268°
'cyan', 'brick', // 85°
'red', 'apricot', // 131°
'orange', 'indigo', // 314°
'pink', 'forest', // 223°
'pink', // 39°
'crimson', // 59°
'tangerine', // 105°
// ── Red family ── // ── Next 14: still good separation ──
'rose', 'gold', // 151°
'coral', 'emerald', // 203°
'crimson', 'cerulean', // 288°
'scarlet', 'denim', // 334°
'tomato', 'cyan', // 20°
'salmon', 'sage', // 242°
'brick', 'tomato', // 72°
'ruby', 'rust', // 118°
'mustard', // 164°
'canary', // 190°
'teal', // 255°
'arctic', // 301°
'royal', // 347°
'green', // 7°
// ── Orange / warm family ── // ── Remaining: fill the hue gaps progressively ──
'amber', 'rose', // 46°
'tangerine', 'ruby', // 92°
'peach', 'sienna', // 144°
'rust', 'mint', // 216°
'copper', 'sky', // 275°
'apricot', 'sapphire', // 321°
'bronze', 'yellow', // 13°
'sienna', 'red', // 26°
'orange', // 33°
// ── Yellow / gold family ── 'coral', // 52°
'gold', 'scarlet', // 65°
'lemon', 'salmon', // 79°
'mustard', 'amber', // 98°
'honey', 'peach', // 111°
'saffron', 'copper', // 124°
'marigold', 'bronze', // 137°
'canary', 'lemon', // 157°
'sunflower', 'honey', // 170°
'marigold', // 183°
// ── Green family ── 'sunflower', // 196°
'emerald', 'lime', // 209°
'lime', 'olive', // 229°
'mint', 'jade', // 236°
'forest', 'chartreuse', // 249°
'olive', 'aqua', // 262°
'jade', 'azure', // 281°
'sage', 'seafoam', // 295°
'chartreuse', 'cobalt', // 308°
'periwinkle', // 327°
// ── Cyan / teal family ── 'steel', // 340°
'teal', 'cornflower', // 353°
'aqua',
'turquoise',
'sky',
'azure',
'cerulean',
'seafoam',
'arctic',
// ── Blue / indigo family ──
'cobalt',
'indigo',
'sapphire',
'periwinkle',
'denim',
'steel',
'royal',
'cornflower',
] as const; ] as const;
export type MemberColorName = (typeof MEMBER_COLOR_PALETTE)[number]; 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([ const DISALLOWED_MEMBER_COLORS = new Set([
'purple', 'purple',
'violet', 'violet',

View file

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