diff --git a/frontend/packages/framework/src/components/TaskLogViewer/TaskLogViewer.tsx b/frontend/packages/framework/src/components/TaskLogViewer/TaskLogViewer.tsx index 8ec0b52a7b..2d575ef95a 100644 --- a/frontend/packages/framework/src/components/TaskLogViewer/TaskLogViewer.tsx +++ b/frontend/packages/framework/src/components/TaskLogViewer/TaskLogViewer.tsx @@ -33,7 +33,7 @@ import Typography from '@mui/material/Typography'; import { useEffect, useMemo, useRef, useState } from 'react'; import { useExecutionEvents } from '../../hooks/useExecutionEvents'; import { useLogDownload } from '../../hooks/useLogDownload'; -import { useTaskLogs, type LogType } from '../../hooks/useTaskLogs'; +import { useTaskLogs, type LogType, type StepText } from '../../hooks/useTaskLogs'; import { ExecutionEventsPanel } from './ExecutionEventsPanel'; import { LogOutputPane } from './LogOutputPane'; import { LogStepTabs } from './LogStepTabs'; @@ -53,6 +53,21 @@ export const LOG_TAIL_LINE_OPTIONS = [ export type LogTailLineChoice = (typeof LOG_TAIL_LINE_OPTIONS)[number]['value']; +const NUMERIC_LOG_TAIL_OPTIONS = LOG_TAIL_LINE_OPTIONS.map((option) => Number(option.value)).filter( + (value) => Number.isFinite(value), +); + +/** + * Smallest numeric cap on offer. A proven-complete log at or below this size + * looks identical under every option, so the select has nothing left to do. + * Derived from the options list so changing the list moves the threshold. + * + * Falls back to 0 when the list holds no numeric option: `Math.min()` of an + * empty list is Infinity, which would hide the select for every finished task. + */ +const SMALLEST_LOG_TAIL_OPTION = + NUMERIC_LOG_TAIL_OPTIONS.length > 0 ? Math.min(...NUMERIC_LOG_TAIL_OPTIONS) : 0; + const LOG_TAIL_STORAGE_KEY = 'sep.taskLogViewer.tail'; const DEFAULT_LOG_TAIL_CHOICE = '1000' satisfies LogTailLineChoice; @@ -85,6 +100,45 @@ function isRunningStatus(status?: string): boolean { return (status ?? '').toLowerCase() === 'running'; } +/** + * Line count, saturating at `limit + 1`. Callers only need to know whether a + * pane is over the threshold, so a large log stops being scanned as soon as it + * provably is — no full pass over megabytes of "All lines" output. + */ +function countLinesUpTo(text: string, limit: number): number { + if (text === '') { + return 0; + } + let lines = 0; + for (let index = 0; index < text.length; index += 1) { + if (text[index] === '\n') { + lines += 1; + if (lines > limit) { + return lines; + } + } + } + // A trailing fragment without its newline is still a line on screen. + return text.endsWith('\n') ? lines : lines + 1; +} + +/** + * Largest line count across every step and both streams, saturating at + * `limit + 1`. The line-cap decision uses this rather than the visible pane so + * the control does not appear and disappear as the user moves between step or + * stream tabs. + */ +function maxPaneLineCountUpTo(textByStep: Record, limit: number): number { + let max = 0; + for (const pane of Object.values(textByStep)) { + max = Math.max(max, countLinesUpTo(pane.stdout, limit), countLinesUpTo(pane.stderr, limit)); + if (max > limit) { + return max; + } + } + return max; +} + function resolveBadgeStatus( finishStatus: ReturnType['finishStatus'], error: ReturnType['error'], @@ -100,7 +154,7 @@ export function TaskLogViewer({ taskHistoryId, taskStatus, height = 480 }: TaskL const [logTailChoice, setLogTailChoice] = useState(readStoredLogTailChoice); const tailLines = logTailChoiceToParam(logTailChoice); const effectiveTailLines = running ? undefined : tailLines; - const { textByStep, stepOrder, finishStatus, error } = useTaskLogs( + const { textByStep, stepOrder, streamStatus, finishStatus, error } = useTaskLogs( taskHistoryId, effectiveTailLines, ); @@ -242,6 +296,25 @@ export function TaskLogViewer({ taskHistoryId, taskStatus, height = 480 }: TaskL const badgeStatus = resolveBadgeStatus(finishStatus, error); + // Hide the line cap once a finished history has streamed a log that is + // provably complete and short enough that every option would show the same + // thing. Gated on the terminal stream status so the control does not flicker + // while lines are still arriving. + const showLogTailSelect = useMemo(() => { + if (running || streamStatus !== 'finished') { + return true; + } + // Saturated at one over the threshold: any pane above it keeps the select + // regardless of the requested cap, so the exact count no longer matters. + const maxLines = maxPaneLineCountUpTo(textByStep, SMALLEST_LOG_TAIL_OPTION); + if (maxLines > SMALLEST_LOG_TAIL_OPTION) { + return true; + } + // A pane sitting exactly at the requested cap may have been trimmed + // server-side, so only a count strictly below the cap proves completeness. + return effectiveTailLines !== undefined && maxLines >= effectiveTailLines; + }, [running, streamStatus, textByStep, effectiveTailLines]); + return ( {badgeStatus && } - - - - - + {showLogTailSelect && ( + + + + + + )} setWrap(checked)} /> diff --git a/frontend/packages/framework/src/components/TaskLogViewer/__tests__/TaskLogViewer.test.tsx b/frontend/packages/framework/src/components/TaskLogViewer/__tests__/TaskLogViewer.test.tsx index b2de1c8fcc..0705fe7cb4 100644 --- a/frontend/packages/framework/src/components/TaskLogViewer/__tests__/TaskLogViewer.test.tsx +++ b/frontend/packages/framework/src/components/TaskLogViewer/__tests__/TaskLogViewer.test.tsx @@ -79,6 +79,14 @@ describe('TaskLogViewer', () => { return screen.getByRole('combobox'); } + function queryTailSelect() { + return screen.queryByRole('combobox'); + } + + function lines(count: number): string { + return 'x\n'.repeat(count); + } + function fetchUrl(callIndex: number): string { const url = mock.fetchSpy.mock.calls[callIndex]?.[0]; return typeof url === 'string' ? url : (url as URL).href; @@ -202,6 +210,127 @@ describe('TaskLogViewer', () => { expect(screen.getByText(/no output yet/i)).toBeInTheDocument(); }); + it('hides the line cap when a finished log is provably shorter than the smallest option', async () => { + render( + + + , + ); + await flushPromises(); + + const handle = getHandle('20'); + act(() => { + handle.pushMessage({ msg: lines(3), step: 'setup', type: 'stdout', offset: 1 }); + handle.pushMessage({ msg: lines(2), step: 'setup', type: 'stderr', offset: 1 }); + handle.pushNamed('finish', { status: 'success' }); + }); + + await waitFor(() => expect(queryTailSelect()).toBeNull()); + // Hiding the control leaves the stored choice alone for the next log. + expect(globalThis.localStorage.getItem('sep.taskLogViewer.tail')).toBeNull(); + // The request still carried the stored cap — size is unknown until it arrives. + expect(logFetchUrls()[0]).toBe('/stream-logs/20?tail=1000'); + }); + + it('hides the line cap when a short finished log was fetched with All lines', async () => { + globalThis.localStorage.setItem('sep.taskLogViewer.tail', 'all'); + + render( + + + , + ); + await flushPromises(); + + const handle = getHandle('21'); + act(() => { + handle.pushMessage({ msg: lines(4), step: 'setup', type: 'stdout', offset: 1 }); + handle.pushNamed('finish', { status: 'success' }); + }); + + await waitFor(() => expect(queryTailSelect()).toBeNull()); + }); + + it('keeps the line cap when a finished pane sits exactly at the requested cap', async () => { + globalThis.localStorage.setItem('sep.taskLogViewer.tail', '100'); + + render( + + + , + ); + await flushPromises(); + expect(logFetchUrls()[0]).toBe('/stream-logs/22?tail=100'); + + const handle = getHandle('22'); + act(() => { + handle.pushMessage({ msg: lines(100), step: 'setup', type: 'stdout', offset: 1 }); + handle.pushNamed('finish', { status: 'success' }); + }); + await waitFor(() => expect(screen.getByTestId('log-output')).toBeInTheDocument()); + + // 100 lines under a tail=100 request may have been trimmed server-side. + expect(getTailSelect()).toBeInTheDocument(); + }); + + it('keeps the line cap when a finished log exceeds the smallest option', async () => { + render( + + + , + ); + await flushPromises(); + + const handle = getHandle('23'); + act(() => { + handle.pushMessage({ msg: lines(2), step: 'setup', type: 'stdout', offset: 1 }); + handle.pushMessage({ msg: lines(150), step: 'build', type: 'stderr', offset: 1 }); + handle.pushNamed('finish', { status: 'success' }); + }); + await waitFor(() => expect(screen.getByTestId('log-output')).toBeInTheDocument()); + + // Decision uses the largest pane, not the visible one. + expect(getTailSelect()).toBeInTheDocument(); + }); + + it('keeps the line cap when a short finished log ends in a stream error', async () => { + render( + + + , + ); + await flushPromises(); + + const handle = getHandle('25'); + act(() => { + handle.pushMessage({ msg: lines(2), step: 'setup', type: 'stdout', offset: 1 }); + handle.pushNamed('sep-error', { detail: 'gateway blew up' }); + }); + await waitFor(() => expect(screen.getByText('gateway blew up')).toBeInTheDocument()); + + // An aborted stream never proves the log is complete. + expect(getTailSelect()).toBeInTheDocument(); + }); + + it('keeps the line cap visible but disabled for a running task with a short log', async () => { + render( + + + , + ); + await flushPromises(); + + const handle = getHandle('24'); + act(() => { + handle.pushMessage({ msg: lines(2), step: 'setup', type: 'stdout', offset: 1 }); + handle.pushNamed('finish', { status: 'success' }); + }); + await waitFor(() => expect(screen.getByText('Done')).toBeInTheDocument()); + + expect(getTailSelect()).toBeInTheDocument(); + expect(getTailSelect()).toHaveAttribute('aria-disabled', 'true'); + }); + it('marks the stderr top tab as unread when stderr arrives while on stdout', async () => { render(