From 92dd8f445f51ec35fb3e1741d3a5598f9d392c08 Mon Sep 17 00:00:00 2001 From: iliya Date: Sat, 14 Mar 2026 21:25:01 +0200 Subject: [PATCH] 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. --- .../services/team/TeamMemberLogsFinder.ts | 86 +++++++- .../chat/viewers/MarkdownViewer.tsx | 44 ++++ src/renderer/components/team/MemberBadge.tsx | 4 +- .../team/activity/ActiveTasksBlock.tsx | 10 +- .../team/activity/PendingRepliesBlock.tsx | 10 +- .../team/dialogs/TaskDetailDialog.tsx | 4 +- .../team/kanban/KanbanFilterPopover.tsx | 4 +- .../components/team/members/MemberCard.tsx | 7 +- .../team/members/MemberDetailHeader.tsx | 9 +- .../team/members/MemberExecutionLog.tsx | 10 +- .../team/members/MemberHoverCard.tsx | 9 +- .../components/team/members/MemberLogsTab.tsx | 72 ++++++- .../members/SubagentRecentMessagesPreview.tsx | 12 +- .../team/review/ChangeReviewDialog.tsx | 3 +- src/renderer/constants/teamColors.ts | 23 ++- src/renderer/utils/memberHelpers.ts | 43 ++-- src/shared/constants/memberColors.ts | 195 ++++++++++++------ src/shared/types/team.ts | 2 + 18 files changed, 408 insertions(+), 139 deletions(-) diff --git a/src/main/services/team/TeamMemberLogsFinder.ts b/src/main/services/team/TeamMemberLogsFinder.ts index 885a4c65..d8147674 100644 --- a/src/main/services/team/TeamMemberLogsFinder.ts +++ b/src/main/services/team/TeamMemberLogsFinder.ts @@ -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(); + private readonly discoveryCache = new Map< + string, + { result: NonNullable>>; 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 { + async findMemberLogs( + teamName: string, + memberName: string, + mtimeSinceMs?: number | null + ): Promise { 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; } | 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 { 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 diff --git a/src/renderer/components/chat/viewers/MarkdownViewer.tsx b/src/renderer/components/chat/viewers/MarkdownViewer.tsx index 9ff1cce8..092f2301 100644 --- a/src/renderer/components/chat/viewers/MarkdownViewer.tsx +++ b/src/renderer/components/chat/viewers/MarkdownViewer.tsx @@ -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. ``, ``) + * 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. ``, ``), + * 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 = ({ rehypePlugins={disableHighlight ? REHYPE_PLUGINS_NO_HIGHLIGHT : REHYPE_PLUGINS} components={components} urlTransform={allowCustomProtocols} + allowElement={isAllowedElement} > {content} diff --git a/src/renderer/components/team/MemberBadge.tsx b/src/renderer/components/team/MemberBadge.tsx index 580763dd..3a6ea00f 100644 --- a/src/renderer/components/team/MemberBadge.tsx +++ b/src/renderer/components/team/MemberBadge.tsx @@ -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)} ); diff --git a/src/renderer/components/team/activity/ActiveTasksBlock.tsx b/src/renderer/components/team/activity/ActiveTasksBlock.tsx index 7783f322..3a897f86 100644 --- a/src/renderer/components/team/activity/ActiveTasksBlock.tsx +++ b/src/renderer/components/team/activity/ActiveTasksBlock.tsx @@ -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)} ) : ( - {member.name} + {displayMemberName(member.name)} )} {roleLabel ? ( diff --git a/src/renderer/components/team/activity/PendingRepliesBlock.tsx b/src/renderer/components/team/activity/PendingRepliesBlock.tsx index 9dccfcb8..9a0da204 100644 --- a/src/renderer/components/team/activity/PendingRepliesBlock.tsx +++ b/src/renderer/components/team/activity/PendingRepliesBlock.tsx @@ -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)} ) : ( - {member.name} + {displayMemberName(member.name)} )} {roleLabel ? ( diff --git a/src/renderer/components/team/dialogs/TaskDetailDialog.tsx b/src/renderer/components/team/dialogs/TaskDetailDialog.tsx index 91f272a4..01866cdc 100644 --- a/src/renderer/components/team/dialogs/TaskDetailDialog.tsx +++ b/src/renderer/components/team/dialogs/TaskDetailDialog.tsx @@ -703,7 +703,7 @@ export const TaskDetailDialog = ({ ) : null} {/* Sections container with uniform spacing */} -
+
{/* Description */} ) : null } - contentClassName="pl-2.5" + contentClassName="pl-2.5 overflow-visible" headerClassName="-mx-6 w-[calc(100%+3rem)]" headerContentClassName="pl-6" defaultOpen={false} diff --git a/src/renderer/components/team/kanban/KanbanFilterPopover.tsx b/src/renderer/components/team/kanban/KanbanFilterPopover.tsx index bd48b8b0..876d630a 100644 --- a/src/renderer/components/team/kanban/KanbanFilterPopover.tsx +++ b/src/renderer/components/team/kanban/KanbanFilterPopover.tsx @@ -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)} ))} {/* eslint-disable-next-line jsx-a11y/label-has-associated-control -- Radix Checkbox renders a button, not a native input */} diff --git a/src/renderer/components/team/members/MemberCard.tsx b/src/renderer/components/team/members/MemberCard.tsx index 6d9940b6..d0fd37e1 100644 --- a/src/renderer/components/team/members/MemberCard.tsx +++ b/src/renderer/components/team/members/MemberCard.tsx @@ -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 = ({ />
- {member.name} + {displayMemberName(member.name)} {member.gitBranch ? ( @@ -187,7 +188,7 @@ export const MemberCard = ({ {spawnError ?? 'Spawn failed'} - ) : ( + ) : !activityTask ? ( {isRemoved ? 'removed' : presenceLabel} - )} + ) : null}
0 ? `${completed}/${totalTasks} completed` : undefined} diff --git a/src/renderer/components/team/members/MemberDetailHeader.tsx b/src/renderer/components/team/members/MemberDetailHeader.tsx index 24bd84fd..02d36001 100644 --- a/src/renderer/components/team/members/MemberDetailHeader.tsx +++ b/src/renderer/components/team/members/MemberDetailHeader.tsx @@ -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 = ({
- {member.name} + {displayMemberName(member.name)}
diff --git a/src/renderer/components/team/members/MemberExecutionLog.tsx b/src/renderer/components/team/members/MemberExecutionLog.tsx index f83ec00b..40a498a5 100644 --- a/src/renderer/components/team/members/MemberExecutionLog.tsx +++ b/src/renderer/components/team/members/MemberExecutionLog.tsx @@ -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>(new Set()); @@ -34,7 +40,7 @@ export const MemberExecutionLog = ({ new Map() ); - if (!conversation.items.length) { + if (!orderedItems.length) { return (
Nothing to display @@ -44,7 +50,7 @@ export const MemberExecutionLog = ({ return (
- {conversation.items.map((item) => { + {orderedItems.map((item) => { if (item.type === 'system') { return ; } diff --git a/src/renderer/components/team/members/MemberHoverCard.tsx b/src/renderer/components/team/members/MemberHoverCard.tsx index 147ca0b2..35cf9c3c 100644 --- a/src/renderer/components/team/members/MemberHoverCard.tsx +++ b/src/renderer/components/team/members/MemberHoverCard.tsx @@ -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)} { - 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 ( -
+
diff --git a/src/renderer/components/team/members/SubagentRecentMessagesPreview.tsx b/src/renderer/components/team/members/SubagentRecentMessagesPreview.tsx index 504b5421..54bb3872 100644 --- a/src/renderer/components/team/members/SubagentRecentMessagesPreview.tsx +++ b/src/renderer/components/team/members/SubagentRecentMessagesPreview.tsx @@ -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 = ({
- Latest messages{memberName ? ` — ${memberName}` : ''} + Latest messages{memberName ? ` — ${displayMemberName(memberName)}` : ''}
{messages.map((m, index) => ( -
+
- - {index < messages.length - 1 ? ( -
- ) : null}
))} diff --git a/src/renderer/components/team/review/ChangeReviewDialog.tsx b/src/renderer/components/team/review/ChangeReviewDialog.tsx index 9faf437a..72f689fb 100644 --- a/src/renderer/components/team/review/ChangeReviewDialog.tsx +++ b/src/renderer/components/team/review/ChangeReviewDialog.tsx @@ -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; diff --git a/src/renderer/constants/teamColors.ts b/src/renderer/constants/teamColors.ts index dae4d898..6b55b3ac 100644 --- a/src/renderer/constants/teamColors.ts +++ b/src/renderer/constants/teamColors.ts @@ -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 { diff --git a/src/renderer/utils/memberHelpers.ts b/src/renderer/utils/memberHelpers.ts index 90e1320e..edca2bf8 100644 --- a/src/renderer/utils/memberHelpers.ts +++ b/src/renderer/utils/memberHelpers.ts @@ -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 name→colorName 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 { const map = new Map(); const active = members.filter((m) => !m.removedAt); const removed = members.filter((m) => m.removedAt); const usedColors = new Set(); + 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); diff --git a/src/shared/constants/memberColors.ts b/src/shared/constants/memberColors.ts index 4fb8c5d8..2406dcde 100644 --- a/src/shared/constants/memberColors.ts +++ b/src/shared/constants/memberColors.ts @@ -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 = { + 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', diff --git a/src/shared/types/team.ts b/src/shared/types/team.ts index bf73edb4..8942f9f2 100644 --- a/src/shared/types/team.ts +++ b/src/shared/types/team.ts @@ -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 {