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 { 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>
|
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
|
||||||
|
|
@ -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>
|
<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
|
||||||
|
|
|
||||||
|
|
@ -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')}
|
||||||
|
|
|
||||||
|
|
@ -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"]'
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue