diff --git a/src/renderer/components/team/ClaudeLogsPanel.tsx b/src/renderer/components/team/ClaudeLogsPanel.tsx index 05b5784c..4325808f 100644 --- a/src/renderer/components/team/ClaudeLogsPanel.tsx +++ b/src/renderer/components/team/ClaudeLogsPanel.tsx @@ -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.' )} -
+
{data.total > 0 ? ( <> -
+
)}
+ {toolbarAccessory} 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 ? ( + 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} /> )} diff --git a/src/renderer/components/team/claudeLogsSourceMember.ts b/src/renderer/components/team/claudeLogsSourceMember.ts new file mode 100644 index 00000000..2cf41b7d --- /dev/null +++ b/src/renderer/components/team/claudeLogsSourceMember.ts @@ -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'; +} diff --git a/src/renderer/components/ui/MemberSelect.tsx b/src/renderer/components/ui/MemberSelect.tsx index 0f80cccf..ec79b656 100644 --- a/src/renderer/components/ui/MemberSelect.tsx +++ b/src/renderer/components/ui/MemberSelect.tsx @@ -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 => ( + + ); + 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 = ({ { 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(); + }); + }); }); diff --git a/test/renderer/components/team/ClaudeLogsSection.test.ts b/test/renderer/components/team/ClaudeLogsSection.test.ts new file mode 100644 index 00000000..e4683640 --- /dev/null +++ b/test/renderer/components/team/ClaudeLogsSection.test.ts @@ -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 { + 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); + }); +}); diff --git a/test/renderer/components/ui/MemberSelect.test.tsx b/test/renderer/components/ui/MemberSelect.test.tsx new file mode 100644 index 00000000..b9083e87 --- /dev/null +++ b/test/renderer/components/ui/MemberSelect.test.tsx @@ -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( + + ); + 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(); + }); + }); +});