Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
import DownloadIcon from '@mui/icons-material/Download';
import Badge from '@mui/material/Badge';
import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
import FormControl from '@mui/material/FormControl';
import FormControlLabel from '@mui/material/FormControlLabel';
import IconButton from '@mui/material/IconButton';
Expand Down Expand Up @@ -111,20 +112,16 @@ export function TaskLogViewer({ taskHistoryId, taskStatus, height = 480 }: TaskL
const [wrap, setWrap] = useState(false);

const [unreadTypes, setUnreadTypes] = useState<Set<LogType>>(new Set());
const [unreadEvents, setUnreadEvents] = useState(false);
const [unreadSteps, setUnreadSteps] = useState<Set<string>>(new Set());

const prevLogSizesRef = useRef<Record<string, number>>({});
const prevEventCountRef = useRef(0);

// Reset view state when switching to a different task history
useEffect(() => {
setActiveStep(undefined);
setUnreadTypes(new Set());
setUnreadSteps(new Set());
setUnreadEvents(false);
prevLogSizesRef.current = {};
prevEventCountRef.current = 0;
}, [taskHistoryId]);

useEffect(() => {
Expand Down Expand Up @@ -171,29 +168,22 @@ export function TaskLogViewer({ taskHistoryId, taskStatus, height = 480 }: TaskL
}
}, [textByStep, stepOrder, topTab, activeStep]);

// Events unread badge on top tab
useEffect(() => {
const total = Object.values(eventsByStep).reduce((sum, list) => sum + list.length, 0);
if (total > prevEventCountRef.current && topTab !== 'events') {
setUnreadEvents(true);
}
prevEventCountRef.current = total;
}, [eventsByStep, topTab]);

const handleTopTab = (value: TopTab) => {
setTopTab(value);
// Execution events deliberately carry no unread indicator: they arrive over
// SSE on every pushed event, and badging them pulled attention away from
// stdout and stderr, which are the reason the console is open.
if (value === 'events') {
setUnreadEvents(false);
} else {
setUnreadTypes((prev) => {
if (!prev.has(value)) {
return prev;
}
const next = new Set(prev);
next.delete(value);
return next;
});
return;
}
setUnreadTypes((prev) => {
if (!prev.has(value)) {
return prev;
}
const next = new Set(prev);
next.delete(value);
return next;
});
};

const handleStepSelect = (step: string) => {
Expand Down Expand Up @@ -250,12 +240,19 @@ export function TaskLogViewer({ taskHistoryId, taskStatus, height = 480 }: TaskL
sx={{ px: 1, pt: 1, borderBottom: 1, borderColor: 'divider' }}
>
<Tabs
value={topTab}
onChange={(_, v: TopTab) => handleTopTab(v)}
sx={{ flex: 1, minHeight: 40 }}
// The events view is not one of these tabs, so hand MUI `false`
// rather than an out-of-range value: no tab reads as active and no
// out-of-range warning is logged.
value={topTab === 'events' ? false : topTab}
onChange={(_, v: LogType) => handleTopTab(v)}
sx={{ minHeight: 40 }}
>
<Tab
value="stdout"
// MUI gives every tab tabIndex -1 when no tab is selected, which
// would strand keyboard users outside the strip while the events
// view is open. Keep one entry point; arrow keys move from there.
{...(topTab === 'events' ? { tabIndex: 0 } : {})}
label={
<Badge color="primary" variant="dot" invisible={!unreadTypes.has('stdout')}>
<span>stdout</span>
Expand All @@ -270,15 +267,25 @@ export function TaskLogViewer({ taskHistoryId, taskStatus, height = 480 }: TaskL
</Badge>
}
/>
<Tab
value="events"
label={
<Badge color="primary" variant="dot" invisible={!unreadEvents}>
<span>Execution events</span>
</Badge>
}
/>
</Tabs>
{/* Subordinate to the primary tabs, but still one click away. */}
<Button
size="small"
color="inherit"
onClick={() => handleTopTab('events')}
// Not a toggle: a second click is a no-op and the way back is a
// primary tab, so mark it as the current view rather than pressed.
aria-current={topTab === 'events' ? 'true' : undefined}
sx={{
ml: 1,
textTransform: 'none',
color: topTab === 'events' ? 'text.primary' : 'text.secondary',
bgcolor: topTab === 'events' ? 'action.selected' : 'transparent',
}}
>
Execution events
</Button>
<Box sx={{ flex: 1 }} />
<Stack direction="row" alignItems="center" spacing={1} sx={{ pr: 1 }}>
{badgeStatus && <StatusBadge status={badgeStatus} />}
<Tooltip
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@

import { act, render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { afterEach, beforeEach, describe, expect, it, onTestFinished, vi } from 'vitest';
import {
flushPromises,
mockStreamFetch,
Expand Down Expand Up @@ -75,6 +75,25 @@ describe('TaskLogViewer', () => {
return handle;
}

function getEventHandle(id: string): SseStreamHandle {
const handle = mock.pending
.filter((h) => h.url.split('?')[0] === `/stream-logs/${id}/execution-events`)
.at(-1);
if (!handle) {
throw new Error(`No execution-events stream handle for ${id}`);
}
return handle;
}

/** The primary stdout/stderr strip; the per-step strip is a second tablist. */
function getPrimaryTabList() {
return screen.getAllByRole('tablist')[0];
}

function getEventsButton() {
return screen.getByRole('button', { name: /execution events/i });
}

function getTailSelect() {
return screen.getByRole('combobox');
}
Expand Down Expand Up @@ -231,7 +250,7 @@ describe('TaskLogViewer', () => {
expect(dotAfter?.classList.contains('MuiBadge-invisible')).toBe(true);
});

it('switches pane when clicking the Execution events tab', async () => {
it('opens the execution events panel from the demoted control in one interaction', async () => {
render(
<QueryWrapper>
<TaskLogViewer taskHistoryId="1" taskStatus="RUNNING" />
Expand All @@ -246,10 +265,144 @@ describe('TaskLogViewer', () => {
await waitFor(() => expect(screen.getByTestId('log-output')).toBeInTheDocument());

const user = userEvent.setup();
await user.click(screen.getByRole('tab', { name: /execution events/i }));
await user.click(getEventsButton());

expect(screen.queryByTestId('log-output')).not.toBeInTheDocument();
expect(screen.getByText(/no execution events yet/i)).toBeInTheDocument();
expect(getEventsButton()).toHaveAttribute('aria-current', 'true');

// Clicking again is a stable no-op; the way back is a primary tab.
await user.click(getEventsButton());
expect(screen.getByText(/no execution events yet/i)).toBeInTheDocument();
expect(getEventsButton()).toHaveAttribute('aria-current', 'true');
});

it('renders exactly two primary tabs, stdout and stderr', async () => {
render(
<QueryWrapper>
<TaskLogViewer taskHistoryId="30" taskStatus="RUNNING" />
</QueryWrapper>,
);
await flushPromises();

const tabs = within(getPrimaryTabList()).getAllByRole('tab');
expect(tabs.map((tab) => tab.textContent)).toEqual(['stdout', 'stderr']);
expect(screen.queryByRole('tab', { name: /execution events/i })).toBeNull();
});

it('shows no unread indicator while a running task pushes execution events', async () => {
const { container } = render(
<QueryWrapper>
<TaskLogViewer taskHistoryId="31" taskStatus="RUNNING" />
</QueryWrapper>,
);
await flushPromises();

const eventHandle = getEventHandle('31');
act(() => {
eventHandle.pushMessage({
timestamp: '2026-04-28T10:00:00Z',
type: 'STEP_STARTED',
description: 'setup started',
step: 'setup',
});
});
await flushPromises();

expect(getEventsButton().querySelector('.MuiBadge-dot')).toBeNull();
// Nothing anywhere in the console badges while the events view is closed.
expect(container.querySelectorAll('.MuiBadge-dot:not(.MuiBadge-invisible)')).toHaveLength(0);

// The event really did arrive while the view was closed.
const user = userEvent.setup();
await user.click(getEventsButton());
expect(screen.getByText(/setup started/)).toBeInTheDocument();
});

it('leaves the primary tabs unselected for the events view and returns in one click', async () => {
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {});
onTestFinished(() => consoleError.mockRestore());

render(
<QueryWrapper>
<TaskLogViewer taskHistoryId="32" taskStatus="RUNNING" />
</QueryWrapper>,
);
await flushPromises();

const handle = getHandle('32');
act(() => {
handle.pushMessage({ msg: 'out\n', step: 'setup', type: 'stdout', offset: 1 });
});
await waitFor(() => expect(screen.getByTestId('log-output')).toBeInTheDocument());

const user = userEvent.setup();
await user.click(getEventsButton());

const tabs = within(getPrimaryTabList()).getAllByRole('tab');
expect(tabs.every((tab) => tab.getAttribute('aria-selected') === 'false')).toBe(true);
// MUI warns when Tabs `value` is not one of its children; passing false must not.
const tabsWarnings = consoleError.mock.calls.filter((call) =>
call.some((arg) => typeof arg === 'string' && /Tabs/.test(arg)),
);
expect(tabsWarnings).toEqual([]);

// An unselected strip must still be reachable by keyboard.
expect(screen.getByRole('tab', { name: /stdout/i })).toHaveAttribute('tabindex', '0');

await user.click(screen.getByRole('tab', { name: /stdout/i }));
expect(screen.getByTestId('log-output')).toBeInTheDocument();
expect(getEventsButton()).not.toHaveAttribute('aria-current');
});

it('keeps events search and per-step grouping from the demoted entry point', async () => {
render(
<QueryWrapper>
<TaskLogViewer taskHistoryId="33" taskStatus="RUNNING" />
</QueryWrapper>,
);
await flushPromises();

const handle = getHandle('33');
act(() => {
handle.pushMessage({ msg: 'out\n', step: 'log-step', type: 'stdout', offset: 1 });
});

const eventHandle = getEventHandle('33');
act(() => {
eventHandle.pushMessage({
timestamp: '2026-04-28T10:00:00Z',
type: 'STEP_STARTED',
description: 'setup started',
step: 'setup',
});
eventHandle.pushMessage({
timestamp: '2026-04-28T10:00:05Z',
type: 'STEP_FINISHED',
description: 'build finished',
step: 'build',
});
});
await flushPromises();

const user = userEvent.setup();
await user.click(getEventsButton());

// The step strip lists the execution-event steps, not the log steps.
await waitFor(() => {
const stepTabs = within(screen.getAllByRole('tablist')[1]).getAllByRole('tab');
expect(stepTabs.map((tab) => tab.textContent)).toEqual(['setup', 'build']);
});
expect(screen.getByText(/setup started/)).toBeInTheDocument();

// Selecting a step filters the events shown.
await user.click(screen.getByRole('tab', { name: 'build' }));
expect(screen.getByText(/build finished/)).toBeInTheDocument();
expect(screen.queryByText(/setup started/)).toBeNull();

// The search box still narrows within the selected step.
await user.type(screen.getByPlaceholderText(/search events/i), 'nothing-matches');
expect(screen.getByText(/no events match/i)).toBeInTheDocument();
});

it('triggers a blob download when the download button is clicked', async () => {
Expand Down