fix(member-log-stream): simplify member logs view
This commit is contained in:
parent
42a3fd6834
commit
877a81439b
3 changed files with 32 additions and 167 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) {
|
||||||
|
|
@ -45,17 +39,8 @@ export const MemberLogStreamSection = ({
|
||||||
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;
|
||||||
|
|
@ -71,56 +56,24 @@ export const MemberLogStreamSection = ({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="inline-flex rounded-md bg-[var(--color-surface-subtle)] p-0.5">
|
<ExecutionLogStreamView
|
||||||
<button
|
title={t('memberLogStream.logs.title')}
|
||||||
type="button"
|
stream={stream}
|
||||||
className={`rounded px-2.5 py-1 text-xs 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 px-2.5 py-1 text-xs 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
|
|
||||||
showSegmentParticipantBadge={false}
|
|
||||||
buildSegmentRenderKey={buildMemberSegmentRenderKey}
|
|
||||||
getSegmentMetaLabel={getSegmentMetaLabel}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<MemberRuntimeProcessLogsPanel
|
|
||||||
enabled={enabled && selectedLogView === 'process'}
|
|
||||||
loadRuntimeLogTail={loadRuntimeLogTail}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,7 @@ interface ParticipantVisual {
|
||||||
|
|
||||||
export interface ExecutionLogStreamViewProps<TStream extends ExecutionLogStreamLike> {
|
export interface ExecutionLogStreamViewProps<TStream extends ExecutionLogStreamLike> {
|
||||||
title: string;
|
title: string;
|
||||||
description: string;
|
description?: string;
|
||||||
stream: TStream | null;
|
stream: TStream | null;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
|
|
@ -312,7 +312,9 @@ export const ExecutionLogStreamView = <TStream extends ExecutionLogStreamLike>({
|
||||||
<h4 className="text-xs font-semibold uppercase text-[var(--color-text-muted)]">
|
<h4 className="text-xs font-semibold uppercase text-[var(--color-text-muted)]">
|
||||||
{title}
|
{title}
|
||||||
</h4>
|
</h4>
|
||||||
<p className="text-xs text-[var(--color-text-muted)]">{description}</p>
|
{description ? (
|
||||||
|
<p className="text-xs text-[var(--color-text-muted)]">{description}</p>
|
||||||
|
) : null}
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
{boundedHistoryNote ? (
|
{boundedHistoryNote ? (
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,17 @@
|
||||||
/* eslint-disable security/detect-non-literal-fs-filename -- Fixture E2E reads a repo fixture and writes temp JSONL. */
|
/* eslint-disable security/detect-non-literal-fs-filename -- Fixture E2E reads a repo fixture and writes temp JSONL. */
|
||||||
import { readFile, rm, stat, writeFile, mkdtemp } from 'fs/promises';
|
|
||||||
import os from 'os';
|
|
||||||
import path from 'path';
|
|
||||||
import React, { act } from 'react';
|
import React, { act } from 'react';
|
||||||
import { createRoot } from 'react-dom/client';
|
import { createRoot } from 'react-dom/client';
|
||||||
|
|
||||||
|
import { mkdtemp, readFile, rm, stat, writeFile } from 'fs/promises';
|
||||||
|
import os from 'os';
|
||||||
|
import path from 'path';
|
||||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
import { GetMemberLogStreamUseCase } from '../../../../../src/features/member-log-stream/core/application/use-cases/GetMemberLogStreamUseCase';
|
|
||||||
import {
|
import {
|
||||||
type MemberLogStreamRequestOptions,
|
type MemberLogStreamRequestOptions,
|
||||||
type MemberLogStreamResponse,
|
type MemberLogStreamResponse,
|
||||||
type MemberRuntimeLogTailOptions,
|
|
||||||
type MemberRuntimeLogTailResponse,
|
|
||||||
} from '../../../../../src/features/member-log-stream/contracts';
|
} from '../../../../../src/features/member-log-stream/contracts';
|
||||||
|
import { GetMemberLogStreamUseCase } from '../../../../../src/features/member-log-stream/core/application/use-cases/GetMemberLogStreamUseCase';
|
||||||
import { ClaudeMemberTranscriptStreamSource } from '../../../../../src/features/member-log-stream/main/adapters/output/sources/ClaudeMemberTranscriptStreamSource';
|
import { ClaudeMemberTranscriptStreamSource } from '../../../../../src/features/member-log-stream/main/adapters/output/sources/ClaudeMemberTranscriptStreamSource';
|
||||||
import { OpenCodeMemberRuntimeStreamSource } from '../../../../../src/features/member-log-stream/main/adapters/output/sources/OpenCodeMemberRuntimeStreamSource';
|
import { OpenCodeMemberRuntimeStreamSource } from '../../../../../src/features/member-log-stream/main/adapters/output/sources/OpenCodeMemberRuntimeStreamSource';
|
||||||
import { BoardTaskExactLogChunkBuilder } from '../../../../../src/main/services/team/taskLogs/exact/BoardTaskExactLogChunkBuilder';
|
import { BoardTaskExactLogChunkBuilder } from '../../../../../src/main/services/team/taskLogs/exact/BoardTaskExactLogChunkBuilder';
|
||||||
|
|
@ -44,14 +43,6 @@ const apiState = {
|
||||||
) => Promise<MemberLogStreamResponse>
|
) => Promise<MemberLogStreamResponse>
|
||||||
>(),
|
>(),
|
||||||
setMemberLogStreamTracking: vi.fn<(teamName: string, enabled: boolean) => Promise<void>>(),
|
setMemberLogStreamTracking: vi.fn<(teamName: string, enabled: boolean) => Promise<void>>(),
|
||||||
getMemberRuntimeLogTail:
|
|
||||||
vi.fn<
|
|
||||||
(
|
|
||||||
teamName: string,
|
|
||||||
memberName: string,
|
|
||||||
options: MemberRuntimeLogTailOptions
|
|
||||||
) => Promise<MemberRuntimeLogTailResponse>
|
|
||||||
>(),
|
|
||||||
onTeamChange: vi.fn<(callback: (event: unknown, data: unknown) => void) => () => void>(),
|
onTeamChange: vi.fn<(callback: (event: unknown, data: unknown) => void) => () => void>(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -63,9 +54,6 @@ vi.mock('@renderer/api', () => ({
|
||||||
setMemberLogStreamTracking: (
|
setMemberLogStreamTracking: (
|
||||||
...args: Parameters<typeof apiState.setMemberLogStreamTracking>
|
...args: Parameters<typeof apiState.setMemberLogStreamTracking>
|
||||||
) => apiState.setMemberLogStreamTracking(...args),
|
) => apiState.setMemberLogStreamTracking(...args),
|
||||||
getMemberRuntimeLogTail: (
|
|
||||||
...args: Parameters<typeof apiState.getMemberRuntimeLogTail>
|
|
||||||
) => apiState.getMemberRuntimeLogTail(...args),
|
|
||||||
},
|
},
|
||||||
teams: {
|
teams: {
|
||||||
onTeamChange: (...args: Parameters<typeof apiState.onTeamChange>) =>
|
onTeamChange: (...args: Parameters<typeof apiState.onTeamChange>) =>
|
||||||
|
|
@ -279,7 +267,6 @@ describe('MemberLogStreamSection real fixture e2e', () => {
|
||||||
document.body.innerHTML = '';
|
document.body.innerHTML = '';
|
||||||
apiState.getMemberLogStream.mockReset();
|
apiState.getMemberLogStream.mockReset();
|
||||||
apiState.setMemberLogStreamTracking.mockReset();
|
apiState.setMemberLogStreamTracking.mockReset();
|
||||||
apiState.getMemberRuntimeLogTail.mockReset();
|
|
||||||
apiState.onTeamChange.mockReset();
|
apiState.onTeamChange.mockReset();
|
||||||
vi.unstubAllGlobals();
|
vi.unstubAllGlobals();
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
|
|
@ -294,13 +281,6 @@ describe('MemberLogStreamSection real fixture e2e', () => {
|
||||||
stubMatchMedia();
|
stubMatchMedia();
|
||||||
apiState.onTeamChange.mockImplementation(() => () => undefined);
|
apiState.onTeamChange.mockImplementation(() => () => undefined);
|
||||||
apiState.setMemberLogStreamTracking.mockResolvedValue(undefined);
|
apiState.setMemberLogStreamTracking.mockResolvedValue(undefined);
|
||||||
apiState.getMemberRuntimeLogTail.mockResolvedValue({
|
|
||||||
kind: 'stdout',
|
|
||||||
content: 'process stdout line',
|
|
||||||
truncated: false,
|
|
||||||
bytesRead: 19,
|
|
||||||
missing: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
const { useCase, getOpenCodeTranscript, findRecentMemberLogFileRefsByMember } =
|
const { useCase, getOpenCodeTranscript, findRecentMemberLogFileRefsByMember } =
|
||||||
await createFixtureUseCase();
|
await createFixtureUseCase();
|
||||||
|
|
@ -339,8 +319,9 @@ describe('MemberLogStreamSection real fixture e2e', () => {
|
||||||
content.includes('Member-wide Claude transcript final note for Jack.')
|
content.includes('Member-wide Claude transcript final note for Jack.')
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(text).toContain('Logs');
|
expect(text).not.toContain('Member-scoped transcript and runtime logs');
|
||||||
expect(text).toContain('Member-scoped transcript and runtime logs');
|
expect(text).not.toContain('Execution');
|
||||||
|
expect(text).not.toContain('Process');
|
||||||
expect(text).toContain('Claude transcript');
|
expect(text).toContain('Claude transcript');
|
||||||
expect(text).toContain('OpenCode runtime');
|
expect(text).toContain('OpenCode runtime');
|
||||||
expect(text).toContain('Calculator behavior');
|
expect(text).toContain('Calculator behavior');
|
||||||
|
|
@ -396,75 +377,4 @@ describe('MemberLogStreamSection real fixture e2e', () => {
|
||||||
expect(apiState.setMemberLogStreamTracking).toHaveBeenCalledWith(TEAM_NAME, true);
|
expect(apiState.setMemberLogStreamTracking).toHaveBeenCalledWith(TEAM_NAME, true);
|
||||||
expect(apiState.setMemberLogStreamTracking).toHaveBeenCalledWith(TEAM_NAME, false);
|
expect(apiState.setMemberLogStreamTracking).toHaveBeenCalledWith(TEAM_NAME, false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('loads bounded process runtime logs after switching the Logs UI to Process', async () => {
|
|
||||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
|
||||||
stubMatchMedia();
|
|
||||||
apiState.onTeamChange.mockImplementation(() => () => undefined);
|
|
||||||
apiState.setMemberLogStreamTracking.mockResolvedValue(undefined);
|
|
||||||
apiState.getMemberLogStream.mockResolvedValue({
|
|
||||||
participants: [],
|
|
||||||
defaultFilter: 'all',
|
|
||||||
segments: [],
|
|
||||||
source: 'member_empty',
|
|
||||||
coverage: [],
|
|
||||||
warnings: [],
|
|
||||||
truncated: false,
|
|
||||||
generatedAt: GENERATED_AT,
|
|
||||||
metadata: {
|
|
||||||
scannedTranscriptFileCount: 0,
|
|
||||||
includedTranscriptFileCount: 0,
|
|
||||||
droppedSegmentCount: 0,
|
|
||||||
droppedChunkCount: 0,
|
|
||||||
droppedMessageCount: 0,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
apiState.getMemberRuntimeLogTail.mockResolvedValue({
|
|
||||||
kind: 'stdout',
|
|
||||||
content: 'process stdout line',
|
|
||||||
truncated: false,
|
|
||||||
bytesRead: 19,
|
|
||||||
missing: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
const host = document.createElement('div');
|
|
||||||
document.body.appendChild(host);
|
|
||||||
const root = createRoot(host);
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
root.render(
|
|
||||||
React.createElement(
|
|
||||||
TooltipProvider,
|
|
||||||
null,
|
|
||||||
React.createElement(MemberLogStreamSection, {
|
|
||||||
teamName: TEAM_NAME,
|
|
||||||
member: createMember(),
|
|
||||||
})
|
|
||||||
)
|
|
||||||
);
|
|
||||||
await flushMicrotasks();
|
|
||||||
});
|
|
||||||
|
|
||||||
const processButton = Array.from(host.querySelectorAll('button')).find(
|
|
||||||
(button) => button.textContent?.trim() === 'Process'
|
|
||||||
) as HTMLButtonElement | undefined;
|
|
||||||
expect(processButton).toBeTruthy();
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
processButton?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
|
||||||
await flushAsyncWork();
|
|
||||||
});
|
|
||||||
|
|
||||||
await waitForText(host, (content) => content.includes('process stdout line'));
|
|
||||||
expect(apiState.getMemberRuntimeLogTail).toHaveBeenCalledWith(TEAM_NAME, MEMBER_NAME, {
|
|
||||||
kind: 'stdout',
|
|
||||||
maxBytes: 128 * 1024,
|
|
||||||
forceRefresh: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
root.unmount();
|
|
||||||
await flushMicrotasks();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue