feat(logs): compact team log source controls
This commit is contained in:
parent
79faaf1f9f
commit
62ef88300a
8 changed files with 195 additions and 186 deletions
|
|
@ -1,15 +1,13 @@
|
|||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useEffect, useMemo } from 'react';
|
||||
|
||||
import { useAppTranslation } from '@features/localization/renderer';
|
||||
import { api } from '@renderer/api';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { selectResolvedMembersForTeamName } from '@renderer/store/slices/teamSlice';
|
||||
|
||||
import { useMemberLogStream } from '../hooks/useMemberLogStream';
|
||||
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';
|
||||
|
||||
interface MemberLogStreamSectionProps {
|
||||
|
|
@ -19,10 +17,6 @@ interface MemberLogStreamSectionProps {
|
|||
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 {
|
||||
const details = [segment.source.label];
|
||||
if (segment.source.laneId) {
|
||||
|
|
@ -38,24 +32,15 @@ function buildMemberSegmentRenderKey(segment: MemberLogStreamSegment): string {
|
|||
return `${segment.id}:${firstChunkId ?? segment.startTimestamp}`;
|
||||
}
|
||||
|
||||
export function MemberLogStreamSection({
|
||||
export const MemberLogStreamSection = ({
|
||||
teamName,
|
||||
member,
|
||||
enabled = true,
|
||||
onInitialLoadErrorChange,
|
||||
}: Readonly<MemberLogStreamSectionProps>): React.JSX.Element {
|
||||
}: Readonly<MemberLogStreamSectionProps>): React.JSX.Element => {
|
||||
const { t } = useAppTranslation('team');
|
||||
const [selectedLogView, setSelectedLogView] = useState<'execution' | 'process'>('execution');
|
||||
const teamMembers = useStore((s) => selectResolvedMembersForTeamName(s, teamName));
|
||||
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 boundedHistoryNote = useMemo(() => {
|
||||
if (!stream) return null;
|
||||
|
|
@ -70,56 +55,24 @@ export function MemberLogStreamSection({
|
|||
}, [hasInitialLoadError, onInitialLoadErrorChange]);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="inline-flex rounded-xl bg-[var(--color-surface-subtle)] p-1">
|
||||
<button
|
||||
type="button"
|
||||
className={`rounded-lg px-3 py-1.5 text-sm font-medium transition-colors ${
|
||||
selectedLogView === 'execution'
|
||||
? 'bg-[var(--color-surface)] text-[var(--color-text)] shadow-sm'
|
||||
: 'text-[var(--color-text-muted)] hover:text-[var(--color-text)]'
|
||||
}`}
|
||||
onClick={() => setSelectedLogView('execution')}
|
||||
>
|
||||
{t('memberLogStream.tabs.execution')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`rounded-lg px-3 py-1.5 text-sm font-medium transition-colors ${
|
||||
selectedLogView === 'process'
|
||||
? 'bg-[var(--color-surface)] text-[var(--color-text)] shadow-sm'
|
||||
: '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>
|
||||
<ExecutionLogStreamView
|
||||
title=""
|
||||
description=""
|
||||
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
|
||||
showIntro={false}
|
||||
showSegmentParticipantBadge={false}
|
||||
buildSegmentRenderKey={buildMemberSegmentRenderKey}
|
||||
getSegmentMetaLabel={getSegmentMetaLabel}
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import {
|
|||
type MemberLogStreamResponse,
|
||||
normalizeMemberLogStreamResponse,
|
||||
} from '../../contracts';
|
||||
import { normalizeExecutionLogStream } from '../ui/ExecutionLogStreamView';
|
||||
import { normalizeExecutionLogStream } from '../ui/executionLogStreamUtils';
|
||||
|
||||
import type { ResolvedTeamMember } from '@shared/types';
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
export { MemberLogStreamSection } from './adapters/MemberLogStreamSection';
|
||||
export {
|
||||
buildDefaultExecutionSegmentRenderKey,
|
||||
ExecutionLogStreamView,
|
||||
normalizeExecutionLogStream,
|
||||
} from './ui/ExecutionLogStreamView';
|
||||
} from './ui/executionLogStreamUtils';
|
||||
export { ExecutionLogStreamView } from './ui/ExecutionLogStreamView';
|
||||
export { isMemberLogStreamUiEnabled } from './utils/featureGates';
|
||||
|
|
|
|||
|
|
@ -10,23 +10,14 @@ import {
|
|||
getThemedText,
|
||||
} from '@renderer/constants/teamColors';
|
||||
import { useTheme } from '@renderer/hooks/useTheme';
|
||||
import { asEnhancedChunkArray } from '@renderer/types/data';
|
||||
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
||||
import { isLeadMember } from '@shared/utils/leadDetection';
|
||||
import { AlertCircle, Clock, FileText, Loader2 } from 'lucide-react';
|
||||
|
||||
import type {
|
||||
BoardTaskLogActor,
|
||||
BoardTaskLogParticipant,
|
||||
BoardTaskLogSegment,
|
||||
ResolvedTeamMember,
|
||||
} from '@shared/types';
|
||||
import { buildDefaultExecutionSegmentRenderKey } from './executionLogStreamUtils';
|
||||
|
||||
interface ExecutionLogStreamLike {
|
||||
participants: BoardTaskLogParticipant[];
|
||||
defaultFilter: string;
|
||||
segments: BoardTaskLogSegment[];
|
||||
}
|
||||
import type { ExecutionLogStreamLike } from './executionLogStreamUtils';
|
||||
import type { BoardTaskLogActor, BoardTaskLogSegment, ResolvedTeamMember } from '@shared/types';
|
||||
|
||||
interface ParticipantVisual {
|
||||
name: string;
|
||||
|
|
@ -47,6 +38,8 @@ export interface ExecutionLogStreamViewProps<TStream extends ExecutionLogStreamL
|
|||
selectionResetKey: string;
|
||||
boundedHistoryNote?: string | null;
|
||||
forceSegmentHeaders?: boolean;
|
||||
showIntro?: boolean;
|
||||
showSegmentParticipantBadge?: boolean;
|
||||
buildSegmentRenderKey?: (segment: TStream['segments'][number]) => string;
|
||||
getSegmentMetaLabel?: (segment: TStream['segments'][number]) => string | null;
|
||||
}
|
||||
|
|
@ -72,26 +65,6 @@ function actorLabel(actor: BoardTaskLogActor): string {
|
|||
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(
|
||||
stream: ExecutionLogStreamLike | null,
|
||||
members: readonly ResolvedTeamMember[],
|
||||
|
|
@ -129,14 +102,16 @@ const SegmentMarker = <TSegment extends BoardTaskLogSegment>({
|
|||
visual,
|
||||
teamName,
|
||||
metaLabel,
|
||||
showParticipantBadge,
|
||||
}: {
|
||||
segment: TSegment;
|
||||
visual?: ParticipantVisual;
|
||||
teamName: string;
|
||||
metaLabel?: string | null;
|
||||
showParticipantBadge: boolean;
|
||||
}): React.JSX.Element => (
|
||||
<div className="mb-2 flex flex-wrap items-center gap-2 text-[10px] text-[var(--color-text-muted)]">
|
||||
{visual ? (
|
||||
{showParticipantBadge && visual ? (
|
||||
<MemberBadge
|
||||
name={visual.name}
|
||||
color={visual.color}
|
||||
|
|
@ -159,16 +134,24 @@ const SegmentBlock = <TSegment extends BoardTaskLogSegment>({
|
|||
teamName,
|
||||
visual,
|
||||
metaLabel,
|
||||
showParticipantBadge,
|
||||
}: {
|
||||
segment: TSegment;
|
||||
showHeader: boolean;
|
||||
teamName: string;
|
||||
visual?: ParticipantVisual;
|
||||
metaLabel?: string | null;
|
||||
showParticipantBadge: boolean;
|
||||
}): React.JSX.Element => (
|
||||
<div className="min-w-0 overflow-hidden">
|
||||
{showHeader ? (
|
||||
<SegmentMarker segment={segment} visual={visual} teamName={teamName} metaLabel={metaLabel} />
|
||||
<SegmentMarker
|
||||
segment={segment}
|
||||
visual={visual}
|
||||
teamName={teamName}
|
||||
metaLabel={metaLabel}
|
||||
showParticipantBadge={showParticipantBadge}
|
||||
/>
|
||||
) : null}
|
||||
<MemberExecutionLog
|
||||
chunks={segment.chunks}
|
||||
|
|
@ -221,7 +204,7 @@ const ParticipantFilterChip = ({
|
|||
);
|
||||
};
|
||||
|
||||
export function ExecutionLogStreamView<TStream extends ExecutionLogStreamLike>({
|
||||
export const ExecutionLogStreamView = <TStream extends ExecutionLogStreamLike>({
|
||||
title,
|
||||
description,
|
||||
stream,
|
||||
|
|
@ -235,9 +218,11 @@ export function ExecutionLogStreamView<TStream extends ExecutionLogStreamLike>({
|
|||
selectionResetKey,
|
||||
boundedHistoryNote,
|
||||
forceSegmentHeaders = false,
|
||||
showIntro = true,
|
||||
showSegmentParticipantBadge = true,
|
||||
buildSegmentRenderKey,
|
||||
getSegmentMetaLabel,
|
||||
}: Readonly<ExecutionLogStreamViewProps<TStream>>): React.JSX.Element {
|
||||
}: Readonly<ExecutionLogStreamViewProps<TStream>>): React.JSX.Element => {
|
||||
const { t } = useAppTranslation('team');
|
||||
const [selectedParticipantKey, setSelectedParticipantKey] = useState<string>('all');
|
||||
const appliedSelectionResetKeyRef = useRef<string | null>(null);
|
||||
|
|
@ -291,7 +276,11 @@ export function ExecutionLogStreamView<TStream extends ExecutionLogStreamLike>({
|
|||
if (loading) {
|
||||
return (
|
||||
<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)]">
|
||||
<Loader2 size={12} className="animate-spin" />
|
||||
{loadingText}
|
||||
|
|
@ -303,7 +292,11 @@ export function ExecutionLogStreamView<TStream extends ExecutionLogStreamLike>({
|
|||
if (error) {
|
||||
return (
|
||||
<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">
|
||||
<AlertCircle size={14} />
|
||||
{error}
|
||||
|
|
@ -314,8 +307,14 @@ export function ExecutionLogStreamView<TStream extends ExecutionLogStreamLike>({
|
|||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<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>
|
||||
{showIntro ? (
|
||||
<>
|
||||
<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 ? (
|
||||
<p className="text-[11px] text-amber-300">{boundedHistoryNote}</p>
|
||||
) : null}
|
||||
|
|
@ -362,10 +361,11 @@ export function ExecutionLogStreamView<TStream extends ExecutionLogStreamLike>({
|
|||
teamName={teamName}
|
||||
visual={participantVisuals.get(segment.participantKey)}
|
||||
metaLabel={getSegmentMetaLabel?.(segment)}
|
||||
showParticipantBadge={showSegmentParticipantBadge}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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}`;
|
||||
}
|
||||
|
|
@ -97,40 +97,40 @@ export const ClaudeLogsPanel = ({
|
|||
<div className="min-w-0 shrink-0">{toolbarControlsStart}</div>
|
||||
) : null}
|
||||
{data.total > 0 ? (
|
||||
<>
|
||||
<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"
|
||||
placeholder={t('claudeLogs.searchPlaceholder')}
|
||||
value={searchQuery}
|
||||
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"
|
||||
/>
|
||||
{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}
|
||||
<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"
|
||||
placeholder={t('claudeLogs.searchPlaceholder')}
|
||||
value={searchQuery}
|
||||
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"
|
||||
/>
|
||||
</>
|
||||
{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}
|
||||
{pendingNewCount > 0 && (
|
||||
<Button
|
||||
|
|
|
|||
|
|
@ -102,12 +102,14 @@ const TeamLogsSourceSelector = ({
|
|||
selectedKey,
|
||||
onChange,
|
||||
className,
|
||||
triggerVariant = 'default',
|
||||
}: {
|
||||
leadMember: ResolvedTeamMember;
|
||||
members: readonly ResolvedTeamMember[];
|
||||
selectedKey: TeamLogSourceKey;
|
||||
onChange: (key: TeamLogSourceKey) => void;
|
||||
className?: string;
|
||||
triggerVariant?: 'default' | 'avatar';
|
||||
}): React.JSX.Element | null => {
|
||||
const { t } = useAppTranslation('team');
|
||||
const sourceMembers = useMemo(() => [leadMember, ...members], [leadMember, members]);
|
||||
|
|
@ -135,6 +137,7 @@ const TeamLogsSourceSelector = ({
|
|||
searchPlaceholder={t('claudeLogs.sourceSelect.searchPlaceholder')}
|
||||
emptyMessage={t('claudeLogs.sourceSelect.emptyMessage')}
|
||||
ariaLabel={t('claudeLogs.sourceSelect.ariaLabel')}
|
||||
triggerVariant={triggerVariant}
|
||||
getMemberLabel={(member) =>
|
||||
isLeadMember(member)
|
||||
? 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 = ({
|
||||
teamName,
|
||||
member,
|
||||
|
|
@ -354,7 +348,6 @@ export const ClaudeLogsSection = memo(function ClaudeLogsSection({
|
|||
{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" />
|
||||
|
|
@ -365,18 +358,23 @@ export const ClaudeLogsSection = memo(function ClaudeLogsSection({
|
|||
) : null}
|
||||
</span>
|
||||
),
|
||||
[
|
||||
ctrl.online,
|
||||
ctrl.lastLogPreview,
|
||||
isSidebar,
|
||||
selectedMember,
|
||||
showingLeadLogs,
|
||||
showHeaderSkeleton,
|
||||
]
|
||||
[ctrl.online, ctrl.lastLogPreview, isSidebar, showingLeadLogs, showHeaderSkeleton]
|
||||
);
|
||||
|
||||
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 ? (
|
||||
<>
|
||||
<LogsHeaderSkeletonPill className="h-5 w-14" />
|
||||
|
|
@ -422,12 +420,6 @@ 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} />
|
||||
|
|
@ -439,14 +431,18 @@ export const ClaudeLogsSection = memo(function ClaudeLogsSection({
|
|||
viewerClassName={cn('max-h-[213px]', isSidebar && 'cli-logs-sidebar')}
|
||||
viewerMaxHeight={isSidebar ? sidebarViewerMaxHeight : undefined}
|
||||
compactMetaInTooltip={isSidebar}
|
||||
toolbarAccessory={compactSourceSelector}
|
||||
/>
|
||||
) : selectedMember ? (
|
||||
<MemberLogsSourcePanel
|
||||
teamName={teamName}
|
||||
member={selectedMember}
|
||||
enabled={effectiveSelectedSourceKey === memberLogSourceKey(selectedMember.name)}
|
||||
maxHeight={isSidebar ? sidebarViewerMaxHeight : undefined}
|
||||
/>
|
||||
<>
|
||||
<div className="flex justify-end pb-2">{compactSourceSelector}</div>
|
||||
<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)]">
|
||||
{t('claudeLogs.sourceSelect.selectSourceEmpty')}
|
||||
|
|
|
|||
|
|
@ -65,10 +65,21 @@ vi.mock('@renderer/components/team/useClaudeLogsController', () => ({
|
|||
}));
|
||||
|
||||
vi.mock('@renderer/components/team/ClaudeLogsPanel', () => ({
|
||||
ClaudeLogsPanel: ({ toolbarControlsStart }: { toolbarControlsStart?: React.ReactNode }) =>
|
||||
ClaudeLogsPanel: ({
|
||||
toolbarAccessory,
|
||||
toolbarControlsStart,
|
||||
}: {
|
||||
toolbarAccessory?: React.ReactNode;
|
||||
toolbarControlsStart?: React.ReactNode;
|
||||
}) =>
|
||||
React.createElement(
|
||||
'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,
|
||||
'lead-panel'
|
||||
),
|
||||
|
|
@ -103,6 +114,7 @@ vi.mock('@renderer/components/ui/MemberSelect', () => ({
|
|||
searchPlaceholder,
|
||||
emptyMessage,
|
||||
ariaLabel,
|
||||
triggerVariant,
|
||||
}: {
|
||||
members: ResolvedTeamMember[];
|
||||
value: string | null;
|
||||
|
|
@ -111,6 +123,7 @@ vi.mock('@renderer/components/ui/MemberSelect', () => ({
|
|||
searchPlaceholder?: string;
|
||||
emptyMessage?: string;
|
||||
ariaLabel?: string;
|
||||
triggerVariant?: 'default' | 'avatar';
|
||||
}) =>
|
||||
React.createElement(
|
||||
'div',
|
||||
|
|
@ -118,6 +131,7 @@ vi.mock('@renderer/components/ui/MemberSelect', () => ({
|
|||
'data-testid': 'member-select',
|
||||
'data-search-placeholder': searchPlaceholder,
|
||||
'data-empty-message': emptyMessage,
|
||||
'data-trigger-variant': triggerVariant ?? 'default',
|
||||
},
|
||||
React.createElement(
|
||||
'select',
|
||||
|
|
@ -273,11 +287,22 @@ describe('ClaudeLogsSection source filtering', () => {
|
|||
expect(memberSelect?.getAttribute('data-search-placeholder')).toBe('Search log sources...');
|
||||
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"]')
|
||||
?.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.controllerCalls.at(-1)).toEqual({
|
||||
teamName: 'demo-team',
|
||||
enabled: true,
|
||||
});
|
||||
expect(memberSelect?.getAttribute('data-trigger-variant')).toBe('avatar');
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
|
|
@ -379,9 +404,12 @@ describe('ClaudeLogsSection source filtering', () => {
|
|||
|
||||
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 () => {
|
||||
select.value = 'team-lead';
|
||||
select.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
teammateSelect.value = 'team-lead';
|
||||
teammateSelect.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
|
|
@ -436,7 +464,7 @@ describe('ClaudeLogsSection source filtering', () => {
|
|||
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([
|
||||
'Lead',
|
||||
'Builder',
|
||||
|
|
@ -450,6 +478,7 @@ describe('ClaudeLogsSection source filtering', () => {
|
|||
});
|
||||
expect(host.querySelector('[data-testid="member-log-stream"]')?.textContent).toBe('Builder');
|
||||
|
||||
select = host.querySelector('select[aria-label="Log source"]') as HTMLSelectElement;
|
||||
await act(async () => {
|
||||
select.value = 'Reviewer';
|
||||
select.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
|
|
@ -837,9 +866,11 @@ describe('ClaudeLogsSection source filtering', () => {
|
|||
expect(
|
||||
(dialog.querySelector('select[aria-label="Log source"]') as HTMLSelectElement).value
|
||||
).toBe('team-lead');
|
||||
expect(
|
||||
dialog.querySelector('[data-testid="lead-logs-panel"] [data-testid="member-select"]')
|
||||
).not.toBeNull();
|
||||
const dialogMemberSelect = dialog.querySelector(
|
||||
'[data-testid="lead-logs-panel"] [data-testid="member-select"]'
|
||||
);
|
||||
expect(dialogMemberSelect).not.toBeNull();
|
||||
expect(dialogMemberSelect?.getAttribute('data-trigger-variant')).toBe('default');
|
||||
|
||||
const dialogSelect = dialog.querySelector(
|
||||
'select[aria-label="Log source"]'
|
||||
|
|
|
|||
Loading…
Reference in a new issue