feat(logs): add compact lead log source selector

- Add avatar trigger mode to MemberSelect for dense toolbar surfaces.

- Render the lead log source selector beside compact sidebar log search and filters.

- Cover toolbar accessory rendering, avatar trigger behavior and lead alias detection.
This commit is contained in:
777genius 2026-05-24 15:58:38 +03:00
parent 57931c0abd
commit 7e8f4b377d
7 changed files with 252 additions and 15 deletions

View file

@ -29,6 +29,7 @@ interface ClaudeLogsPanelProps {
/** Extra className for the panel wrapper. */
className?: string;
compactMetaInTooltip?: boolean;
toolbarAccessory?: React.ReactNode;
}
// =============================================================================
@ -41,6 +42,7 @@ export const ClaudeLogsPanel = ({
viewerMaxHeight,
className,
compactMetaInTooltip = false,
toolbarAccessory,
}: ClaudeLogsPanelProps): React.JSX.Element => {
const {
data,
@ -86,10 +88,20 @@ export const ClaudeLogsPanel = ({
'Team is not running.'
)}
</span>
<div className="flex items-center gap-2">
<div
className={cn(
'flex items-center gap-2',
compactMetaInTooltip && 'min-w-0 flex-1 justify-end'
)}
>
{data.total > 0 ? (
<>
<div className="flex w-48 items-center gap-1.5 rounded-md border border-[var(--color-border)] bg-transparent px-2 py-1">
<div
className={cn(
'flex items-center gap-1.5 rounded-md border border-[var(--color-border)] bg-transparent px-2 py-1',
compactMetaInTooltip ? 'min-w-0 max-w-48 flex-1' : 'w-48'
)}
>
<Search size={12} className="shrink-0 text-[var(--color-text-muted)]" />
<input
type="text"
@ -109,6 +121,7 @@ export const ClaudeLogsPanel = ({
</button>
)}
</div>
{toolbarAccessory}
<ClaudeLogsFilterPopover
filter={filter}
open={filterOpen}

View file

@ -1,12 +1,16 @@
import { memo, useMemo, useState } from 'react';
import { Button } from '@renderer/components/ui/button';
import { MemberSelect } from '@renderer/components/ui/MemberSelect';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { cn } from '@renderer/lib/utils';
import { useStore } from '@renderer/store';
import { selectResolvedMembersForTeamName } from '@renderer/store/slices/teamSlice';
import { Brain, Expand, MessageSquare, Wrench } from 'lucide-react';
import { ClaudeLogsDialog } from './ClaudeLogsDialog';
import { ClaudeLogsPanel } from './ClaudeLogsPanel';
import { isLeadLogSourceMember } from './claudeLogsSourceMember';
import { CollapsibleTeamSection } from './CollapsibleTeamSection';
import { useClaudeLogsController } from './useClaudeLogsController';
@ -89,10 +93,26 @@ export const ClaudeLogsSection = memo(function ClaudeLogsSection({
onOpenChange,
}: ClaudeLogsSectionProps): React.JSX.Element {
const ctrl = useClaudeLogsController(teamName);
const resolvedMembers = useStore((s) => selectResolvedMembersForTeamName(s, teamName));
const [dialogOpen, setDialogOpen] = useState(false);
const isSidebar = position === 'sidebar';
const showHeaderSkeleton = ctrl.loading && ctrl.data.lines.length === 0 && !ctrl.error;
const leadLogMember = useMemo(
() => resolvedMembers.find((member) => !member.removedAt && isLeadLogSourceMember(member)),
[resolvedMembers]
);
const sidebarLogSourceSelect =
isSidebar && leadLogMember ? (
<MemberSelect
members={[leadLogMember]}
value={leadLogMember.name}
onChange={() => undefined}
size="sm"
triggerVariant="avatar"
popoverAlign="end"
/>
) : null;
const sectionHeaderExtra = useMemo(
() => (
@ -173,6 +193,7 @@ export const ClaudeLogsSection = memo(function ClaudeLogsSection({
viewerClassName={cn('max-h-[213px]', isSidebar && 'cli-logs-sidebar')}
viewerMaxHeight={isSidebar ? sidebarViewerMaxHeight : undefined}
compactMetaInTooltip={isSidebar}
toolbarAccessory={sidebarLogSourceSelect}
/>
)}
</CollapsibleTeamSection>

View file

@ -0,0 +1,11 @@
import { isLeadMember } from '@shared/utils/leadDetection';
import type { ResolvedTeamMember } from '@shared/types';
export function isLeadLogSourceMember(member: ResolvedTeamMember): boolean {
if (isLeadMember(member)) return true;
const normalizedName = member.name.trim().toLowerCase();
if (normalizedName === 'lead') return true;
const normalizedRole = member.role?.trim().toLowerCase();
return normalizedRole === 'lead' || normalizedRole === 'team lead';
}

View file

@ -8,9 +8,10 @@ import {
agentAvatarUrl,
buildMemberAvatarMap,
buildMemberColorMap,
displayMemberName,
} from '@renderer/utils/memberHelpers';
import { Command as CommandPrimitive } from 'cmdk';
import { Check, ChevronsUpDown } from 'lucide-react';
import { Check, ChevronsUpDown, UserRound } from 'lucide-react';
import { Popover, PopoverContent, PopoverTrigger } from './popover';
@ -25,6 +26,9 @@ interface MemberSelectProps {
allowUnassigned?: boolean;
/** Size variant */
size?: 'sm' | 'md';
/** Full select by default. Avatar mode is for dense toolbars/sidebar surfaces. */
triggerVariant?: 'default' | 'avatar';
popoverAlign?: 'start' | 'center' | 'end';
disabled?: boolean;
className?: string;
}
@ -38,6 +42,8 @@ export const MemberSelect = ({
placeholder = 'Select member...',
allowUnassigned = false,
size = 'sm',
triggerVariant = 'default',
popoverAlign,
disabled = false,
className,
}: MemberSelectProps): React.JSX.Element => {
@ -57,6 +63,28 @@ export const MemberSelect = ({
const avatarClass = size === 'md' ? 'size-6' : 'size-5';
const textSize = size === 'md' ? 'text-xs' : 'text-[10px]';
const triggerHeight = size === 'md' ? 'h-9' : 'h-8';
const isAvatarTrigger = triggerVariant === 'avatar';
const effectivePopoverAlign = popoverAlign ?? (isAvatarTrigger ? 'end' : 'start');
const avatarTriggerSize = size === 'md' ? 'size-9' : 'size-8';
const selectedLabel =
selectedMember != null
? displayMemberName(selectedMember.name)
: value
? displayMemberName(value)
: allowUnassigned
? 'Unassigned'
: placeholder;
const renderAvatarByName = (name: string): React.ReactNode => (
<img
src={avatarMap.get(name) ?? agentAvatarUrl(name, avatarSize)}
alt=""
className={`${avatarClass} shrink-0 rounded-full bg-[var(--color-surface-raised)]`}
loading="lazy"
/>
);
const renderMemberAvatar = (member: ResolvedTeamMember): React.ReactNode =>
renderAvatarByName(member.name);
// eslint-disable-next-line sonarjs/function-return-type -- option renderer returns mixed node structure
const renderMemberInline = (member: ResolvedTeamMember): React.ReactNode => {
@ -90,29 +118,48 @@ export const MemberSelect = ({
<button
type="button"
role="combobox"
aria-haspopup="listbox"
aria-expanded={open}
aria-controls={listboxId}
aria-label={`Select member: ${selectedLabel}`}
title={isAvatarTrigger ? selectedLabel : undefined}
disabled={disabled}
className={cn(
`flex ${triggerHeight} w-full items-center justify-between rounded-md border border-[var(--color-border)] bg-transparent px-2 py-1 text-xs shadow-sm transition-colors placeholder:text-[var(--color-text-muted)] focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-[var(--color-border-emphasis)] disabled:cursor-not-allowed disabled:opacity-50`,
isAvatarTrigger
? `inline-flex ${avatarTriggerSize} shrink-0 items-center justify-center rounded-md border border-[var(--color-border)] bg-transparent p-0 text-xs shadow-sm transition-colors hover:bg-[var(--color-surface-raised)] focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-[var(--color-border-emphasis)] disabled:cursor-not-allowed disabled:opacity-50`
: `flex ${triggerHeight} w-full items-center justify-between rounded-md border border-[var(--color-border)] bg-transparent px-2 py-1 text-xs shadow-sm transition-colors placeholder:text-[var(--color-text-muted)] focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-[var(--color-border-emphasis)] disabled:cursor-not-allowed disabled:opacity-50`,
className
)}
>
<span className="min-w-0 truncate text-left">
{selectedMember ? (
renderMemberInline(selectedMember)
) : value === null && allowUnassigned ? (
<span className="text-xs text-[var(--color-text-muted)]">Unassigned</span>
{isAvatarTrigger ? (
selectedMember ? (
renderMemberAvatar(selectedMember)
) : value ? (
renderAvatarByName(value)
) : (
<span className="text-[var(--color-text-muted)]">{placeholder}</span>
)}
</span>
<ChevronsUpDown className="ml-2 size-3.5 shrink-0 opacity-50" />
<UserRound className="size-4 text-[var(--color-text-muted)]" />
)
) : (
<>
<span className="min-w-0 truncate text-left">
{selectedMember ? (
renderMemberInline(selectedMember)
) : value === null && allowUnassigned ? (
<span className="text-xs text-[var(--color-text-muted)]">Unassigned</span>
) : (
<span className="text-[var(--color-text-muted)]">{placeholder}</span>
)}
</span>
<ChevronsUpDown className="ml-2 size-3.5 shrink-0 opacity-50" />
</>
)}
</button>
</PopoverTrigger>
<PopoverContent
className="w-[var(--radix-popover-trigger-width)] min-w-[200px] p-0"
align="start"
className={cn(
isAvatarTrigger ? 'w-56 p-0' : 'w-[var(--radix-popover-trigger-width)] min-w-[200px] p-0'
)}
align={effectivePopoverAlign}
sideOffset={4}
collisionPadding={8}
avoidCollisions

View file

@ -1,5 +1,6 @@
import React, { act } from 'react';
import { createRoot } from 'react-dom/client';
import { afterEach, describe, expect, it, vi } from 'vitest';
import type { ClaudeLogsController } from '@renderer/components/team/useClaudeLogsController';
@ -165,4 +166,55 @@ describe('ClaudeLogsPanel', () => {
await Promise.resolve();
});
});
it('renders toolbar accessory beside log search and filters', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
const ctrl = createController({
isAlive: true,
data: {
lines: ['[stdout] ready'],
total: 1,
hasMore: false,
},
filteredText: '[stdout]\nready',
});
await act(async () => {
root.render(
React.createElement(ClaudeLogsPanel, {
ctrl,
compactMetaInTooltip: true,
toolbarAccessory: React.createElement(
'button',
{ type: 'button', 'data-testid': 'log-member-selector' },
'Lead'
),
})
);
await Promise.resolve();
});
const search = host.querySelector('input[placeholder="Search logs..."]');
const accessory = host.querySelector('[data-testid="log-member-selector"]');
const filter = host.querySelector('[data-testid="logs-filter"]');
expect(search).not.toBeNull();
expect(accessory).not.toBeNull();
expect(filter).not.toBeNull();
expect(search?.parentElement?.className).toContain('flex-1');
expect(search?.compareDocumentPosition(accessory as Node) ?? 0).toBe(
Node.DOCUMENT_POSITION_FOLLOWING
);
expect(accessory?.compareDocumentPosition(filter as Node) ?? 0).toBe(
Node.DOCUMENT_POSITION_FOLLOWING
);
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
});

View file

@ -0,0 +1,29 @@
import { isLeadLogSourceMember } from '@renderer/components/team/claudeLogsSourceMember';
import { describe, expect, it } from 'vitest';
import type { ResolvedTeamMember } from '@shared/types';
function member(overrides: Partial<ResolvedTeamMember>): ResolvedTeamMember {
return {
name: 'alice',
status: 'active',
currentTaskId: null,
taskCount: 0,
lastActiveAt: null,
messageCount: 0,
...overrides,
};
}
describe('isLeadLogSourceMember', () => {
it('accepts canonical and cached lead aliases for compact log source UI', () => {
expect(isLeadLogSourceMember(member({ name: 'team-lead' }))).toBe(true);
expect(isLeadLogSourceMember(member({ name: 'Lead' }))).toBe(true);
expect(isLeadLogSourceMember(member({ name: 'current', role: 'Team Lead' }))).toBe(true);
});
it('does not treat arbitrary leadership-like roles as the lead log source', () => {
expect(isLeadLogSourceMember(member({ name: 'alice', role: 'Tech Lead' }))).toBe(false);
expect(isLeadLogSourceMember(member({ name: 'lead-reviewer' }))).toBe(false);
});
});

View file

@ -0,0 +1,64 @@
import React, { act } from 'react';
import { createRoot } from 'react-dom/client';
import { MemberSelect } from '@renderer/components/ui/MemberSelect';
import { afterEach, describe, expect, it, vi } from 'vitest';
import type { ResolvedTeamMember } from '@shared/types';
function member(name: string): ResolvedTeamMember {
return {
name,
status: 'active',
currentTaskId: null,
taskCount: 0,
lastActiveAt: null,
messageCount: 0,
};
}
describe('MemberSelect', () => {
afterEach(() => {
document.body.innerHTML = '';
vi.unstubAllGlobals();
});
it('uses an avatar trigger for dense surfaces while keeping the full member list popover', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
const onChange = vi.fn();
await act(async () => {
root.render(
<MemberSelect
members={[member('Lead'), member('Alice')]}
value="Lead"
onChange={onChange}
triggerVariant="avatar"
/>
);
await Promise.resolve();
});
const trigger = host.querySelector('button[role="combobox"]') as HTMLButtonElement | null;
expect(trigger).not.toBeNull();
expect(trigger?.getAttribute('aria-label')).toBe('Select member: Lead');
expect(trigger?.getAttribute('title')).toBe('Lead');
expect(host.textContent).not.toContain('Lead');
await act(async () => {
trigger?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
await Promise.resolve();
});
expect(document.body.textContent).toContain('Lead');
expect(document.body.textContent).toContain('Alice');
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
});