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:
parent
85fa86f1be
commit
92dd8f445f
18 changed files with 408 additions and 139 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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 ? (
|
||||
|
|
|
|||
|
|
@ -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 ? (
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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 */}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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} />;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
))}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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<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);
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Reference in a new issue