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:
parent
57931c0abd
commit
7e8f4b377d
7 changed files with 252 additions and 15 deletions
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
11
src/renderer/components/team/claudeLogsSourceMember.ts
Normal file
11
src/renderer/components/team/claudeLogsSourceMember.ts
Normal 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';
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
29
test/renderer/components/team/ClaudeLogsSection.test.ts
Normal file
29
test/renderer/components/team/ClaudeLogsSection.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
64
test/renderer/components/ui/MemberSelect.test.tsx
Normal file
64
test/renderer/components/ui/MemberSelect.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue