feat(logs): compact team log source controls

This commit is contained in:
777genius 2026-05-25 14:54:28 +03:00
parent 79faaf1f9f
commit 62ef88300a
8 changed files with 195 additions and 186 deletions

View file

@ -1,15 +1,13 @@
import { useCallback, useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo } from 'react';
import { useAppTranslation } from '@features/localization/renderer'; import { useAppTranslation } from '@features/localization/renderer';
import { api } from '@renderer/api';
import { useStore } from '@renderer/store'; import { useStore } from '@renderer/store';
import { selectResolvedMembersForTeamName } from '@renderer/store/slices/teamSlice'; import { selectResolvedMembersForTeamName } from '@renderer/store/slices/teamSlice';
import { useMemberLogStream } from '../hooks/useMemberLogStream'; import { useMemberLogStream } from '../hooks/useMemberLogStream';
import { ExecutionLogStreamView } from '../ui/ExecutionLogStreamView'; import { ExecutionLogStreamView } from '../ui/ExecutionLogStreamView';
import { MemberRuntimeProcessLogsPanel } from '../ui/MemberRuntimeProcessLogsPanel';
import type { MemberLogStreamSegment, MemberRuntimeLogKind } from '../../contracts'; import type { MemberLogStreamSegment } from '../../contracts';
import type { ResolvedTeamMember } from '@shared/types'; import type { ResolvedTeamMember } from '@shared/types';
interface MemberLogStreamSectionProps { interface MemberLogStreamSectionProps {
@ -19,10 +17,6 @@ interface MemberLogStreamSectionProps {
onInitialLoadErrorChange?: (hasError: boolean) => void; onInitialLoadErrorChange?: (hasError: boolean) => void;
} }
function describeMemberStream(): string {
return 'Member-scoped transcript and runtime logs rendered with the same execution-log components used in Task Log Stream.';
}
function getSegmentMetaLabel(segment: MemberLogStreamSegment): string { function getSegmentMetaLabel(segment: MemberLogStreamSegment): string {
const details = [segment.source.label]; const details = [segment.source.label];
if (segment.source.laneId) { if (segment.source.laneId) {
@ -38,24 +32,15 @@ function buildMemberSegmentRenderKey(segment: MemberLogStreamSegment): string {
return `${segment.id}:${firstChunkId ?? segment.startTimestamp}`; return `${segment.id}:${firstChunkId ?? segment.startTimestamp}`;
} }
export function MemberLogStreamSection({ export const MemberLogStreamSection = ({
teamName, teamName,
member, member,
enabled = true, enabled = true,
onInitialLoadErrorChange, onInitialLoadErrorChange,
}: Readonly<MemberLogStreamSectionProps>): React.JSX.Element { }: Readonly<MemberLogStreamSectionProps>): React.JSX.Element => {
const { t } = useAppTranslation('team'); const { t } = useAppTranslation('team');
const [selectedLogView, setSelectedLogView] = useState<'execution' | 'process'>('execution');
const teamMembers = useStore((s) => selectResolvedMembersForTeamName(s, teamName)); const teamMembers = useStore((s) => selectResolvedMembersForTeamName(s, teamName));
const { stream, loading, error } = useMemberLogStream({ teamName, member, enabled }); const { stream, loading, error } = useMemberLogStream({ teamName, member, enabled });
const loadRuntimeLogTail = useCallback(
(input: {
readonly kind: MemberRuntimeLogKind;
readonly maxBytes: number;
readonly forceRefresh?: boolean;
}) => api.memberLogStream.getMemberRuntimeLogTail(teamName, member.name, input),
[member.name, teamName]
);
const hasInitialLoadError = Boolean(error && !stream && !loading); const hasInitialLoadError = Boolean(error && !stream && !loading);
const boundedHistoryNote = useMemo(() => { const boundedHistoryNote = useMemo(() => {
if (!stream) return null; if (!stream) return null;
@ -70,56 +55,24 @@ export function MemberLogStreamSection({
}, [hasInitialLoadError, onInitialLoadErrorChange]); }, [hasInitialLoadError, onInitialLoadErrorChange]);
return ( return (
<div className="space-y-4"> <ExecutionLogStreamView
<div className="inline-flex rounded-xl bg-[var(--color-surface-subtle)] p-1"> title=""
<button description=""
type="button" stream={stream}
className={`rounded-lg px-3 py-1.5 text-sm font-medium transition-colors ${ loading={loading}
selectedLogView === 'execution' error={error}
? 'bg-[var(--color-surface)] text-[var(--color-text)] shadow-sm' teamName={teamName}
: 'text-[var(--color-text-muted)] hover:text-[var(--color-text)]' teamMembers={teamMembers}
}`} loadingText={t('memberLogStream.logs.loading')}
onClick={() => setSelectedLogView('execution')} emptyTitle={t('memberLogStream.logs.emptyTitle')}
> emptyDescription={t('memberLogStream.logs.emptyDescription')}
{t('memberLogStream.tabs.execution')} selectionResetKey={`${teamName}:${member.name}`}
</button> boundedHistoryNote={boundedHistoryNote}
<button forceSegmentHeaders
type="button" showIntro={false}
className={`rounded-lg px-3 py-1.5 text-sm font-medium transition-colors ${ showSegmentParticipantBadge={false}
selectedLogView === 'process' buildSegmentRenderKey={buildMemberSegmentRenderKey}
? 'bg-[var(--color-surface)] text-[var(--color-text)] shadow-sm' getSegmentMetaLabel={getSegmentMetaLabel}
: 'text-[var(--color-text-muted)] hover:text-[var(--color-text)]' />
}`}
onClick={() => setSelectedLogView('process')}
>
{t('memberLogStream.tabs.process')}
</button>
</div>
{selectedLogView === 'execution' ? (
<ExecutionLogStreamView
title={t('memberLogStream.logs.title')}
description={describeMemberStream()}
stream={stream}
loading={loading}
error={error}
teamName={teamName}
teamMembers={teamMembers}
loadingText={t('memberLogStream.logs.loading')}
emptyTitle={t('memberLogStream.logs.emptyTitle')}
emptyDescription={t('memberLogStream.logs.emptyDescription')}
selectionResetKey={`${teamName}:${member.name}`}
boundedHistoryNote={boundedHistoryNote}
forceSegmentHeaders
buildSegmentRenderKey={buildMemberSegmentRenderKey}
getSegmentMetaLabel={getSegmentMetaLabel}
/>
) : (
<MemberRuntimeProcessLogsPanel
enabled={enabled && selectedLogView === 'process'}
loadRuntimeLogTail={loadRuntimeLogTail}
/>
)}
</div>
); );
} };

View file

@ -7,7 +7,7 @@ import {
type MemberLogStreamResponse, type MemberLogStreamResponse,
normalizeMemberLogStreamResponse, normalizeMemberLogStreamResponse,
} from '../../contracts'; } from '../../contracts';
import { normalizeExecutionLogStream } from '../ui/ExecutionLogStreamView'; import { normalizeExecutionLogStream } from '../ui/executionLogStreamUtils';
import type { ResolvedTeamMember } from '@shared/types'; import type { ResolvedTeamMember } from '@shared/types';

View file

@ -1,7 +1,7 @@
export { MemberLogStreamSection } from './adapters/MemberLogStreamSection'; export { MemberLogStreamSection } from './adapters/MemberLogStreamSection';
export { export {
buildDefaultExecutionSegmentRenderKey, buildDefaultExecutionSegmentRenderKey,
ExecutionLogStreamView,
normalizeExecutionLogStream, normalizeExecutionLogStream,
} from './ui/ExecutionLogStreamView'; } from './ui/executionLogStreamUtils';
export { ExecutionLogStreamView } from './ui/ExecutionLogStreamView';
export { isMemberLogStreamUiEnabled } from './utils/featureGates'; export { isMemberLogStreamUiEnabled } from './utils/featureGates';

View file

@ -10,23 +10,14 @@ import {
getThemedText, getThemedText,
} from '@renderer/constants/teamColors'; } from '@renderer/constants/teamColors';
import { useTheme } from '@renderer/hooks/useTheme'; import { useTheme } from '@renderer/hooks/useTheme';
import { asEnhancedChunkArray } from '@renderer/types/data';
import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
import { isLeadMember } from '@shared/utils/leadDetection'; import { isLeadMember } from '@shared/utils/leadDetection';
import { AlertCircle, Clock, FileText, Loader2 } from 'lucide-react'; import { AlertCircle, Clock, FileText, Loader2 } from 'lucide-react';
import type { import { buildDefaultExecutionSegmentRenderKey } from './executionLogStreamUtils';
BoardTaskLogActor,
BoardTaskLogParticipant,
BoardTaskLogSegment,
ResolvedTeamMember,
} from '@shared/types';
interface ExecutionLogStreamLike { import type { ExecutionLogStreamLike } from './executionLogStreamUtils';
participants: BoardTaskLogParticipant[]; import type { BoardTaskLogActor, BoardTaskLogSegment, ResolvedTeamMember } from '@shared/types';
defaultFilter: string;
segments: BoardTaskLogSegment[];
}
interface ParticipantVisual { interface ParticipantVisual {
name: string; name: string;
@ -47,6 +38,8 @@ export interface ExecutionLogStreamViewProps<TStream extends ExecutionLogStreamL
selectionResetKey: string; selectionResetKey: string;
boundedHistoryNote?: string | null; boundedHistoryNote?: string | null;
forceSegmentHeaders?: boolean; forceSegmentHeaders?: boolean;
showIntro?: boolean;
showSegmentParticipantBadge?: boolean;
buildSegmentRenderKey?: (segment: TStream['segments'][number]) => string; buildSegmentRenderKey?: (segment: TStream['segments'][number]) => string;
getSegmentMetaLabel?: (segment: TStream['segments'][number]) => string | null; getSegmentMetaLabel?: (segment: TStream['segments'][number]) => string | null;
} }
@ -72,26 +65,6 @@ function actorLabel(actor: BoardTaskLogActor): string {
return `member session ${actor.sessionId.slice(0, 8)}`; return `member session ${actor.sessionId.slice(0, 8)}`;
} }
export function normalizeExecutionLogStream<TStream extends ExecutionLogStreamLike>(
response: TStream
): TStream {
return {
...response,
segments: response.segments.map((segment) => ({
...segment,
chunks: asEnhancedChunkArray(segment.chunks) ?? [],
})),
};
}
export function buildDefaultExecutionSegmentRenderKey(segment: BoardTaskLogSegment): string {
const firstChunkId = segment.chunks[0]?.id;
if (firstChunkId) {
return `${segment.participantKey}:${firstChunkId}`;
}
return `${segment.participantKey}:${segment.startTimestamp}`;
}
function buildParticipantVisualMap( function buildParticipantVisualMap(
stream: ExecutionLogStreamLike | null, stream: ExecutionLogStreamLike | null,
members: readonly ResolvedTeamMember[], members: readonly ResolvedTeamMember[],
@ -129,14 +102,16 @@ const SegmentMarker = <TSegment extends BoardTaskLogSegment>({
visual, visual,
teamName, teamName,
metaLabel, metaLabel,
showParticipantBadge,
}: { }: {
segment: TSegment; segment: TSegment;
visual?: ParticipantVisual; visual?: ParticipantVisual;
teamName: string; teamName: string;
metaLabel?: string | null; metaLabel?: string | null;
showParticipantBadge: boolean;
}): React.JSX.Element => ( }): React.JSX.Element => (
<div className="mb-2 flex flex-wrap items-center gap-2 text-[10px] text-[var(--color-text-muted)]"> <div className="mb-2 flex flex-wrap items-center gap-2 text-[10px] text-[var(--color-text-muted)]">
{visual ? ( {showParticipantBadge && visual ? (
<MemberBadge <MemberBadge
name={visual.name} name={visual.name}
color={visual.color} color={visual.color}
@ -159,16 +134,24 @@ const SegmentBlock = <TSegment extends BoardTaskLogSegment>({
teamName, teamName,
visual, visual,
metaLabel, metaLabel,
showParticipantBadge,
}: { }: {
segment: TSegment; segment: TSegment;
showHeader: boolean; showHeader: boolean;
teamName: string; teamName: string;
visual?: ParticipantVisual; visual?: ParticipantVisual;
metaLabel?: string | null; metaLabel?: string | null;
showParticipantBadge: boolean;
}): React.JSX.Element => ( }): React.JSX.Element => (
<div className="min-w-0 overflow-hidden"> <div className="min-w-0 overflow-hidden">
{showHeader ? ( {showHeader ? (
<SegmentMarker segment={segment} visual={visual} teamName={teamName} metaLabel={metaLabel} /> <SegmentMarker
segment={segment}
visual={visual}
teamName={teamName}
metaLabel={metaLabel}
showParticipantBadge={showParticipantBadge}
/>
) : null} ) : null}
<MemberExecutionLog <MemberExecutionLog
chunks={segment.chunks} chunks={segment.chunks}
@ -221,7 +204,7 @@ const ParticipantFilterChip = ({
); );
}; };
export function ExecutionLogStreamView<TStream extends ExecutionLogStreamLike>({ export const ExecutionLogStreamView = <TStream extends ExecutionLogStreamLike>({
title, title,
description, description,
stream, stream,
@ -235,9 +218,11 @@ export function ExecutionLogStreamView<TStream extends ExecutionLogStreamLike>({
selectionResetKey, selectionResetKey,
boundedHistoryNote, boundedHistoryNote,
forceSegmentHeaders = false, forceSegmentHeaders = false,
showIntro = true,
showSegmentParticipantBadge = true,
buildSegmentRenderKey, buildSegmentRenderKey,
getSegmentMetaLabel, getSegmentMetaLabel,
}: Readonly<ExecutionLogStreamViewProps<TStream>>): React.JSX.Element { }: Readonly<ExecutionLogStreamViewProps<TStream>>): React.JSX.Element => {
const { t } = useAppTranslation('team'); const { t } = useAppTranslation('team');
const [selectedParticipantKey, setSelectedParticipantKey] = useState<string>('all'); const [selectedParticipantKey, setSelectedParticipantKey] = useState<string>('all');
const appliedSelectionResetKeyRef = useRef<string | null>(null); const appliedSelectionResetKeyRef = useRef<string | null>(null);
@ -291,7 +276,11 @@ export function ExecutionLogStreamView<TStream extends ExecutionLogStreamLike>({
if (loading) { if (loading) {
return ( return (
<div className="space-y-2"> <div className="space-y-2">
<h4 className="text-xs font-semibold uppercase text-[var(--color-text-muted)]">{title}</h4> {showIntro ? (
<h4 className="text-xs font-semibold uppercase text-[var(--color-text-muted)]">
{title}
</h4>
) : null}
<div className="flex items-center gap-2 py-4 text-xs text-[var(--color-text-muted)]"> <div className="flex items-center gap-2 py-4 text-xs text-[var(--color-text-muted)]">
<Loader2 size={12} className="animate-spin" /> <Loader2 size={12} className="animate-spin" />
{loadingText} {loadingText}
@ -303,7 +292,11 @@ export function ExecutionLogStreamView<TStream extends ExecutionLogStreamLike>({
if (error) { if (error) {
return ( return (
<div className="space-y-2"> <div className="space-y-2">
<h4 className="text-xs font-semibold uppercase text-[var(--color-text-muted)]">{title}</h4> {showIntro ? (
<h4 className="text-xs font-semibold uppercase text-[var(--color-text-muted)]">
{title}
</h4>
) : null}
<div className="flex items-center gap-2 py-4 text-xs text-red-400"> <div className="flex items-center gap-2 py-4 text-xs text-red-400">
<AlertCircle size={14} /> <AlertCircle size={14} />
{error} {error}
@ -314,8 +307,14 @@ export function ExecutionLogStreamView<TStream extends ExecutionLogStreamLike>({
return ( return (
<div className="space-y-3"> <div className="space-y-3">
<h4 className="text-xs font-semibold uppercase text-[var(--color-text-muted)]">{title}</h4> {showIntro ? (
<p className="text-xs text-[var(--color-text-muted)]">{description}</p> <>
<h4 className="text-xs font-semibold uppercase text-[var(--color-text-muted)]">
{title}
</h4>
<p className="text-xs text-[var(--color-text-muted)]">{description}</p>
</>
) : null}
{boundedHistoryNote ? ( {boundedHistoryNote ? (
<p className="text-[11px] text-amber-300">{boundedHistoryNote}</p> <p className="text-[11px] text-amber-300">{boundedHistoryNote}</p>
) : null} ) : null}
@ -362,10 +361,11 @@ export function ExecutionLogStreamView<TStream extends ExecutionLogStreamLike>({
teamName={teamName} teamName={teamName}
visual={participantVisuals.get(segment.participantKey)} visual={participantVisuals.get(segment.participantKey)}
metaLabel={getSegmentMetaLabel?.(segment)} metaLabel={getSegmentMetaLabel?.(segment)}
showParticipantBadge={showSegmentParticipantBadge}
/> />
))} ))}
</div> </div>
)} )}
</div> </div>
); );
} };

View file

@ -0,0 +1,29 @@
import { asEnhancedChunkArray } from '@renderer/types/data';
import type { BoardTaskLogParticipant, BoardTaskLogSegment } from '@shared/types';
export interface ExecutionLogStreamLike {
participants: BoardTaskLogParticipant[];
defaultFilter: string;
segments: BoardTaskLogSegment[];
}
export function normalizeExecutionLogStream<TStream extends ExecutionLogStreamLike>(
response: TStream
): TStream {
return {
...response,
segments: response.segments.map((segment) => ({
...segment,
chunks: asEnhancedChunkArray(segment.chunks) ?? [],
})),
};
}
export function buildDefaultExecutionSegmentRenderKey(segment: BoardTaskLogSegment): string {
const firstChunkId = segment.chunks[0]?.id;
if (firstChunkId) {
return `${segment.participantKey}:${firstChunkId}`;
}
return `${segment.participantKey}:${segment.startTimestamp}`;
}

View file

@ -97,40 +97,40 @@ export const ClaudeLogsPanel = ({
<div className="min-w-0 shrink-0">{toolbarControlsStart}</div> <div className="min-w-0 shrink-0">{toolbarControlsStart}</div>
) : null} ) : null}
{data.total > 0 ? ( {data.total > 0 ? (
<> <div
<div className={cn(
className={cn( 'flex items-center gap-1.5 rounded-md border border-[var(--color-border)] bg-transparent px-2 py-1',
'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'
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" placeholder={t('claudeLogs.searchPlaceholder')}
placeholder={t('claudeLogs.searchPlaceholder')} value={searchQuery}
value={searchQuery} onChange={(e) => setSearchQuery(e.target.value)}
onChange={(e) => setSearchQuery(e.target.value)} className="min-w-0 flex-1 bg-transparent text-xs text-[var(--color-text)] placeholder:text-[var(--color-text-muted)] focus:outline-none"
className="min-w-0 flex-1 bg-transparent text-xs text-[var(--color-text)] placeholder:text-[var(--color-text-muted)] focus:outline-none"
/>
{searchQuery && (
<button
type="button"
className="shrink-0 rounded p-0.5 text-[var(--color-text-muted)] transition-colors hover:bg-[var(--color-surface-raised)] hover:text-[var(--color-text)]"
onClick={() => setSearchQuery('')}
aria-label={t('claudeLogs.clearSearch')}
>
<X size={14} />
</button>
)}
</div>
{toolbarAccessory}
<ClaudeLogsFilterPopover
filter={filter}
open={filterOpen}
onOpenChange={setFilterOpen}
onApply={setFilter}
/> />
</> {searchQuery && (
<button
type="button"
className="shrink-0 rounded p-0.5 text-[var(--color-text-muted)] transition-colors hover:bg-[var(--color-surface-raised)] hover:text-[var(--color-text)]"
onClick={() => setSearchQuery('')}
aria-label={t('claudeLogs.clearSearch')}
>
<X size={14} />
</button>
)}
</div>
) : null}
{toolbarAccessory}
{data.total > 0 ? (
<ClaudeLogsFilterPopover
filter={filter}
open={filterOpen}
onOpenChange={setFilterOpen}
onApply={setFilter}
/>
) : null} ) : null}
{pendingNewCount > 0 && ( {pendingNewCount > 0 && (
<Button <Button

View file

@ -102,12 +102,14 @@ const TeamLogsSourceSelector = ({
selectedKey, selectedKey,
onChange, onChange,
className, className,
triggerVariant = 'default',
}: { }: {
leadMember: ResolvedTeamMember; leadMember: ResolvedTeamMember;
members: readonly ResolvedTeamMember[]; members: readonly ResolvedTeamMember[];
selectedKey: TeamLogSourceKey; selectedKey: TeamLogSourceKey;
onChange: (key: TeamLogSourceKey) => void; onChange: (key: TeamLogSourceKey) => void;
className?: string; className?: string;
triggerVariant?: 'default' | 'avatar';
}): React.JSX.Element | null => { }): React.JSX.Element | null => {
const { t } = useAppTranslation('team'); const { t } = useAppTranslation('team');
const sourceMembers = useMemo(() => [leadMember, ...members], [leadMember, members]); const sourceMembers = useMemo(() => [leadMember, ...members], [leadMember, members]);
@ -135,6 +137,7 @@ const TeamLogsSourceSelector = ({
searchPlaceholder={t('claudeLogs.sourceSelect.searchPlaceholder')} searchPlaceholder={t('claudeLogs.sourceSelect.searchPlaceholder')}
emptyMessage={t('claudeLogs.sourceSelect.emptyMessage')} emptyMessage={t('claudeLogs.sourceSelect.emptyMessage')}
ariaLabel={t('claudeLogs.sourceSelect.ariaLabel')} ariaLabel={t('claudeLogs.sourceSelect.ariaLabel')}
triggerVariant={triggerVariant}
getMemberLabel={(member) => getMemberLabel={(member) =>
isLeadMember(member) isLeadMember(member)
? t('claudeLogs.sourceSelect.leadLabel') ? t('claudeLogs.sourceSelect.leadLabel')
@ -146,15 +149,6 @@ const TeamLogsSourceSelector = ({
); );
}; };
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 = ({ const MemberLogsSourcePanel = ({
teamName, teamName,
member, member,
@ -354,7 +348,6 @@ export const ClaudeLogsSection = memo(function ClaudeLogsSection({
{showingLeadLogs && ctrl.lastLogPreview ? ( {showingLeadLogs && ctrl.lastLogPreview ? (
<LogPreviewInline preview={ctrl.lastLogPreview} /> <LogPreviewInline preview={ctrl.lastLogPreview} />
) : null} ) : null}
{!showingLeadLogs && selectedMember ? <MemberSourcePill member={selectedMember} /> : null}
{showHeaderSkeleton ? ( {showHeaderSkeleton ? (
<span className="flex min-w-0 flex-1 items-center gap-1.5 opacity-70"> <span className="flex min-w-0 flex-1 items-center gap-1.5 opacity-70">
<LogsHeaderSkeletonPill className="size-3 rounded" /> <LogsHeaderSkeletonPill className="size-3 rounded" />
@ -365,18 +358,23 @@ export const ClaudeLogsSection = memo(function ClaudeLogsSection({
) : null} ) : null}
</span> </span>
), ),
[ [ctrl.online, ctrl.lastLogPreview, isSidebar, showingLeadLogs, showHeaderSkeleton]
ctrl.online,
ctrl.lastLogPreview,
isSidebar,
selectedMember,
showingLeadLogs,
showHeaderSkeleton,
]
); );
const canOpenFullscreen = showingLeadLogs ? ctrl.data.total > 0 : selectedMember !== null; const canOpenFullscreen = showingLeadLogs ? ctrl.data.total > 0 : selectedMember !== null;
const compactSourceSelector =
selectableMembers.length > 0 ? (
<TeamLogsSourceSelector
leadMember={leadMember}
members={selectableMembers}
selectedKey={effectiveSelectedSourceKey}
onChange={setSelectedSourceKey}
className="shrink-0 pb-0"
triggerVariant="avatar"
/>
) : null;
const afterBadge = showHeaderSkeleton ? ( const afterBadge = showHeaderSkeleton ? (
<> <>
<LogsHeaderSkeletonPill className="h-5 w-14" /> <LogsHeaderSkeletonPill className="h-5 w-14" />
@ -422,12 +420,6 @@ export const ClaudeLogsSection = memo(function ClaudeLogsSection({
contentClassName="pt-0 [overflow-anchor:none]" contentClassName="pt-0 [overflow-anchor:none]"
> >
{/* When dialog is open, hide the compact log viewer to avoid two competing scroll containers */} {/* 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 ? ( {dialogOpen ? (
<div className="flex items-center gap-2 p-2 text-xs text-[var(--color-text-muted)]"> <div className="flex items-center gap-2 p-2 text-xs text-[var(--color-text-muted)]">
<Expand size={12} /> <Expand size={12} />
@ -439,14 +431,18 @@ 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={compactSourceSelector}
/> />
) : selectedMember ? ( ) : selectedMember ? (
<MemberLogsSourcePanel <>
teamName={teamName} <div className="flex justify-end pb-2">{compactSourceSelector}</div>
member={selectedMember} <MemberLogsSourcePanel
enabled={effectiveSelectedSourceKey === memberLogSourceKey(selectedMember.name)} teamName={teamName}
maxHeight={isSidebar ? sidebarViewerMaxHeight : undefined} member={selectedMember}
/> enabled={effectiveSelectedSourceKey === memberLogSourceKey(selectedMember.name)}
maxHeight={isSidebar ? sidebarViewerMaxHeight : undefined}
/>
</>
) : ( ) : (
<div className="py-4 text-center text-xs text-[var(--color-text-muted)]"> <div className="py-4 text-center text-xs text-[var(--color-text-muted)]">
{t('claudeLogs.sourceSelect.selectSourceEmpty')} {t('claudeLogs.sourceSelect.selectSourceEmpty')}

View file

@ -65,10 +65,21 @@ vi.mock('@renderer/components/team/useClaudeLogsController', () => ({
})); }));
vi.mock('@renderer/components/team/ClaudeLogsPanel', () => ({ vi.mock('@renderer/components/team/ClaudeLogsPanel', () => ({
ClaudeLogsPanel: ({ toolbarControlsStart }: { toolbarControlsStart?: React.ReactNode }) => ClaudeLogsPanel: ({
toolbarAccessory,
toolbarControlsStart,
}: {
toolbarAccessory?: React.ReactNode;
toolbarControlsStart?: React.ReactNode;
}) =>
React.createElement( React.createElement(
'div', 'div',
{ 'data-testid': 'lead-logs-panel' }, {
'data-testid': 'lead-logs-panel',
'data-has-toolbar-accessory': toolbarAccessory ? 'true' : 'false',
'data-has-toolbar-controls-start': toolbarControlsStart ? 'true' : 'false',
},
toolbarAccessory,
toolbarControlsStart, toolbarControlsStart,
'lead-panel' 'lead-panel'
), ),
@ -103,6 +114,7 @@ vi.mock('@renderer/components/ui/MemberSelect', () => ({
searchPlaceholder, searchPlaceholder,
emptyMessage, emptyMessage,
ariaLabel, ariaLabel,
triggerVariant,
}: { }: {
members: ResolvedTeamMember[]; members: ResolvedTeamMember[];
value: string | null; value: string | null;
@ -111,6 +123,7 @@ vi.mock('@renderer/components/ui/MemberSelect', () => ({
searchPlaceholder?: string; searchPlaceholder?: string;
emptyMessage?: string; emptyMessage?: string;
ariaLabel?: string; ariaLabel?: string;
triggerVariant?: 'default' | 'avatar';
}) => }) =>
React.createElement( React.createElement(
'div', 'div',
@ -118,6 +131,7 @@ vi.mock('@renderer/components/ui/MemberSelect', () => ({
'data-testid': 'member-select', 'data-testid': 'member-select',
'data-search-placeholder': searchPlaceholder, 'data-search-placeholder': searchPlaceholder,
'data-empty-message': emptyMessage, 'data-empty-message': emptyMessage,
'data-trigger-variant': triggerVariant ?? 'default',
}, },
React.createElement( React.createElement(
'select', 'select',
@ -273,11 +287,22 @@ describe('ClaudeLogsSection source filtering', () => {
expect(memberSelect?.getAttribute('data-search-placeholder')).toBe('Search log sources...'); expect(memberSelect?.getAttribute('data-search-placeholder')).toBe('Search log sources...');
expect(select.getAttribute('data-trigger-aria-label')).toBe('Log source'); expect(select.getAttribute('data-trigger-aria-label')).toBe('Log source');
expect(host.querySelector('[data-testid="lead-logs-panel"]')).not.toBeNull(); expect(host.querySelector('[data-testid="lead-logs-panel"]')).not.toBeNull();
expect(
host
.querySelector('[data-testid="lead-logs-panel"]')
?.getAttribute('data-has-toolbar-accessory')
).toBe('true');
expect(
host
.querySelector('[data-testid="lead-logs-panel"]')
?.getAttribute('data-has-toolbar-controls-start')
).toBe('false');
expect(sectionState.memberLogStreamCalls).toEqual([]); expect(sectionState.memberLogStreamCalls).toEqual([]);
expect(sectionState.controllerCalls.at(-1)).toEqual({ expect(sectionState.controllerCalls.at(-1)).toEqual({
teamName: 'demo-team', teamName: 'demo-team',
enabled: true, enabled: true,
}); });
expect(memberSelect?.getAttribute('data-trigger-variant')).toBe('avatar');
await act(async () => { await act(async () => {
root.unmount(); root.unmount();
@ -379,9 +404,12 @@ describe('ClaudeLogsSection source filtering', () => {
expect(host.querySelector('[data-testid="member-log-stream"]')?.textContent).toBe('Builder'); expect(host.querySelector('[data-testid="member-log-stream"]')?.textContent).toBe('Builder');
const teammateSelect = host.querySelector(
'select[aria-label="Log source"]'
) as HTMLSelectElement;
await act(async () => { await act(async () => {
select.value = 'team-lead'; teammateSelect.value = 'team-lead';
select.dispatchEvent(new Event('change', { bubbles: true })); teammateSelect.dispatchEvent(new Event('change', { bubbles: true }));
await Promise.resolve(); await Promise.resolve();
}); });
@ -436,7 +464,7 @@ describe('ClaudeLogsSection source filtering', () => {
await Promise.resolve(); await Promise.resolve();
}); });
const select = host.querySelector('select[aria-label="Log source"]') as HTMLSelectElement; let select = host.querySelector('select[aria-label="Log source"]') as HTMLSelectElement;
expect(Array.from(select.options).map((option) => option.textContent)).toEqual([ expect(Array.from(select.options).map((option) => option.textContent)).toEqual([
'Lead', 'Lead',
'Builder', 'Builder',
@ -450,6 +478,7 @@ describe('ClaudeLogsSection source filtering', () => {
}); });
expect(host.querySelector('[data-testid="member-log-stream"]')?.textContent).toBe('Builder'); expect(host.querySelector('[data-testid="member-log-stream"]')?.textContent).toBe('Builder');
select = host.querySelector('select[aria-label="Log source"]') as HTMLSelectElement;
await act(async () => { await act(async () => {
select.value = 'Reviewer'; select.value = 'Reviewer';
select.dispatchEvent(new Event('change', { bubbles: true })); select.dispatchEvent(new Event('change', { bubbles: true }));
@ -837,9 +866,11 @@ describe('ClaudeLogsSection source filtering', () => {
expect( expect(
(dialog.querySelector('select[aria-label="Log source"]') as HTMLSelectElement).value (dialog.querySelector('select[aria-label="Log source"]') as HTMLSelectElement).value
).toBe('team-lead'); ).toBe('team-lead');
expect( const dialogMemberSelect = dialog.querySelector(
dialog.querySelector('[data-testid="lead-logs-panel"] [data-testid="member-select"]') '[data-testid="lead-logs-panel"] [data-testid="member-select"]'
).not.toBeNull(); );
expect(dialogMemberSelect).not.toBeNull();
expect(dialogMemberSelect?.getAttribute('data-trigger-variant')).toBe('default');
const dialogSelect = dialog.querySelector( const dialogSelect = dialog.querySelector(
'select[aria-label="Log source"]' 'select[aria-label="Log source"]'