feat: add team log member filtering

This commit is contained in:
777genius 2026-05-24 15:40:31 +03:00
parent 57c209a828
commit 3a7f9ea10b
19 changed files with 2185 additions and 146 deletions

View file

@ -15,7 +15,7 @@ const SECTIONS: readonly { id: string; label: string; icon: LucideIcon }[] = [
{ id: 'team', label: 'Team', icon: Users },
{ id: 'sessions', label: 'Sessions', icon: History },
{ id: 'kanban', label: 'Kanban', icon: Columns3 },
{ id: 'claude-logs', label: 'Claude Logs', icon: Terminal },
{ id: 'claude-logs', label: 'Logs', icon: Terminal },
{ id: 'messages', label: 'Messages', icon: MessageSquare },
];

View file

@ -1,71 +0,0 @@
/**
* ClaudeLogsDialog
*
* Fullscreen-style dialog for viewing Claude logs in a large viewport.
* Uses the same ClaudeLogsPanel as the compact sidebar but with more space.
* Only one CliLogsRichView is mounted at a time when this dialog is open,
* the compact panel hides its log viewer.
*/
import React from 'react';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@renderer/components/ui/dialog';
import { Terminal } from 'lucide-react';
import { ClaudeLogsPanel } from './ClaudeLogsPanel';
import type { ClaudeLogsController } from './useClaudeLogsController';
// =============================================================================
// Props
// =============================================================================
interface ClaudeLogsDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
ctrl: ClaudeLogsController;
}
// =============================================================================
// Component
// =============================================================================
export const ClaudeLogsDialog = ({
open,
onOpenChange,
ctrl,
}: ClaudeLogsDialogProps): React.JSX.Element => {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="flex h-[90vh] w-[80vw] max-w-none flex-col overflow-hidden">
<DialogHeader className="shrink-0">
<DialogTitle className="flex items-center gap-2 text-sm">
<span className="inline-flex size-5 items-center justify-center rounded-md border border-[var(--color-border)] bg-[var(--color-bg-secondary)] text-[var(--color-text-secondary)] shadow-sm">
<Terminal size={12} />
</span>
Claude logs
{ctrl.badge != null && (
<span className="font-mono text-[11px] text-[var(--color-text-muted)]">
({ctrl.badge})
</span>
)}
{ctrl.online && (
<span className="pointer-events-none relative inline-flex size-2">
<span className="absolute inline-flex size-full animate-ping rounded-full bg-emerald-400 opacity-50" />
<span className="relative inline-flex size-2 rounded-full bg-emerald-400" />
</span>
)}
</DialogTitle>
</DialogHeader>
<div className="min-h-0 flex-1 overflow-hidden">
<ClaudeLogsPanel
ctrl={ctrl}
viewerClassName="max-h-full h-full"
className="flex h-full flex-col [&>div:last-child]:min-h-0 [&>div:last-child]:flex-1"
/>
</div>
</DialogContent>
</Dialog>
);
};

View file

@ -6,18 +6,12 @@ import { Popover, PopoverContent, PopoverTrigger } from '@renderer/components/ui
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { Filter } from 'lucide-react';
export type ClaudeLogStream = 'stdout' | 'stderr';
export type ClaudeLogKind = 'output' | 'thinking' | 'tool';
export interface ClaudeLogsFilterState {
streams: Set<ClaudeLogStream>;
kinds: Set<ClaudeLogKind>;
}
export const DEFAULT_CLAUDE_LOGS_FILTER: ClaudeLogsFilterState = {
streams: new Set<ClaudeLogStream>(['stdout', 'stderr']),
kinds: new Set<ClaudeLogKind>(['output', 'thinking', 'tool']),
};
import {
type ClaudeLogKind,
type ClaudeLogsFilterState,
type ClaudeLogStream,
DEFAULT_CLAUDE_LOGS_FILTER,
} from './claudeLogsFilterState';
function setEquals<T>(a: Set<T>, b: Set<T>): boolean {
if (a.size !== b.size) return false;
@ -108,7 +102,7 @@ export const ClaudeLogsFilterPopover = ({
variant="ghost"
size="sm"
className="relative h-7 px-2 text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
aria-label="Filter Claude logs"
aria-label="Filter logs"
>
<Filter size={14} />
{activeCount > 0 && (

View file

@ -29,6 +29,7 @@ interface ClaudeLogsPanelProps {
/** Extra className for the panel wrapper. */
className?: string;
compactMetaInTooltip?: boolean;
toolbarControlsStart?: React.ReactNode;
}
// =============================================================================
@ -41,6 +42,7 @@ export const ClaudeLogsPanel = ({
viewerMaxHeight,
className,
compactMetaInTooltip = false,
toolbarControlsStart,
}: ClaudeLogsPanelProps): React.JSX.Element => {
const {
data,
@ -86,7 +88,10 @@ export const ClaudeLogsPanel = ({
'Team is not running.'
)}
</span>
<div className="flex items-center gap-2">
<div className="flex min-w-0 items-center justify-end gap-2">
{toolbarControlsStart ? (
<div className="min-w-0 shrink-0">{toolbarControlsStart}</div>
) : null}
{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">

View file

@ -1,16 +1,33 @@
import { memo, useMemo, useState } from 'react';
import { memo, useCallback, useEffect, useMemo, useState } from 'react';
import { Button } from '@renderer/components/ui/button';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@renderer/components/ui/dialog';
import { MemberSelect } from '@renderer/components/ui/MemberSelect';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { cn } from '@renderer/lib/utils';
import { Brain, Expand, MessageSquare, Wrench } from 'lucide-react';
import { useStore } from '@renderer/store';
import { selectResolvedMembersForTeamName } from '@renderer/store/slices/teamSlice';
import { isLeadMember } from '@shared/utils/leadDetection';
import { Brain, Expand, MessageSquare, Terminal, Wrench } from 'lucide-react';
import { ClaudeLogsDialog } from './ClaudeLogsDialog';
import { MemberLogStreamWithLegacyFallback } from './members/MemberLogStreamWithLegacyFallback';
import { ClaudeLogsPanel } from './ClaudeLogsPanel';
import { CollapsibleTeamSection } from './CollapsibleTeamSection';
import {
buildSelectableLogMembers,
formatMemberLogSourceDescription,
formatMemberLogSourceLabel,
getMemberNameFromLogSourceKey,
LEAD_LOG_SOURCE_KEY,
memberLogSourceKey,
normalizeMemberLogSourceName,
resolveLeadLogMember,
} from './teamLogSources';
import { useClaudeLogsController } from './useClaudeLogsController';
import type { TeamLogSourceKey } from './teamLogSources';
import type { LastLogPreview } from './useClaudeLogsController';
import type { ResolvedTeamMember } from '@shared/types';
// =============================================================================
// Constants
@ -78,6 +95,177 @@ const LogPreviewInline = ({ preview }: { preview: LastLogPreview }): React.JSX.E
);
};
const TeamLogsSourceSelector = ({
leadMember,
members,
selectedKey,
onChange,
className,
}: {
leadMember: ResolvedTeamMember;
members: readonly ResolvedTeamMember[];
selectedKey: TeamLogSourceKey;
onChange: (key: TeamLogSourceKey) => void;
className?: string;
}): React.JSX.Element | null => {
const sourceMembers = useMemo(() => [leadMember, ...members], [leadMember, members]);
const selectedMemberName =
selectedKey === LEAD_LOG_SOURCE_KEY
? leadMember.name
: getMemberNameFromLogSourceKey(selectedKey);
if (sourceMembers.length <= 1) return null;
return (
<div className={cn('min-w-0 pb-2', className)}>
<MemberSelect
members={sourceMembers}
value={selectedMemberName}
onChange={(memberName) => {
const selectedMember = sourceMembers.find((member) => member.name === memberName);
if (!selectedMember || isLeadMember(selectedMember)) {
onChange(LEAD_LOG_SOURCE_KEY);
return;
}
onChange(memberLogSourceKey(selectedMember.name));
}}
placeholder="Select log source..."
searchPlaceholder="Search log sources..."
emptyMessage="No log sources found."
ariaLabel="Log source"
getMemberLabel={(member) =>
isLeadMember(member) ? 'Lead' : formatMemberLogSourceLabel(member)
}
getMemberDescription={formatMemberLogSourceDescription}
/>
</div>
);
};
const MemberSourcePill = ({ member }: { member: ResolvedTeamMember }): React.JSX.Element => (
<span
className="min-w-0 truncate rounded-md border border-[var(--color-border)] px-1.5 py-0.5 text-[10px] text-[var(--color-text-muted)]"
title={formatMemberLogSourceLabel(member)}
>
{formatMemberLogSourceLabel(member)}
</span>
);
const MemberLogsSourcePanel = ({
teamName,
member,
enabled,
maxHeight,
}: {
teamName: string;
member: ResolvedTeamMember;
enabled: boolean;
maxHeight?: number;
}): React.JSX.Element => {
const content = (
<MemberLogStreamWithLegacyFallback teamName={teamName} member={member} enabled={enabled} />
);
if (maxHeight === undefined) {
return content;
}
return (
<div className="min-h-0 overflow-auto pr-1" style={{ maxHeight }}>
{content}
</div>
);
};
const TeamLogsDialog = ({
open,
onOpenChange,
teamName,
leadMember,
members,
selectedKey,
onSourceChange,
showingLeadLogs,
ctrl,
selectedMember,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
teamName: string;
leadMember: ResolvedTeamMember;
members: readonly ResolvedTeamMember[];
selectedKey: TeamLogSourceKey;
onSourceChange: (key: TeamLogSourceKey) => void;
showingLeadLogs: boolean;
ctrl: ReturnType<typeof useClaudeLogsController>;
selectedMember: ResolvedTeamMember | null;
}): React.JSX.Element => {
const sourceSelector =
members.length > 0 ? (
<TeamLogsSourceSelector
leadMember={leadMember}
members={members}
selectedKey={selectedKey}
onChange={onSourceChange}
className="w-64 max-w-[min(18rem,40vw)] shrink-0 pb-0"
/>
) : null;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="flex h-[90vh] w-[80vw] max-w-none flex-col overflow-hidden">
<DialogHeader className="shrink-0">
<DialogTitle className="flex items-center gap-2 text-sm">
<span className="inline-flex size-5 items-center justify-center rounded-md border border-[var(--color-border)] bg-[var(--color-bg-secondary)] text-[var(--color-text-secondary)] shadow-sm">
<Terminal size={12} />
</span>
Logs
{showingLeadLogs && ctrl.badge != null ? (
<span className="font-mono text-[11px] text-[var(--color-text-muted)]">
({ctrl.badge})
</span>
) : null}
{showingLeadLogs && ctrl.online ? (
<span className="pointer-events-none relative inline-flex size-2">
<span className="absolute inline-flex size-full animate-ping rounded-full bg-emerald-400 opacity-50" />
<span className="relative inline-flex size-2 rounded-full bg-emerald-400" />
</span>
) : null}
</DialogTitle>
</DialogHeader>
<div
className={cn('min-h-0 flex-1', showingLeadLogs ? 'overflow-hidden' : 'overflow-auto')}
>
{showingLeadLogs ? (
<ClaudeLogsPanel
ctrl={ctrl}
viewerClassName="max-h-full h-full"
className="flex h-full flex-col [&>div:last-child]:min-h-0 [&>div:last-child]:flex-1"
toolbarControlsStart={sourceSelector}
/>
) : selectedMember ? (
<>
{sourceSelector ? (
<div className="mb-3 flex justify-end">{sourceSelector}</div>
) : null}
<MemberLogsSourcePanel
teamName={teamName}
member={selectedMember}
enabled={open && selectedKey === memberLogSourceKey(selectedMember.name)}
/>
</>
) : (
<div className="py-4 text-center text-xs text-[var(--color-text-muted)]">
Select a log source.
</div>
)}
</div>
</DialogContent>
</Dialog>
);
};
// =============================================================================
// Main component
// =============================================================================
@ -88,22 +276,79 @@ export const ClaudeLogsSection = memo(function ClaudeLogsSection({
sidebarViewerMaxHeight,
onOpenChange,
}: ClaudeLogsSectionProps): React.JSX.Element {
const ctrl = useClaudeLogsController(teamName);
const teamMembers = useStore((state) => selectResolvedMembersForTeamName(state, teamName));
const [selectedSourceState, setSelectedSourceState] = useState<{
teamName: string;
sourceKey: TeamLogSourceKey;
}>(() => ({ teamName, sourceKey: LEAD_LOG_SOURCE_KEY }));
const selectedSourceKey =
selectedSourceState.teamName === teamName ? selectedSourceState.sourceKey : LEAD_LOG_SOURCE_KEY;
const setSelectedSourceKey = useCallback(
(sourceKey: TeamLogSourceKey) => {
setSelectedSourceState({ teamName, sourceKey });
},
[teamName]
);
const leadMember = useMemo(() => resolveLeadLogMember(teamMembers), [teamMembers]);
const selectableMembers = useMemo(() => buildSelectableLogMembers(teamMembers), [teamMembers]);
const selectedMemberName = getMemberNameFromLogSourceKey(selectedSourceKey);
const selectedMemberSourceName = selectedMemberName
? normalizeMemberLogSourceName(selectedMemberName)
: null;
const selectedMember = useMemo(
() =>
selectedMemberSourceName
? (selectableMembers.find(
(member) => normalizeMemberLogSourceName(member.name) === selectedMemberSourceName
) ?? null)
: null,
[selectableMembers, selectedMemberSourceName]
);
const effectiveSelectedSourceKey =
selectedSourceKey === LEAD_LOG_SOURCE_KEY
? LEAD_LOG_SOURCE_KEY
: selectedMember
? memberLogSourceKey(selectedMember.name)
: LEAD_LOG_SOURCE_KEY;
const showingLeadLogs = effectiveSelectedSourceKey === LEAD_LOG_SOURCE_KEY;
const ctrl = useClaudeLogsController(teamName, { enabled: showingLeadLogs });
const [dialogOpen, setDialogOpen] = useState(false);
const isSidebar = position === 'sidebar';
const showHeaderSkeleton = ctrl.loading && ctrl.data.lines.length === 0 && !ctrl.error;
const showHeaderSkeleton =
showingLeadLogs && ctrl.loading && ctrl.data.lines.length === 0 && !ctrl.error;
useEffect(() => {
if (selectedSourceState.teamName !== teamName) {
setSelectedSourceState({ teamName, sourceKey: LEAD_LOG_SOURCE_KEY });
}
}, [selectedSourceState.teamName, teamName]);
useEffect(() => {
if (selectedSourceKey === LEAD_LOG_SOURCE_KEY) return;
if (selectedMember) {
const canonicalSourceKey = memberLogSourceKey(selectedMember.name);
if (selectedSourceKey !== canonicalSourceKey) {
setSelectedSourceKey(canonicalSourceKey);
}
return;
}
setSelectedSourceKey(LEAD_LOG_SOURCE_KEY);
}, [selectedMember, selectedSourceKey, setSelectedSourceKey]);
const sectionHeaderExtra = useMemo(
() => (
<span className={cn('flex min-w-0 items-center gap-2', isSidebar && 'basis-full pt-0.5')}>
{ctrl.online ? (
{showingLeadLogs && ctrl.online ? (
<span className="pointer-events-none relative inline-flex size-2 shrink-0">
<span className="absolute inline-flex size-full animate-ping rounded-full bg-emerald-400 opacity-50" />
<span className="relative inline-flex size-2 rounded-full bg-emerald-400" />
</span>
) : null}
{ctrl.lastLogPreview ? <LogPreviewInline preview={ctrl.lastLogPreview} /> : null}
{showingLeadLogs && ctrl.lastLogPreview ? (
<LogPreviewInline preview={ctrl.lastLogPreview} />
) : null}
{!showingLeadLogs && selectedMember ? <MemberSourcePill member={selectedMember} /> : null}
{showHeaderSkeleton ? (
<span className="flex min-w-0 flex-1 items-center gap-1.5 opacity-70">
<LogsHeaderSkeletonPill className="size-3 rounded" />
@ -114,9 +359,18 @@ export const ClaudeLogsSection = memo(function ClaudeLogsSection({
) : null}
</span>
),
[ctrl.online, ctrl.lastLogPreview, isSidebar, showHeaderSkeleton]
[
ctrl.online,
ctrl.lastLogPreview,
isSidebar,
selectedMember,
showingLeadLogs,
showHeaderSkeleton,
]
);
const canOpenFullscreen = showingLeadLogs ? ctrl.data.total > 0 : selectedMember !== null;
const afterBadge = showHeaderSkeleton ? (
<>
<LogsHeaderSkeletonPill className="h-5 w-14" />
@ -124,7 +378,7 @@ export const ClaudeLogsSection = memo(function ClaudeLogsSection({
<Expand size={14} />
</span>
</>
) : ctrl.data.total > 0 ? (
) : canOpenFullscreen ? (
<Tooltip>
<TooltipTrigger asChild>
<Button
@ -150,7 +404,7 @@ export const ClaudeLogsSection = memo(function ClaudeLogsSection({
sectionId="claude-logs"
title="Logs"
icon={null}
badge={ctrl.badge}
badge={showingLeadLogs ? ctrl.badge : undefined}
afterBadge={afterBadge}
headerClassName={isSidebar ? '-mx-3 w-[calc(100%+1.5rem)] py-0' : undefined}
headerSurfaceClassName={isSidebar ? '!rounded-none' : undefined}
@ -162,22 +416,50 @@ export const ClaudeLogsSection = memo(function ClaudeLogsSection({
contentClassName="pt-0 [overflow-anchor:none]"
>
{/* When dialog is open, hide the compact log viewer to avoid two competing scroll containers */}
<TeamLogsSourceSelector
leadMember={leadMember}
members={selectableMembers}
selectedKey={effectiveSelectedSourceKey}
onChange={setSelectedSourceKey}
/>
{dialogOpen ? (
<div className="flex items-center gap-2 p-2 text-xs text-[var(--color-text-muted)]">
<Expand size={12} />
Viewing in fullscreen mode
</div>
) : (
) : showingLeadLogs ? (
<ClaudeLogsPanel
ctrl={ctrl}
viewerClassName={cn('max-h-[213px]', isSidebar && 'cli-logs-sidebar')}
viewerMaxHeight={isSidebar ? sidebarViewerMaxHeight : undefined}
compactMetaInTooltip={isSidebar}
/>
) : selectedMember ? (
<MemberLogsSourcePanel
teamName={teamName}
member={selectedMember}
enabled={effectiveSelectedSourceKey === memberLogSourceKey(selectedMember.name)}
maxHeight={isSidebar ? sidebarViewerMaxHeight : undefined}
/>
) : (
<div className="py-4 text-center text-xs text-[var(--color-text-muted)]">
Select a log source.
</div>
)}
</CollapsibleTeamSection>
<ClaudeLogsDialog open={dialogOpen} onOpenChange={setDialogOpen} ctrl={ctrl} />
<TeamLogsDialog
open={dialogOpen}
onOpenChange={setDialogOpen}
teamName={teamName}
leadMember={leadMember}
members={selectableMembers}
selectedKey={effectiveSelectedSourceKey}
onSourceChange={setSelectedSourceKey}
showingLeadLogs={showingLeadLogs}
ctrl={ctrl}
selectedMember={selectedMember}
/>
</>
);
});

View file

@ -0,0 +1,12 @@
export type ClaudeLogStream = 'stdout' | 'stderr';
export type ClaudeLogKind = 'output' | 'thinking' | 'tool';
export interface ClaudeLogsFilterState {
streams: Set<ClaudeLogStream>;
kinds: Set<ClaudeLogKind>;
}
export const DEFAULT_CLAUDE_LOGS_FILTER: ClaudeLogsFilterState = {
streams: new Set<ClaudeLogStream>(['stdout', 'stderr']),
kinds: new Set<ClaudeLogKind>(['output', 'thinking', 'tool']),
};

View file

@ -1,9 +1,5 @@
import { useEffect, useMemo, useState } from 'react';
import {
isMemberLogStreamUiEnabled,
MemberLogStreamSection,
} from '@features/member-log-stream/renderer';
// import { MemberWorkSyncStatusPanel } from '@features/member-work-sync/renderer';
import { Button } from '@renderer/components/ui/button';
import { Dialog, DialogContent, DialogFooter, DialogHeader } from '@renderer/components/ui/dialog';
@ -43,7 +39,7 @@ import { MemberDetailHeader } from './MemberDetailHeader';
import { MemberDetailStats } from './MemberDetailStats';
import { type MemberActivityFilter, type MemberDetailTab } from './memberDetailTypes';
import { MemberLaunchDiagnosticsButton } from './MemberLaunchDiagnosticsButton';
import { MemberLogsTab } from './MemberLogsTab';
import { MemberLogStreamWithLegacyFallback } from './MemberLogStreamWithLegacyFallback';
import { MemberMessagesTab } from './MemberMessagesTab';
import { MemberStatsTab } from './MemberStatsTab';
import { MemberTasksTab } from './MemberTasksTab';
@ -197,7 +193,6 @@ export const MemberDetailDialog = ({
const [activeTab, setActiveTab] = useState<MemberDetailTab>(initialTab);
const [restarting, setRestarting] = useState(false);
const [restartError, setRestartError] = useState<string | null>(null);
const [showLegacyLogsFallback, setShowLegacyLogsFallback] = useState(false);
const runtimeSummary = useMemo(
() =>
@ -269,7 +264,6 @@ export const MemberDetailDialog = ({
setActiveTab(initialTab);
setRestartError(null);
setRestarting(false);
setShowLegacyLogsFallback(false);
}, [initialTab, member, open]);
const {
@ -279,7 +273,6 @@ export const MemberDetailDialog = ({
} = useMemberStats(teamName, member?.name ?? null);
const totalTokens = memberStats ? memberStats.inputTokens + memberStats.outputTokens : null;
const memberLogStreamEnabled = isMemberLogStreamUiEnabled();
if (!member) return null;
@ -395,26 +388,11 @@ export const MemberDetailDialog = ({
/>
</TabsContent>
<TabsContent value="logs" className="min-w-0 overflow-hidden">
{memberLogStreamEnabled ? (
<div className="space-y-4">
<MemberLogStreamSection
teamName={teamName}
member={member}
enabled={open && activeTab === 'logs'}
onInitialLoadErrorChange={setShowLegacyLogsFallback}
/>
{showLegacyLogsFallback ? (
<div className="rounded-md border border-[var(--color-border)] p-3">
<div className="mb-3 text-xs font-semibold uppercase text-[var(--color-text-muted)]">
Legacy Logs Fallback
</div>
<MemberLogsTab teamName={teamName} memberName={member.name} />
</div>
) : null}
</div>
) : (
<MemberLogsTab teamName={teamName} memberName={member.name} />
)}
<MemberLogStreamWithLegacyFallback
teamName={teamName}
member={member}
enabled={open && activeTab === 'logs'}
/>
</TabsContent>
</Tabs>

View file

@ -0,0 +1,52 @@
import { useEffect, useState } from 'react';
import {
isMemberLogStreamUiEnabled,
MemberLogStreamSection,
} from '@features/member-log-stream/renderer';
import { MemberLogsTab } from './MemberLogsTab';
import type { ResolvedTeamMember } from '@shared/types';
interface MemberLogStreamWithLegacyFallbackProps {
teamName: string;
member: ResolvedTeamMember;
enabled?: boolean;
}
export const MemberLogStreamWithLegacyFallback = ({
teamName,
member,
enabled = true,
}: Readonly<MemberLogStreamWithLegacyFallbackProps>): React.JSX.Element => {
const streamUiEnabled = isMemberLogStreamUiEnabled();
const [showLegacyLogsFallback, setShowLegacyLogsFallback] = useState(false);
useEffect(() => {
setShowLegacyLogsFallback(false);
}, [member.name, streamUiEnabled, teamName]);
if (!streamUiEnabled) {
return <MemberLogsTab teamName={teamName} memberName={member.name} />;
}
return (
<div className="space-y-4">
<MemberLogStreamSection
teamName={teamName}
member={member}
enabled={enabled}
onInitialLoadErrorChange={setShowLegacyLogsFallback}
/>
{showLegacyLogsFallback ? (
<div className="rounded-md border border-[var(--color-border)] p-3">
<div className="mb-3 text-xs font-semibold uppercase text-[var(--color-text-muted)]">
Legacy Logs Fallback
</div>
<MemberLogsTab teamName={teamName} memberName={member.name} />
</div>
) : null}
</div>
);
};

View file

@ -1,6 +1,5 @@
import { DEFAULT_CLAUDE_LOGS_FILTER } from '../ClaudeLogsFilterPopover';
import { type ClaudeLogsFilterState, DEFAULT_CLAUDE_LOGS_FILTER } from '../claudeLogsFilterState';
import type { ClaudeLogsFilterState } from '../ClaudeLogsFilterPopover';
import type { ClaudeLogsViewerState } from '../CliLogsRichView';
import type { MessagesFilterState } from '../messages/MessagesFilterPopover';

View file

@ -0,0 +1,74 @@
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
import { isLeadMember } from '@shared/utils/leadDetection';
import type { ResolvedTeamMember } from '@shared/types';
export const LEAD_LOG_SOURCE_KEY = 'lead';
const FALLBACK_LEAD_LOG_MEMBER: ResolvedTeamMember = {
name: 'team-lead',
agentType: 'team-lead',
status: 'active',
currentTaskId: null,
taskCount: 0,
lastActiveAt: null,
messageCount: 0,
};
export type TeamLogSourceKey = typeof LEAD_LOG_SOURCE_KEY | `member:${string}`;
export function memberLogSourceKey(memberName: string): TeamLogSourceKey {
return `member:${memberName}`;
}
export function getMemberNameFromLogSourceKey(sourceKey: TeamLogSourceKey): string | null {
if (sourceKey === LEAD_LOG_SOURCE_KEY) return null;
return sourceKey.slice('member:'.length);
}
export function formatMemberLogSourceLabel(member: ResolvedTeamMember): string {
return member.removedAt ? `${member.name} (removed)` : member.name;
}
export function formatMemberLogSourceDescription(member: ResolvedTeamMember): string | null {
if (isLeadMember(member)) return 'Team Lead';
if (member.removedAt) return 'Removed';
return formatAgentRole(member.role) ?? formatAgentRole(member.agentType) ?? null;
}
export function normalizeMemberLogSourceName(memberName: string): string {
return memberName.trim().toLowerCase();
}
export function buildSelectableLogMembers(
members: readonly ResolvedTeamMember[]
): ResolvedTeamMember[] {
const sourceByName = new Map<
string,
{
member: ResolvedTeamMember;
index: number;
}
>();
members.forEach((member, index) => {
const sourceName = normalizeMemberLogSourceName(member.name);
if (!sourceName || sourceName === 'user' || isLeadMember(member)) return;
const existing = sourceByName.get(sourceName);
if (!existing || (existing.member.removedAt && !member.removedAt)) {
sourceByName.set(sourceName, { member, index: existing?.index ?? index });
}
});
return [...sourceByName.values()]
.sort((left, right) => left.index - right.index)
.map((entry) => entry.member);
}
export function resolveLeadLogMember(members: readonly ResolvedTeamMember[]): ResolvedTeamMember {
const leadMembers = members.filter((member) => isLeadMember(member));
return (
leadMembers.find((member) => !member.removedAt) ?? leadMembers[0] ?? FALLBACK_LEAD_LOG_MEMBER
);
}

View file

@ -17,9 +17,8 @@ import {
getTeamClaudeLogsSidebarUiState,
setTeamClaudeLogsSidebarUiState,
} from './sidebar/teamSidebarUiState';
import { DEFAULT_CLAUDE_LOGS_FILTER } from './ClaudeLogsFilterPopover';
import { type ClaudeLogsFilterState, DEFAULT_CLAUDE_LOGS_FILTER } from './claudeLogsFilterState';
import type { ClaudeLogsFilterState } from './ClaudeLogsFilterPopover';
import type { ClaudeLogsViewerState } from './CliLogsRichView';
import type { TeamClaudeLogsResponse } from '@shared/types';
@ -367,7 +366,11 @@ function filterStreamJsonText(
// Hook
// =============================================================================
export function useClaudeLogsController(teamName: string): ClaudeLogsController {
export function useClaudeLogsController(
teamName: string,
options: { enabled?: boolean } = {}
): ClaudeLogsController {
const enabled = options.enabled ?? true;
const isAlive = useStore((s) =>
s.selectedTeamName === teamName ? (s.selectedTeamData?.isAlive ?? false) : false
);
@ -407,6 +410,7 @@ export function useClaudeLogsController(teamName: string): ClaudeLogsController
const logContainerRef = useRef<HTMLDivElement | null>(null);
const committedRef = useRef<TeamClaudeLogsResponse>({ lines: [], total: 0, hasMore: false });
const pendingCountRef = useRef(0);
const pendingPollingFetchRef = useRef<(() => void) | null>(null);
// ── Reset on team change ──────────────────────────────────────────────
useEffect(() => {
@ -447,6 +451,13 @@ export function useClaudeLogsController(teamName: string): ClaudeLogsController
useEffect(() => {
let cancelled = false;
if (!enabled) {
setLoading(false);
return () => {
cancelled = true;
};
}
const computeNewCount = (
committed: TeamClaudeLogsResponse,
latest: TeamClaudeLogsResponse
@ -460,8 +471,17 @@ export function useClaudeLogsController(teamName: string): ClaudeLogsController
return Math.max(0, diff);
};
const fetchLogs = async (): Promise<void> => {
if (inFlightRef.current) return;
const fetchLogs = async (options: { queueIfBusy?: boolean } = {}): Promise<void> => {
if (inFlightRef.current) {
if (options.queueIfBusy) {
pendingPollingFetchRef.current = () => {
if (!cancelled) {
void fetchLogs();
}
};
}
return;
}
inFlightRef.current = true;
try {
setLoading(true);
@ -483,20 +503,27 @@ export function useClaudeLogsController(teamName: string): ClaudeLogsController
setError(e instanceof Error ? e.message : String(e));
} finally {
inFlightRef.current = false;
if (!cancelled) setLoading(false);
const pendingFetch = pendingPollingFetchRef.current;
pendingPollingFetchRef.current = null;
if (pendingFetch) {
pendingFetch();
} else if (!cancelled) {
setLoading(false);
}
}
};
void fetchLogs();
void fetchLogs({ queueIfBusy: true });
const id = window.setInterval(() => void fetchLogs(), POLL_MS);
return () => {
cancelled = true;
window.clearInterval(id);
};
}, [teamName, loadedCount]);
}, [enabled, teamName, loadedCount]);
// ── Load older logs ───────────────────────────────────────────────────
const loadOlderLogs = useCallback(async (): Promise<void> => {
if (!enabled) return;
if (loadingMoreRef.current || inFlightRef.current) return;
const current = committedRef.current;
@ -526,7 +553,7 @@ export function useClaudeLogsController(teamName: string): ClaudeLogsController
loadingMoreRef.current = false;
setLoadingMore(false);
}
}, [teamName]);
}, [enabled, teamName]);
// ── Auto-load when content fits in container ──────────────────────────
const isNearBottom = useCallback(
@ -550,6 +577,7 @@ export function useClaudeLogsController(teamName: string): ClaudeLogsController
// ── Apply pending logs ────────────────────────────────────────────────
const applyPending = useCallback(async (): Promise<void> => {
if (!enabled) return;
if (applyingPendingRef.current) return;
applyingPendingRef.current = true;
@ -575,7 +603,7 @@ export function useClaudeLogsController(teamName: string): ClaudeLogsController
} finally {
applyingPendingRef.current = false;
}
}, [loadedCount, pending, teamName]);
}, [enabled, loadedCount, pending, teamName]);
// ── Computed values ───────────────────────────────────────────────────
const online = useMemo(() => isRecent(data.updatedAt), [data.updatedAt]);

View file

@ -27,6 +27,11 @@ interface MemberSelectProps {
size?: 'sm' | 'md';
disabled?: boolean;
className?: string;
searchPlaceholder?: string;
emptyMessage?: string;
getMemberLabel?: (member: ResolvedTeamMember) => string;
getMemberDescription?: (member: ResolvedTeamMember) => string | null | undefined;
ariaLabel?: string;
}
const UNASSIGNED_VALUE = '__unassigned__';
@ -40,6 +45,11 @@ export const MemberSelect = ({
size = 'sm',
disabled = false,
className,
searchPlaceholder = 'Search members...',
emptyMessage = 'No members found.',
getMemberLabel,
getMemberDescription,
ariaLabel,
}: MemberSelectProps): React.JSX.Element => {
const [open, setOpen] = React.useState(false);
const [search, setSearch] = React.useState('');
@ -57,13 +67,26 @@ 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 resolveMemberLabel = React.useCallback(
(member: ResolvedTeamMember): string =>
getMemberLabel?.(member) ?? (member.name === 'team-lead' ? 'lead' : member.name),
[getMemberLabel]
);
const resolveMemberDescription = React.useCallback(
(member: ResolvedTeamMember): string | null | undefined =>
getMemberDescription?.(member) ??
formatAgentRole(member.role) ??
formatAgentRole(member.agentType),
[getMemberDescription]
);
// eslint-disable-next-line sonarjs/function-return-type -- option renderer returns mixed node structure
const renderMemberInline = (member: ResolvedTeamMember): React.ReactNode => {
const resolvedColor = colorMap.get(member.name);
const colors = getTeamColorSet(resolvedColor ?? '');
const label = resolveMemberLabel(member);
return (
<span className="inline-flex items-center gap-1.5">
<span className="inline-flex min-w-0 max-w-full items-center gap-1.5">
<img
src={avatarMap.get(member.name) ?? agentAvatarUrl(member.name, avatarSize)}
alt=""
@ -71,14 +94,14 @@ export const MemberSelect = ({
loading="lazy"
/>
<span
className={`rounded px-1.5 py-0.5 ${textSize} font-medium tracking-wide`}
className={`min-w-0 truncate rounded px-1.5 py-0.5 ${textSize} font-medium tracking-wide`}
style={{
backgroundColor: getThemedBadge(colors, isLight),
color: colors.text,
border: `1px solid ${colors.border}40`,
}}
>
{member.name === 'team-lead' ? 'lead' : member.name}
{label}
</span>
</span>
);
@ -92,6 +115,7 @@ export const MemberSelect = ({
role="combobox"
aria-expanded={open}
aria-controls={listboxId}
aria-label={ariaLabel}
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`,
@ -125,7 +149,7 @@ export const MemberSelect = ({
<CommandPrimitive.Input
value={search}
onValueChange={setSearch}
placeholder="Search members..."
placeholder={searchPlaceholder}
className="flex h-8 w-full border-0 bg-transparent px-2 py-1 text-xs text-[var(--color-text)] outline-none placeholder:text-[var(--color-text-muted)]"
/>
</div>
@ -135,7 +159,7 @@ export const MemberSelect = ({
onWheel={(e) => e.stopPropagation()}
>
<CommandPrimitive.Empty className="py-4 pr-2 text-center text-xs text-[var(--color-text-muted)]">
No members found.
{emptyMessage}
</CommandPrimitive.Empty>
{allowUnassigned && !search.trim() ? (
<CommandPrimitive.Item
@ -157,8 +181,12 @@ export const MemberSelect = ({
.filter((m) => {
if (!search.trim()) return true;
const q = search.toLowerCase();
const label = resolveMemberLabel(m);
const description = resolveMemberDescription(m);
return (
m.name.toLowerCase().includes(q) ||
label.toLowerCase().includes(q) ||
(description?.toLowerCase().includes(q) ?? false) ||
(m.role?.toLowerCase().includes(q) ?? false) ||
(m.agentType?.toLowerCase().includes(q) ?? false)
);
@ -167,7 +195,8 @@ export const MemberSelect = ({
const isSelected = m.name === value;
const resolvedColor = colorMap.get(m.name);
const colors = getTeamColorSet(resolvedColor ?? '');
const role = formatAgentRole(m.role) ?? formatAgentRole(m.agentType);
const label = resolveMemberLabel(m);
const role = resolveMemberDescription(m);
return (
<CommandPrimitive.Item
@ -187,7 +216,7 @@ export const MemberSelect = ({
loading="lazy"
/>
<span className="min-w-0 truncate font-medium" style={{ color: colors.text }}>
{m.name === 'team-lead' ? 'lead' : m.name}
{label}
</span>
{role ? (
<span className="shrink-0 text-[10px] text-[var(--color-text-muted)]">

View file

@ -0,0 +1,46 @@
import React, { act } from 'react';
import { createRoot } from 'react-dom/client';
import { TeamTabSectionNav } from '@renderer/components/layout/TeamTabSectionNav';
import { useStore } from '@renderer/store';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
describe('TeamTabSectionNav', () => {
let host: HTMLDivElement;
let root: ReturnType<typeof createRoot>;
beforeEach(() => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
useStore.setState({ messagesPanelMode: 'inline' } as never);
host = document.createElement('div');
document.body.appendChild(host);
root = createRoot(host);
});
afterEach(async () => {
await act(async () => {
root.unmount();
await Promise.resolve();
});
document.body.innerHTML = '';
vi.unstubAllGlobals();
vi.clearAllMocks();
});
it('labels the logs section as Logs in the section jump menu', async () => {
await act(async () => {
root.render(<TeamTabSectionNav teamName="demo-team" />);
await Promise.resolve();
});
const trigger = host.querySelector('button[title="Jump to section"]') as HTMLButtonElement;
await act(async () => {
trigger.click();
await Promise.resolve();
});
const menu = document.body.querySelector('[role="menu"]') as HTMLElement | null;
expect(menu?.textContent).toContain('Logs');
expect(menu?.textContent).not.toContain('Claude Logs');
});
});

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';
@ -106,6 +107,49 @@ describe('ClaudeLogsPanel', () => {
});
});
it('renders leading toolbar controls before the search field', 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: ['lead output'],
total: 1,
hasMore: false,
},
filteredText: 'lead output',
});
await act(async () => {
root.render(
React.createElement(ClaudeLogsPanel, {
ctrl,
toolbarControlsStart: React.createElement(
'div',
{ 'data-testid': 'toolbar-source' },
'Lead'
),
})
);
await Promise.resolve();
});
const source = host.querySelector('[data-testid="toolbar-source"]');
const search = host.querySelector('input[placeholder="Search logs..."]');
expect(source).not.toBeNull();
expect(search).not.toBeNull();
expect(source?.compareDocumentPosition(search as Node)).toBe(
Node.DOCUMENT_POSITION_FOLLOWING
);
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
it('shows the offline empty state only when no logs exist', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
const host = document.createElement('div');

File diff suppressed because it is too large Load diff

View file

@ -1,8 +1,8 @@
import React, { act } from 'react';
import { createRoot } from 'react-dom/client';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { useStore } from '@renderer/store';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { ResolvedTeamMember, TeamTaskWithKanban } from '@shared/types';

View file

@ -0,0 +1,81 @@
import {
buildSelectableLogMembers,
formatMemberLogSourceDescription,
formatMemberLogSourceLabel,
getMemberNameFromLogSourceKey,
memberLogSourceKey,
resolveLeadLogMember,
} from '@renderer/components/team/teamLogSources';
import { describe, expect, it } from 'vitest';
import type { ResolvedTeamMember } from '@shared/types';
function member(
name: string,
overrides: Partial<ResolvedTeamMember> = {}
): ResolvedTeamMember {
return {
name,
status: 'active',
currentTaskId: null,
taskCount: 0,
lastActiveAt: null,
messageCount: 0,
...overrides,
};
}
describe('team log source helpers', () => {
it('builds teammate sources without lead, user, blank names, or duplicate removed entries', () => {
const sources = buildSelectableLogMembers([
member('team-lead', { agentType: 'team-lead' }),
member('user'),
member(' '),
member('Builder', { removedAt: 1715000000000 }),
member('Reviewer'),
member('builder'),
]);
expect(sources.map((source) => source.name)).toEqual(['builder', 'Reviewer']);
expect(sources[0]?.removedAt).toBeUndefined();
});
it('keeps first active duplicate source and preserves original ordering slot', () => {
const sources = buildSelectableLogMembers([
member('Zed'),
member('alpha', { removedAt: 1715000000000 }),
member('Beta'),
member('ALPHA'),
member('alpha-late'),
]);
expect(sources.map((source) => source.name)).toEqual(['Zed', 'ALPHA', 'Beta', 'alpha-late']);
});
it('resolves active lead before removed lead and falls back safely when roster has no lead', () => {
expect(
resolveLeadLogMember([
member('team-lead', { agentType: 'team-lead', removedAt: 1715000000000 }),
member('captain', { agentType: 'orchestrator' }),
]).name
).toBe('captain');
const fallback = resolveLeadLogMember([member('Builder')]);
expect(fallback.name).toBe('team-lead');
expect(fallback.agentType).toBe('team-lead');
});
it('formats source labels, descriptions, and stable member source keys', () => {
const removed = member('Builder', { removedAt: 1715000000000 });
const developer = member('Reviewer', { role: 'reviewer' });
const lead = member('lead-alias', { agentType: 'lead' });
expect(formatMemberLogSourceLabel(removed)).toBe('Builder (removed)');
expect(formatMemberLogSourceDescription(removed)).toBe('Removed');
expect(formatMemberLogSourceDescription(developer)).toBe('Reviewer');
expect(formatMemberLogSourceDescription(lead)).toBe('Team Lead');
expect(getMemberNameFromLogSourceKey(memberLogSourceKey('name:with:colon'))).toBe(
'name:with:colon'
);
});
});

View file

@ -0,0 +1,276 @@
import React, { act } from 'react';
import { createRoot } from 'react-dom/client';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { ClaudeLogsFilterState } from '@renderer/components/team/claudeLogsFilterState';
import type { ClaudeLogsViewerState } from '@renderer/components/team/CliLogsRichView';
import type { ClaudeLogsController } from '@renderer/components/team/useClaudeLogsController';
import type { TeamClaudeLogsResponse } from '@shared/types';
const controllerState = vi.hoisted(() => ({
getClaudeLogs: vi.fn<() => Promise<TeamClaudeLogsResponse>>(),
setSidebarState: vi.fn(),
}));
function createLogsResponse(text = 'lead'): TeamClaudeLogsResponse {
return {
lines: [`{"type":"assistant","content":[{"type":"text","text":"${text}"}]}`],
total: 1,
hasMore: false,
};
}
function createDeferred<T>(): {
promise: Promise<T>;
resolve: (value: T) => void;
reject: (error: unknown) => void;
} {
let resolve!: (value: T) => void;
let reject!: (error: unknown) => void;
const promise = new Promise<T>((promiseResolve, promiseReject) => {
resolve = promiseResolve;
reject = promiseReject;
});
return { promise, resolve, reject };
}
vi.mock('@renderer/api', () => ({
api: {
teams: {
getClaudeLogs: controllerState.getClaudeLogs,
},
},
}));
vi.mock('@renderer/store', () => ({
useStore: (selector: (state: unknown) => unknown) =>
selector({
selectedTeamName: 'demo-team',
selectedTeamData: { isAlive: true },
}),
}));
vi.mock('@renderer/components/team/sidebar/teamSidebarUiState', () => ({
getTeamClaudeLogsSidebarUiState: () => ({
searchQuery: '',
filter: {
streams: new Set(['stdout', 'stderr']),
kinds: new Set(['output', 'thinking', 'tool']),
} satisfies ClaudeLogsFilterState,
filterOpen: false,
viewerState: {} as ClaudeLogsViewerState,
}),
setTeamClaudeLogsSidebarUiState: controllerState.setSidebarState,
}));
import { useClaudeLogsController } from '@renderer/components/team/useClaudeLogsController';
function ControllerHarness({ enabled }: Readonly<{ enabled: boolean }>): React.JSX.Element {
useClaudeLogsController('demo-team', { enabled });
return React.createElement('div');
}
function ControllerCaptureHarness({
enabled,
onController,
}: Readonly<{
enabled: boolean;
onController: (controller: ClaudeLogsController) => void;
}>): React.JSX.Element {
const controller = useClaudeLogsController('demo-team', { enabled });
onController(controller);
return React.createElement('div');
}
describe('useClaudeLogsController enabled option', () => {
beforeEach(() => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
controllerState.getClaudeLogs.mockResolvedValue(createLogsResponse());
controllerState.setSidebarState.mockClear();
});
afterEach(() => {
document.body.innerHTML = '';
vi.clearAllMocks();
vi.unstubAllGlobals();
});
it('does not fetch lead logs while disabled and loads them when re-enabled', async () => {
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
await act(async () => {
root.render(React.createElement(ControllerHarness, { enabled: false }));
await Promise.resolve();
});
expect(controllerState.getClaudeLogs).not.toHaveBeenCalled();
await act(async () => {
root.render(React.createElement(ControllerHarness, { enabled: true }));
await Promise.resolve();
await Promise.resolve();
});
expect(controllerState.getClaudeLogs).toHaveBeenCalledTimes(1);
expect(controllerState.getClaudeLogs).toHaveBeenCalledWith('demo-team', {
offset: 0,
limit: 100,
});
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
it('queues a fresh lead fetch when re-enabled before the previous request settles', async () => {
const firstRequest = createDeferred<TeamClaudeLogsResponse>();
controllerState.getClaudeLogs
.mockReturnValueOnce(firstRequest.promise)
.mockResolvedValue(createLogsResponse('fresh lead'));
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
await act(async () => {
root.render(React.createElement(ControllerHarness, { enabled: true }));
await Promise.resolve();
});
expect(controllerState.getClaudeLogs).toHaveBeenCalledTimes(1);
await act(async () => {
root.render(React.createElement(ControllerHarness, { enabled: false }));
await Promise.resolve();
});
await act(async () => {
root.render(React.createElement(ControllerHarness, { enabled: true }));
await Promise.resolve();
});
expect(controllerState.getClaudeLogs).toHaveBeenCalledTimes(1);
await act(async () => {
firstRequest.resolve(createLogsResponse('stale lead'));
await Promise.resolve();
await Promise.resolve();
});
expect(controllerState.getClaudeLogs).toHaveBeenCalledTimes(2);
expect(controllerState.getClaudeLogs).toHaveBeenLastCalledWith('demo-team', {
offset: 0,
limit: 100,
});
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
it('does not run a queued lead fetch after being disabled again', async () => {
const firstRequest = createDeferred<TeamClaudeLogsResponse>();
controllerState.getClaudeLogs
.mockReturnValueOnce(firstRequest.promise)
.mockResolvedValue(createLogsResponse('unexpected lead'));
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
await act(async () => {
root.render(React.createElement(ControllerHarness, { enabled: true }));
await Promise.resolve();
});
expect(controllerState.getClaudeLogs).toHaveBeenCalledTimes(1);
await act(async () => {
root.render(React.createElement(ControllerHarness, { enabled: false }));
await Promise.resolve();
});
await act(async () => {
root.render(React.createElement(ControllerHarness, { enabled: true }));
await Promise.resolve();
});
await act(async () => {
root.render(React.createElement(ControllerHarness, { enabled: false }));
await Promise.resolve();
});
await act(async () => {
firstRequest.resolve(createLogsResponse('stale lead'));
await Promise.resolve();
await Promise.resolve();
});
expect(controllerState.getClaudeLogs).toHaveBeenCalledTimes(1);
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
it('does not load more or apply pending lead logs while disabled', async () => {
let latestController: ClaudeLogsController | null = null;
const getLatestController = (): ClaudeLogsController => {
if (!latestController) {
throw new Error('Controller was not captured');
}
return latestController;
};
controllerState.getClaudeLogs.mockResolvedValue({
...createLogsResponse('lead with more'),
hasMore: true,
total: 150,
});
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
await act(async () => {
root.render(
React.createElement(ControllerCaptureHarness, {
enabled: true,
onController: (controller) => {
latestController = controller;
},
})
);
await Promise.resolve();
await Promise.resolve();
});
expect(controllerState.getClaudeLogs).toHaveBeenCalledTimes(1);
expect(getLatestController().data.hasMore).toBe(true);
await act(async () => {
root.render(
React.createElement(ControllerCaptureHarness, {
enabled: false,
onController: (controller) => {
latestController = controller;
},
})
);
await Promise.resolve();
});
await act(async () => {
const disabledController = getLatestController();
await disabledController.loadOlderLogs();
await disabledController.applyPending();
await Promise.resolve();
});
expect(controllerState.getClaudeLogs).toHaveBeenCalledTimes(1);
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
});

View file

@ -0,0 +1,174 @@
import React, { act } from 'react';
import { createRoot } from 'react-dom/client';
import { MemberSelect } from '@renderer/components/ui/MemberSelect';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { ResolvedTeamMember } from '@shared/types';
vi.mock('@renderer/hooks/useTheme', () => ({
useTheme: () => ({
theme: 'dark',
resolvedTheme: 'dark',
isDark: true,
isLight: false,
}),
}));
function member(
name: string,
overrides: Partial<ResolvedTeamMember> = {}
): ResolvedTeamMember {
return {
name,
status: 'active',
currentTaskId: null,
taskCount: 0,
lastActiveAt: null,
messageCount: 0,
...overrides,
};
}
async function flush(): Promise<void> {
await Promise.resolve();
await Promise.resolve();
}
describe('MemberSelect', () => {
let host: HTMLDivElement;
let root: ReturnType<typeof createRoot>;
let originalScrollIntoView: typeof HTMLElement.prototype.scrollIntoView;
beforeEach(() => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
originalScrollIntoView = HTMLElement.prototype.scrollIntoView;
HTMLElement.prototype.scrollIntoView = vi.fn();
host = document.createElement('div');
document.body.appendChild(host);
root = createRoot(host);
});
afterEach(async () => {
await act(async () => {
root.unmount();
await flush();
});
document.body.innerHTML = '';
HTMLElement.prototype.scrollIntoView = originalScrollIntoView;
vi.unstubAllGlobals();
vi.clearAllMocks();
});
it('preserves Create Task defaults for unassigned and lead display', async () => {
const onChange = vi.fn();
await act(async () => {
root.render(
<MemberSelect
members={[
member('team-lead', { agentType: 'team-lead' }),
member('alice', { role: 'reviewer' }),
]}
value={null}
onChange={onChange}
allowUnassigned
/>
);
await flush();
});
const trigger = host.querySelector('button[role="combobox"]') as HTMLButtonElement;
expect(trigger.textContent).toContain('Unassigned');
expect(trigger.getAttribute('aria-label')).toBeNull();
await act(async () => {
trigger.click();
await flush();
});
const list = document.body.querySelector('[cmdk-list]') as HTMLElement | null;
expect(list?.textContent).toContain('Unassigned');
expect(list?.textContent).toContain('lead');
expect(list?.textContent).toContain('alice');
expect(document.body.querySelector('input')?.getAttribute('placeholder')).toBe(
'Search members...'
);
});
it('supports custom log-source labels, descriptions, search text, and selection', async () => {
const onChange = vi.fn();
await act(async () => {
root.render(
<MemberSelect
members={[
member('team-lead', { agentType: 'team-lead' }),
member('Builder', { removedAt: 1715000000000 }),
member('Reviewer', { role: 'reviewer' }),
]}
value="team-lead"
onChange={onChange}
searchPlaceholder="Search log sources..."
emptyMessage="No log sources found."
ariaLabel="Log source"
getMemberLabel={(candidate) =>
candidate.name === 'team-lead'
? 'Lead'
: candidate.removedAt
? `${candidate.name} (removed)`
: candidate.name
}
getMemberDescription={(candidate) =>
candidate.name === 'team-lead'
? 'Team Lead'
: candidate.removedAt
? 'Removed'
: 'Reviewer'
}
/>
);
await flush();
});
const trigger = host.querySelector('button[role="combobox"]') as HTMLButtonElement;
expect(trigger.textContent).toContain('Lead');
expect(trigger.getAttribute('aria-label')).toBe('Log source');
await act(async () => {
trigger.click();
await flush();
});
const input = document.body.querySelector('input') as HTMLInputElement;
const list = document.body.querySelector('[cmdk-list]') as HTMLElement | null;
expect(input.getAttribute('placeholder')).toBe('Search log sources...');
expect(list?.textContent).toContain('Lead');
expect(list?.textContent).toContain('Team Lead');
expect(list?.textContent).toContain('Builder (removed)');
expect(list?.textContent).toContain('Removed');
await act(async () => {
const valueSetter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set;
valueSetter?.call(input, 'removed');
input.dispatchEvent(new Event('input', { bubbles: true }));
await flush();
});
expect(list?.textContent).toContain('Builder (removed)');
expect(list?.textContent).not.toContain('Reviewer');
expect(list?.textContent).not.toContain('Team Lead');
const builderItem = Array.from(list?.querySelectorAll('[cmdk-item]') ?? []).find((item) =>
item.textContent?.includes('Builder (removed)')
) as HTMLElement | undefined;
expect(builderItem).toBeDefined();
await act(async () => {
builderItem?.click();
await flush();
});
expect(onChange).toHaveBeenCalledWith('Builder');
});
});