Skip to content

Comments

Runs page redesign#7873

Draft
diegoimbert wants to merge 50 commits intomainfrom
di/runs-page-redesign
Draft

Runs page redesign#7873
diegoimbert wants to merge 50 commits intomainfrom
di/runs-page-redesign

Conversation

@diegoimbert
Copy link
Contributor

@diegoimbert diegoimbert commented Feb 10, 2026

  • Easier to read layout visually
  • Micro interactions (Ctrl A, Shift/Cmd click multi-select, right click to go to job/script/cancel/rerun)
  • Merged From/To picker with ManualDatePicker (now called TimeframeSelect)
  • Batch actions button still here for discoverability / non-power-users

In future PR: Replace top filters with standard searchbar filter component

Screen.Recording.2026-02-10.at.12.13.05.mov

@diegoimbert diegoimbert marked this pull request as ready for review February 10, 2026 11:27
@claude
Copy link
Contributor

claude bot commented Feb 10, 2026

Claude finished @diegoimbert's task in 5m 11s —— View job


Review: PR #7873 — Runs page redesign

  • Read and analyze all changed files
  • Review code quality, bugs, performance, and security
  • Post inline comments for specific issues
  • Write comprehensive review summary with testing instructions

Summary

This is a substantial UX improvement to the Runs page. The PR consolidates the date picker into a unified TimeframeSelect component, adds power-user interactions (Ctrl+A, Shift-click, Cmd-click multi-select, right-click context menu), and cleans up the layout. The code is well-structured overall, and the component decomposition is clean.

What looks good

  • TimeframeSelect is a well-designed component that merges the from/to pickers with preset timeframes into a single, cohesive UI element. The URL sync via useUrlSyncedTimeframe is a nice touch for shareable links.
  • useKeyPressed hook is well-implemented with proper cleanup (blur/visibility handlers to prevent stuck keys).
  • Right-click context menu via RightClickPopover + DropdownMenu is a clean separation of concerns.
  • StaleWhileLoading class is a smart pattern to prevent flickering during data transitions.
  • Grid CSS in runs-grid.css is thorough — handling all combinations of labels/selection/tags avoids layout shifts.
  • Clean deletion of ManuelDatePicker, DropdownSelect, and RunsBatchActionsDropdown — no dangling imports.
  • The formatDateRange utility is a good addition for intelligent date display.

Issues found

Bugs:

  1. formatDateRange parameter naming is inverted — The function takes (before, after) but the caller passes (minTs, maxTs). The "Before" / "After" labels in the output will be semantically wrong when only one date is set. See inline comment.
  2. selectableJobs reactivity on hover — The derived selectableJobs depends on hoveredDropdownAction, which changes as the user hovers over context menu items. This means the "select all" checkbox behavior changes while hovering, which could confuse users. See inline comment.

Code quality:
3. Leftover console.log in RightClickPopover.svelte:19. See inline comment.
4. JSON.stringify for array comparison in RunsTable.svelte:494 — use a simple length + element check instead. See inline comment.
5. any type for icon in DropdownMenu.svelte — could be typed more specifically for better IDE support.

Performance:
6. O(n*m) lookup in selectedIdsPossibleActions — For each selected ID, a linear scan through flatJobs is performed. With Ctrl+A on large datasets, this could be slow. Consider building a Map. See inline comment.

UX considerations:
7. Context menu viewport overflowRightClickPopover doesn't clamp position to viewport boundaries; right-clicking near screen edges could push the menu off-screen. See inline comment.
8. Click-outside on context menu prevents interactionpreventDefault + stopPropagation on click-outside means users must click twice to interact with elements outside the menu. See inline comment.

No security issues found

The changes are purely frontend UI/UX with no new API calls, auth changes, or user input handling that would introduce security concerns.


Testing instructions

Navigate to the Runs page in the Windmill app. Verify the new layout renders correctly with the runs table, duration chart, and filters visible. Test the TimeframeSelect component by clicking the calendar icon next to the refresh button — select preset timeframes (e.g., "Within last 5 minutes") and set custom from/to dates using the manual date pickers in the dropdown. Verify the URL updates with min_ts/max_ts/timeframe query params when selections change.

Test multi-select interactions: click a job row to select it, then Shift+click another row to select a range, Ctrl/Cmd+click to toggle individual rows, and Ctrl+A (after clicking inside the table) to select all jobs. Right-click on a selected job to see the context menu with options like "Show run details", "Go to script/flow page", "Run again", and "Cancel". Verify these actions work correctly. Test multi-select right-click: select multiple jobs, right-click, and verify the "Run again" and "Cancel" options show counts of eligible jobs.

Use the Batch actions dropdown at the bottom of the table to enter cancel/rerun selection modes — verify the checkbox column appears, the "select all" checkbox works, and the exit button returns to normal mode. Test the bottom bar toggles (Schedules, Planned later, Auto-refresh) and the per-page selector.

Also verify the Service Logs page (/service_logs) still works correctly with the refactored TimeframeSelect component.

selectedIds = []
loadingSelectedIds = true

const loadingToast = sendUserToast('Loading job ids', 'info')
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Typographical note: Consider capitalizing 'IDs' in the toast message ('Loading job IDs') for consistency.

}

async function onReRunSelectedJobs() {
async function onReRunSelectedJobs(batchReRunOptions: BatchReRunOptions) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Lexicographical note: The function names 'onRerunAllJobsMatchingFilters' (line 401) and 'onReRunSelectedJobs' (line 420) use inconsistent capitalization for 'rerun'. Consider standardizing the naming convention.

import Select from './select/Select.svelte'
import { goto } from '$lib/navigation'
import { page } from '$app/stores'
import { watch } from 'runed'
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is a potential typographical error in the new import statement. Please verify that 'runed' is the correct module name. If it's incorrect (e.g., if you intended to import from something like 'routed' or another module), please update it accordingly.

return
}
return sendUserToast('Only scripts can be used as a input schema', true)
sendUserToast('Only scripts can be used as a input schema', true)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Typographical/grammatical suggestion: Consider changing the toast message from "Only scripts can be used as a input schema" to "Only scripts can be used as an input schema".

<Button
variant="accent"
onClick={() => onConfirm(options)}
endIcon={{ icon: RefreshCwIcon }}>Run again</Button
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's a potential typo at the closing Button tag. It appears that the closing tag is split across two lines ("</Button" followed by a standalone ">"). Please merge these into a single properly closed tag (e.g., "") to ensure correct rendering.

<ToggleButton
value="dependencies"
label="Deps"
showTooltipIcon
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Typographical error in tooltip: In 'Deploying a script, flow or an app launch a dependency job that create and then attach the lockfile to the deployed item. This mechanism ensure that logic is always executed with the exact same direct and indirect dependencies.', consider updating to something like 'Deploying a script, flow or an app launches a dependency job that creates and then attaches the lockfile to the deployed item. This mechanism ensures that logic is always executed with the exact same direct and indirect dependencies.'

}
{
label: 'Sync',
value: 'deploymentcallbacks',
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Typographical error in tooltip: The phrase 'configured in the the workspace settings' has a duplicate 'the'. Please remove one instance.

>
Load next {perPage} jobs
</button>
{/if}</div
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's a minor typographical formatting issue in the snippet footer block. The closing tag is split over two lines (line 543 shows </div and line 544 contains >), which can be confusing. It might be cleaner to merge it into a single line as </div>.

} else {
if (batchRerunOptionsIsOpen) batchRerunOptionsIsOpen = false
if (
JSON.stringify(selectedIds) !== JSON.stringify([jobOrDate.job.id]) ||
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using JSON.stringify for array comparison is fragile and slower than necessary. Consider a simpler check:

if (selectedIds.length !== 1 || selectedIds[0] !== jobOrDate.job.id || selectedWorkspace !== jobOrDate.job.workspace_id) {

Comment on lines +239 to +243
let selectedIdsPossibleActions = $derived.by(() => {
const cancellableJobIds: string[] = []
const rerunnableJobIds: string[] = []
for (const jobId of selectedIds) {
const job = flatJobs?.find(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Performance concern: selectedIdsPossibleActions iterates through all selectedIds and for each does a linear flatJobs?.find(...). With large selection sets (e.g., Ctrl+A on 1000+ jobs), this is O(n*m). Consider building a Map from flatJobs for O(1) lookups, or caching the lookup map when flatJobs changes.

return true
}

let selectableJobs = $derived(jobs?.filter(jobIsSelectable) ?? [])
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: selectableJobs filters from jobs (the raw list), but jobIsSelectable checks rightClickPopover?.isOpen() and hoveredDropdownAction, which are transient UI states. Since this is a $derived, it re-computes reactively — but hoveredDropdownAction changes as the user hovers over menu items, which will cause the "select all" checkbox to toggle different sets of jobs while the user is just hovering. This could lead to confusing selection behavior where hovering over "Cancel" in the context menu silently changes which jobs would be selected by the "select all" checkbox in the header.

Comment on lines +446 to +448
class={twMerge(
'flex flex-row items-center h-full w-full select-none transition-opacity',
nonSelectable || (rightClickPopover?.isOpen() && !selected)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The opacity dimming when the context menu is open (rightClickPopover?.isOpen() && !selected) creates a nice visual effect, but rightClickPopover?.isOpen() is a function call inside a reactive template. Since _isOpen is a $state variable in RightClickPopover, this should still trigger reactivity correctly through the component method — but it's worth verifying this works as expected in practice since method calls on component refs don't always trigger Svelte 5 fine-grained reactivity.

Comment on lines +2 to +5
function computeMinMaxInc(inc: number) {
let minTs = new Date(new Date().getTime() - inc).toISOString()
let maxTs = new Date().toISOString()
return { minTs, maxTs }
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: computeMinMaxInc computes maxTs as "now" at the time of invocation, but the dynamic timeframes call this lazily via computeMinMax(). This is correct behavior — just noting that consecutive calls within the same frame will produce slightly different maxTs values. If precision matters, consider caching now for a single computation cycle.

url.searchParams.set('timeframe', timeframe.label)
else url.searchParams.delete('timeframe')

history.replaceState(null, '', url)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Calling history.replaceState directly bypasses SvelteKit's router. This is intentional to avoid full navigation, but be aware it won't trigger SvelteKit's beforeNavigate/afterNavigate lifecycle hooks. This seems acceptable for query-param-only updates.

Comment on lines +41 to +42
export const runsTimeframes: Timeframe[] = [
{ label: 'Latest runs', computeMinMax: () => ({ minTs: null, maxTs: null }) },
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: The runsTimeframes and serviceLogsTimeframes arrays share most of their items. Consider extracting shared timeframe entries and composing these arrays to avoid duplication:

const sharedTimeframes = [
  { label: 'Within last 5 minutes', computeMinMax: () => computeMinMaxInc(5 * 60 * 1000) },
  // ...
]
export const runsTimeframes = [
  { label: 'Latest runs', computeMinMax: () => ({ minTs: null, maxTs: null }) },
  { label: 'Within 30 seconds', computeMinMax: () => computeMinMaxInc(30 * 1000) },
  { label: 'Within last minute', computeMinMax: () => computeMinMaxInc(60 * 1000) },
  ...sharedTimeframes,
].map(item => ({ ...item, type: 'dynamic' }))

Comment on lines +61 to +65
if (key === 'A' && (keysPressed.Control || keysPressed.Meta)) {
if (batchRerunOptionsIsOpen) return
e.preventDefault()
e.stopPropagation()
selectedIds = flatJobs
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ctrl+A selects all jobs in the virtual list, but flatJobs includes date separators. The filter is correct here, but note that selectedIds could end up containing IDs of jobs not visible due to virtualization. This is fine for the batch action use case, but worth documenting.

onClick?: () => void
onHover?: (hover: boolean) => void
}
export type Props = {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The icon type is any — consider typing this more specifically (e.g., typeof import('lucide-svelte').Icon or Component) for better type safety and IDE support.

}
}}
class="absolute left-0 top-0 z-[9999] w-fit"
style="transform: translate({mousePos.x + 2}px, {mousePos.y + 2}px)"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The popover is positioned using transform: translate(...) from the top-left corner. If the user right-clicks near the bottom or right edge of the viewport, the menu could overflow off-screen. Consider adding viewport boundary clamping logic (e.g., checking if mousePos.x + menuWidth > window.innerWidth).

Comment on lines +34 to +37
onClickOutside: (e) => {
_isOpen = false
e.preventDefault()
e.stopPropagation()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Calling e.preventDefault() and e.stopPropagation() on click-outside events prevents normal interactions (like clicking buttons elsewhere on the page) when the context menu is open. The user would need to click twice — once to dismiss the menu, once to actually interact. This is a common UX tradeoff but can feel unintuitive. Consider only preventing default if the click target is within the runs table area.

Comment on lines 1594 to 1596
export function formatDateRange(
before: string | Date | undefined,
after: string | Date | undefined
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: The parameter names before and after are semantically inverted from how the function is used. The caller passes (minTs, maxTs) — where minTs is the "from" date and maxTs is the "to" date. But the function labels the first parameter before and the second after, which reads as "before = end of range, after = start of range" — the opposite convention.

The internal logic shows this confusion: when only the first parameter is set, it renders "Before {date}" (implying an upper bound), but the caller passes minTs (a lower bound) as the first argument.

Consider renaming to from/to or minDate/maxDate to match the calling convention.

end: string | Date | undefined
): string {
const now = new Date()
const startDate = start ? new Date(start) : undefined
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider validating the Date conversions for 'start' and 'end'. Without a check for invalid date strings, the function may output 'Invalid Date'.

}

// Both dates provided
if (startDate && endDate) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add a guard for the case where the start date is later than the end date. Handling this (by swapping or throwing an error) could avoid confusing outputs.

const startDate = start ? new Date(start) : undefined
const endDate = end ? new Date(end) : undefined

// Helper to format time only
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The helper functions (formatTime, formatDateNoYear, formatDateWithYear, isSameDay, isSameYear) are redefined on each call. Consider moving them outside the function for improved performance if this function is called frequently.

diegoimbert and others added 2 commits February 10, 2026 14:20
Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com>
} else {
if (batchRerunOptionsIsOpen) batchRerunOptionsIsOpen = false
if (
selectedIds.length !== 1 ||
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good improvement: the new checks replace JSON.stringify() with direct array length and element comparison for a single selection. This is clearer and more efficient. Note that this assumes that when a job is selected, selectedIds is always a single-element array. If multi-select logic changes later, the condition may need to be revisited.

jobKindsCat = v
}
{
label: 'Sync',
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Typo fix: Removed duplicate 'the' from the tooltip string.

@@ -957,7 +868,7 @@
value="deploymentcallbacks"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Typo fix: Removed duplicate 'the' from the tooltip for 'deploymentcallbacks'.

label: 'Sync',
value: 'deploymentcallbacks',
tooltip:
'Sync jobs that are triggered on every script deployment to sync the workspace with the Git repository configured in the workspace settings'
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Typographical error: The duplicate 'the' in the tooltip text has been removed. Please double-check all similar strings for consistency.

@diegoimbert diegoimbert marked this pull request as draft February 10, 2026 14:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant