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. */ /** Extra className for the panel wrapper. */
className?: string; className?: string;
compactMetaInTooltip?: boolean; compactMetaInTooltip?: boolean;
toolbarAccessory?: React.ReactNode;
} }
// ============================================================================= // =============================================================================
@ -41,6 +42,7 @@ export const ClaudeLogsPanel = ({
viewerMaxHeight, viewerMaxHeight,
className, className,
compactMetaInTooltip = false, compactMetaInTooltip = false,
toolbarAccessory,
}: ClaudeLogsPanelProps): React.JSX.Element => { }: ClaudeLogsPanelProps): React.JSX.Element => {
const { const {
data, data,
@ -86,10 +88,20 @@ export const ClaudeLogsPanel = ({
'Team is not running.' 'Team is not running.'
)} )}
</span> </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 ? ( {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)]" /> <Search size={12} className="shrink-0 text-[var(--color-text-muted)]" />
<input <input
type="text" type="text"
@ -109,6 +121,7 @@ export const ClaudeLogsPanel = ({
</button> </button>
)} )}
</div> </div>
{toolbarAccessory}
<ClaudeLogsFilterPopover <ClaudeLogsFilterPopover
filter={filter} filter={filter}
open={filterOpen} open={filterOpen}

View file

@ -1,12 +1,16 @@
import { memo, useMemo, useState } from 'react'; import { memo, useMemo, useState } from 'react';
import { Button } from '@renderer/components/ui/button'; import { Button } from '@renderer/components/ui/button';
import { MemberSelect } from '@renderer/components/ui/MemberSelect';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { cn } from '@renderer/lib/utils'; 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 { Brain, Expand, MessageSquare, Wrench } from 'lucide-react';
import { ClaudeLogsDialog } from './ClaudeLogsDialog'; import { ClaudeLogsDialog } from './ClaudeLogsDialog';
import { ClaudeLogsPanel } from './ClaudeLogsPanel'; import { ClaudeLogsPanel } from './ClaudeLogsPanel';
import { isLeadLogSourceMember } from './claudeLogsSourceMember';
import { CollapsibleTeamSection } from './CollapsibleTeamSection'; import { CollapsibleTeamSection } from './CollapsibleTeamSection';
import { useClaudeLogsController } from './useClaudeLogsController'; import { useClaudeLogsController } from './useClaudeLogsController';
@ -89,10 +93,26 @@ export const ClaudeLogsSection = memo(function ClaudeLogsSection({
onOpenChange, onOpenChange,
}: ClaudeLogsSectionProps): React.JSX.Element { }: ClaudeLogsSectionProps): React.JSX.Element {
const ctrl = useClaudeLogsController(teamName); const ctrl = useClaudeLogsController(teamName);
const resolvedMembers = useStore((s) => selectResolvedMembersForTeamName(s, teamName));
const [dialogOpen, setDialogOpen] = useState(false); const [dialogOpen, setDialogOpen] = useState(false);
const isSidebar = position === 'sidebar'; const isSidebar = position === 'sidebar';
const showHeaderSkeleton = ctrl.loading && ctrl.data.lines.length === 0 && !ctrl.error; 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( const sectionHeaderExtra = useMemo(
() => ( () => (
@ -173,6 +193,7 @@ export const ClaudeLogsSection = memo(function ClaudeLogsSection({
viewerClassName={cn('max-h-[213px]', isSidebar && 'cli-logs-sidebar')} viewerClassName={cn('max-h-[213px]', isSidebar && 'cli-logs-sidebar')}
viewerMaxHeight={isSidebar ? sidebarViewerMaxHeight : undefined} viewerMaxHeight={isSidebar ? sidebarViewerMaxHeight : undefined}
compactMetaInTooltip={isSidebar} compactMetaInTooltip={isSidebar}
toolbarAccessory={sidebarLogSourceSelect}
/> />
)} )}
</CollapsibleTeamSection> </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, agentAvatarUrl,
buildMemberAvatarMap, buildMemberAvatarMap,
buildMemberColorMap, buildMemberColorMap,
displayMemberName,
} from '@renderer/utils/memberHelpers'; } from '@renderer/utils/memberHelpers';
import { Command as CommandPrimitive } from 'cmdk'; 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'; import { Popover, PopoverContent, PopoverTrigger } from './popover';
@ -25,6 +26,9 @@ interface MemberSelectProps {
allowUnassigned?: boolean; allowUnassigned?: boolean;
/** Size variant */ /** Size variant */
size?: 'sm' | 'md'; size?: 'sm' | 'md';
/** Full select by default. Avatar mode is for dense toolbars/sidebar surfaces. */
triggerVariant?: 'default' | 'avatar';
popoverAlign?: 'start' | 'center' | 'end';
disabled?: boolean; disabled?: boolean;
className?: string; className?: string;
} }
@ -38,6 +42,8 @@ export const MemberSelect = ({
placeholder = 'Select member...', placeholder = 'Select member...',
allowUnassigned = false, allowUnassigned = false,
size = 'sm', size = 'sm',
triggerVariant = 'default',
popoverAlign,
disabled = false, disabled = false,
className, className,
}: MemberSelectProps): React.JSX.Element => { }: MemberSelectProps): React.JSX.Element => {
@ -57,6 +63,28 @@ export const MemberSelect = ({
const avatarClass = size === 'md' ? 'size-6' : 'size-5'; const avatarClass = size === 'md' ? 'size-6' : 'size-5';
const textSize = size === 'md' ? 'text-xs' : 'text-[10px]'; const textSize = size === 'md' ? 'text-xs' : 'text-[10px]';
const triggerHeight = size === 'md' ? 'h-9' : 'h-8'; 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 // eslint-disable-next-line sonarjs/function-return-type -- option renderer returns mixed node structure
const renderMemberInline = (member: ResolvedTeamMember): React.ReactNode => { const renderMemberInline = (member: ResolvedTeamMember): React.ReactNode => {
@ -90,29 +118,48 @@ export const MemberSelect = ({
<button <button
type="button" type="button"
role="combobox" role="combobox"
aria-haspopup="listbox"
aria-expanded={open} aria-expanded={open}
aria-controls={listboxId} aria-controls={listboxId}
aria-label={`Select member: ${selectedLabel}`}
title={isAvatarTrigger ? selectedLabel : undefined}
disabled={disabled} disabled={disabled}
className={cn( 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 className
)} )}
> >
<span className="min-w-0 truncate text-left"> {isAvatarTrigger ? (
{selectedMember ? ( selectedMember ? (
renderMemberInline(selectedMember) renderMemberAvatar(selectedMember)
) : value === null && allowUnassigned ? ( ) : value ? (
<span className="text-xs text-[var(--color-text-muted)]">Unassigned</span> renderAvatarByName(value)
) : ( ) : (
<span className="text-[var(--color-text-muted)]">{placeholder}</span> <UserRound className="size-4 text-[var(--color-text-muted)]" />
)} )
</span> ) : (
<ChevronsUpDown className="ml-2 size-3.5 shrink-0 opacity-50" /> <>
<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> </button>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent <PopoverContent
className="w-[var(--radix-popover-trigger-width)] min-w-[200px] p-0" className={cn(
align="start" isAvatarTrigger ? 'w-56 p-0' : 'w-[var(--radix-popover-trigger-width)] min-w-[200px] p-0'
)}
align={effectivePopoverAlign}
sideOffset={4} sideOffset={4}
collisionPadding={8} collisionPadding={8}
avoidCollisions avoidCollisions

View file

@ -1,5 +1,6 @@
import React, { act } from 'react'; import React, { act } from 'react';
import { createRoot } from 'react-dom/client'; import { createRoot } from 'react-dom/client';
import { afterEach, describe, expect, it, vi } from 'vitest'; import { afterEach, describe, expect, it, vi } from 'vitest';
import type { ClaudeLogsController } from '@renderer/components/team/useClaudeLogsController'; import type { ClaudeLogsController } from '@renderer/components/team/useClaudeLogsController';
@ -165,4 +166,55 @@ describe('ClaudeLogsPanel', () => {
await Promise.resolve(); 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();
});
});
});