Merge "Fix download dialog input UI in dark theme"
diff --git a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts
index ce633c8..b079dec 100644
--- a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts
@@ -2152,7 +2152,6 @@
       const state: ChecksTabState = {};
       detail.tabState = {checksTab: state};
       if (this.params?.filter) state.filter = this.params.filter;
-      if (this.params?.select) state.select = this.params.select;
       if (this.params?.attempt) state.attempt = this.params.attempt;
     }
 
diff --git a/polygerrit-ui/app/elements/checks/gr-checks-results.ts b/polygerrit-ui/app/elements/checks/gr-checks-results.ts
index 578e771..cb299d0 100644
--- a/polygerrit-ui/app/elements/checks/gr-checks-results.ts
+++ b/polygerrit-ui/app/elements/checks/gr-checks-results.ts
@@ -7,7 +7,14 @@
 import {classMap} from 'lit/directives/class-map.js';
 import {repeat} from 'lit/directives/repeat.js';
 import {ifDefined} from 'lit/directives/if-defined.js';
-import {LitElement, css, html, PropertyValues, TemplateResult} from 'lit';
+import {
+  LitElement,
+  css,
+  html,
+  PropertyValues,
+  TemplateResult,
+  nothing,
+} from 'lit';
 import {customElement, property, query, state} from 'lit/decorators.js';
 import './gr-checks-action';
 import './gr-hovercard-run';
@@ -23,6 +30,13 @@
 import {sharedStyles} from '../../styles/shared-styles';
 import {CheckRun, RunResult} from '../../models/checks/checks-model';
 import {
+  ALL_ATTEMPTS,
+  AttemptChoice,
+  attemptChoiceLabel,
+  isAttemptChoice,
+  LATEST_ATTEMPT,
+  sortAttemptChoices,
+  stringToAttemptChoice,
   allResults,
   createFixAction,
   firstPrimaryLink,
@@ -34,7 +48,7 @@
   secondaryLinks,
   tooltipForLink,
 } from '../../models/checks/checks-util';
-import {assertIsDefined, assert} from '../../utils/common-util';
+import {assertIsDefined, assert, unique} from '../../utils/common-util';
 import {modifierPressed, toggleClass, whenVisible} from '../../utils/dom-util';
 import {durationString} from '../../utils/date-util';
 import {charsOnly} from '../../utils/string-util';
@@ -66,6 +80,8 @@
 import {when} from 'lit/directives/when.js';
 import {KnownExperimentId} from '../../services/flags/flags';
 import {HtmlPatched} from '../../utils/lit-util';
+import {DropdownItem} from '../shared/gr-dropdown-list/gr-dropdown-list';
+import './gr-checks-attempt';
 
 /**
  * Firing this event sets the regular expression of the results filter.
@@ -104,6 +120,9 @@
   @state()
   latestPatchNum?: PatchSetNumber;
 
+  @state()
+  selectedAttempt: AttemptChoice = LATEST_ATTEMPT;
+
   private getChangeModel = resolve(this, changeModelToken);
 
   private getChecksModel = resolve(this, checksModelToken);
@@ -124,6 +143,11 @@
       () => this.getChangeModel().latestPatchNum$,
       x => (this.latestPatchNum = x)
     );
+    subscribe(
+      this,
+      () => this.getChecksModel().checksSelectedAttemptNumber$,
+      x => (this.selectedAttempt = x)
+    );
   }
 
   static override get styles() {
@@ -353,6 +377,7 @@
             >
               ${this.result.checkName}
             </div>
+            ${this.renderAttempt()}
             <div class="space"></div>
           </div>
         </td>
@@ -394,6 +419,11 @@
     `;
   }
 
+  private renderAttempt() {
+    if (this.selectedAttempt !== ALL_ATTEMPTS) return nothing;
+    return html`<gr-checks-attempt .run=${this.result}></gr-checks-attempt>`;
+  }
+
   private renderExpanded() {
     if (!this.isExpanded) return;
     return html`<gr-result-expanded
@@ -771,12 +801,8 @@
   @state()
   latestPatchsetNumber: PatchSetNumber | undefined = undefined;
 
-  /** Maps checkName to selected attempt number. `undefined` means `latest`. */
-  @property({attribute: false})
-  selectedAttempts: Map<string, number | undefined> = new Map<
-    string,
-    number | undefined
-  >();
+  @state()
+  selectedAttempt: AttemptChoice = LATEST_ATTEMPT;
 
   /** Maintains the state of which result sections should show all results. */
   @state()
@@ -828,6 +854,11 @@
     );
     subscribe(
       this,
+      () => this.getChecksModel().checksSelectedAttemptNumber$,
+      x => (this.selectedAttempt = x)
+    );
+    subscribe(
+      this,
       () => this.getChangeModel().latestPatchNum$,
       x => (this.latestPatchsetNumber = x)
     );
@@ -899,8 +930,10 @@
         .notLatest .headerTopRow .right .goToLatest {
           display: block;
         }
+        .headerTopRow .right > * {
+          margin-left: var(--spacing-m);
+        }
         .headerTopRow .right .goToLatest gr-button {
-          margin-right: var(--spacing-m);
           --gr-button-padding: var(--spacing-s) var(--spacing-m);
         }
         .headerBottomRow gr-icon {
@@ -1071,6 +1104,7 @@
       header: true,
       notLatest: !!this.checksPatchsetNumber,
     };
+    const attemptItems = this.createAttemptDropdownItems();
     return html`
       <div class=${classMap(headerClasses)}>
         <div class="headerTopRow">
@@ -1087,6 +1121,14 @@
                 >Go to latest patchset</gr-button
               >
             </div>
+            ${when(
+              attemptItems.length > 0,
+              () => html` <gr-dropdown-list
+                value=${this.selectedAttempt ?? 0}
+                .items=${attemptItems}
+                @value-change=${this.onAttemptSelected}
+              ></gr-dropdown-list>`
+            )}
             <gr-dropdown-list
               value=${this.checksPatchsetNumber ??
               this.latestPatchsetNumber ??
@@ -1211,6 +1253,12 @@
     ></gr-checks-action>`;
   }
 
+  private onAttemptSelected(e: CustomEvent<{value: string | undefined}>) {
+    const attempt = stringToAttemptChoice(e.detail.value);
+    assertIsDefined(attempt, `unexpected attempt choice ${e.detail.value}`);
+    this.getChecksModel().updateStateSetAttempt(attempt);
+  }
+
   private onPatchsetSelected(e: CustomEvent<{value: string}>) {
     const patchset = Number(e.detail.value);
     assert(!isNaN(patchset), 'selected patchset must be a number');
@@ -1221,6 +1269,23 @@
     this.getChecksModel().setPatchset(undefined);
   }
 
+  private createAttemptDropdownItems() {
+    if (this.runs.every(run => run.isSingleAttempt)) return [];
+    const attempts: AttemptChoice[] = this.runs
+      .map(run => run.attempt ?? 0)
+      .filter(isAttemptChoice)
+      .filter(unique);
+    attempts.push(LATEST_ATTEMPT);
+    attempts.push(ALL_ATTEMPTS);
+    const items: DropdownItem[] = attempts.sort(sortAttemptChoices).map(a => {
+      return {
+        value: a,
+        text: attemptChoiceLabel(a),
+      };
+    });
+    return items;
+  }
+
   private createPatchsetDropdownItems() {
     if (!this.latestPatchsetNumber) return [];
     return Array.from(Array(this.latestPatchsetNumber), (_, i) => {
@@ -1244,7 +1309,7 @@
   renderFilter() {
     const runs = this.runs.filter(
       run =>
-        this.isRunSelected(run) && isAttemptSelected(this.selectedAttempts, run)
+        this.isRunSelected(run) && isAttemptSelected(this.selectedAttempt, run)
     );
     if (this.selectedRuns.length === 0 && allResults(runs).length <= 3) {
       if (this.filterRegExp.source.length > 0) {
@@ -1279,7 +1344,7 @@
     const isWarningOrError =
       category === Category.WARNING || category === Category.ERROR;
     const allRuns = this.runs.filter(run =>
-      isAttemptSelected(this.selectedAttempts, run)
+      isAttemptSelected(this.selectedAttempt, run)
     );
     const all = allRuns.reduce(
       (results: RunResult[], run) => [
diff --git a/polygerrit-ui/app/elements/checks/gr-checks-results_test.ts b/polygerrit-ui/app/elements/checks/gr-checks-results_test.ts
index 5fcab03..f8addc5 100644
--- a/polygerrit-ui/app/elements/checks/gr-checks-results_test.ts
+++ b/polygerrit-ui/app/elements/checks/gr-checks-results_test.ts
@@ -12,8 +12,9 @@
 import {fakeRun0, setAllFakeRuns} from '../../models/checks/checks-fakes';
 import {resolve} from '../../models/dependency';
 import {createLabelInfo} from '../../test/test-data-generators';
-import {queryAndAssert, query} from '../../utils/common-util';
+import {queryAndAssert, query, assertIsDefined} from '../../utils/common-util';
 import {PatchSetNumber} from '../../api/rest-api';
+import {GrDropdownList} from '../shared/gr-dropdown-list/gr-dropdown-list';
 
 suite('gr-result-row test', () => {
   let element: GrResultRow;
@@ -136,11 +137,39 @@
       html`<gr-checks-results></gr-checks-results>`
     );
     const getChecksModel = resolve(element, checksModelToken);
+    getChecksModel().allRunsSelectedPatchset$.subscribe(
+      runs => (element.runs = runs)
+    );
     setAllFakeRuns(getChecksModel());
+    await element.updateComplete;
+  });
+
+  test('attempt dropdown items', async () => {
+    const attemptDropdown = queryAndAssert<GrDropdownList>(
+      element,
+      'gr-dropdown-list'
+    );
+    assertIsDefined(attemptDropdown.items);
+    assert.equal(attemptDropdown.items.length, 42);
+    assert.deepEqual(attemptDropdown.items[0], {
+      text: 'Latest Attempt',
+      value: 'latest',
+    });
+    assert.deepEqual(attemptDropdown.items[1], {
+      text: 'All Attempts',
+      value: 'all',
+    });
+    assert.deepEqual(attemptDropdown.items[2], {
+      text: 'Attempt 0',
+      value: 0,
+    });
+    assert.deepEqual(attemptDropdown.items[41], {
+      text: 'Attempt 40',
+      value: 40,
+    });
   });
 
   test('renders', async () => {
-    await element.updateComplete;
     assert.shadowDom.equal(
       element,
       /* HTML */ `
@@ -157,11 +186,20 @@
               <div class="goToLatest">
                 <gr-button link=""> Go to latest patchset </gr-button>
               </div>
+              <gr-dropdown-list value="latest"> </gr-dropdown-list>
               <gr-dropdown-list value="0"> </gr-dropdown-list>
             </div>
           </div>
           <div class="headerBottomRow">
-            <div class="left"></div>
+            <div class="left">
+              <div class="filterDiv">
+                <input
+                  id="filterInput"
+                  placeholder="Filter results by tag or regular expression"
+                  type="text"
+                />
+              </div>
+            </div>
             <div class="right">
               <a href="https://www.google.com" target="_blank">
                 <gr-icon
@@ -212,36 +250,67 @@
           </div>
         </div>
         <div class="body">
-          <div class="collapsed">
-            <h3 class="categoryHeader empty error heading-3">
-              <gr-icon icon="expand_more" class="expandIcon"></gr-icon>
+          <div class="expanded">
+            <h3 class="categoryHeader error heading-3">
+              <gr-icon icon="expand_less" class="expandIcon"></gr-icon>
               <div class="statusIconWrapper">
                 <gr-icon icon="error" filled class="error statusIcon"></gr-icon>
                 <span class="title"> error </span>
-                <span class="count"> (0) </span>
+                <span class="count"> (3) </span>
                 <paper-tooltip offset="5"> </paper-tooltip>
               </div>
             </h3>
+            <gr-result-row
+              class="FAKEErrorFinderFinderFinderFinderFinderFinderFinder"
+            >
+            </gr-result-row>
+            <gr-result-row
+              isexpandable
+              class="FAKEErrorFinderFinderFinderFinderFinderFinderFinder"
+            >
+            </gr-result-row>
+            <gr-result-row isexpandable class="FAKESuperCheck"> </gr-result-row>
+            <table class="resultsTable">
+              <thead>
+                <tr class="headerRow">
+                  <th class="longNames nameCol">Run</th>
+                  <th class="summaryCol">Summary</th>
+                  <th class="expanderCol"></th>
+                </tr>
+              </thead>
+              <tbody></tbody>
+            </table>
           </div>
-          <div class="collapsed">
-            <h3 class="categoryHeader empty heading-3 warning">
-              <gr-icon icon="expand_more" class="expandIcon"></gr-icon>
+          <div class="expanded">
+            <h3 class="categoryHeader heading-3 warning">
+              <gr-icon icon="expand_less" class="expandIcon"></gr-icon>
               <div class="statusIconWrapper">
                 <gr-icon icon="warning" filled class="warning statusIcon">
                 </gr-icon>
                 <span class="title"> warning </span>
-                <span class="count"> (0) </span>
+                <span class="count"> (1) </span>
                 <paper-tooltip offset="5"> </paper-tooltip>
               </div>
             </h3>
+            <gr-result-row class="FAKESuperCheck" isexpandable> </gr-result-row>
+            <table class="resultsTable">
+              <thead>
+                <tr class="headerRow">
+                  <th class="nameCol">Run</th>
+                  <th class="summaryCol">Summary</th>
+                  <th class="expanderCol"></th>
+                </tr>
+              </thead>
+              <tbody></tbody>
+            </table>
           </div>
           <div class="collapsed">
-            <h3 class="categoryHeader empty heading-3 info">
+            <h3 class="categoryHeader heading-3 info">
               <gr-icon icon="expand_more" class="expandIcon"></gr-icon>
               <div class="statusIconWrapper">
                 <gr-icon icon="info" class="info statusIcon"></gr-icon>
                 <span class="title"> info </span>
-                <span class="count"> (0) </span>
+                <span class="count"> (3) </span>
                 <paper-tooltip offset="5"> </paper-tooltip>
               </div>
             </h3>
diff --git a/polygerrit-ui/app/elements/checks/gr-checks-runs.ts b/polygerrit-ui/app/elements/checks/gr-checks-runs.ts
index 99ae21e..addbfc0 100644
--- a/polygerrit-ui/app/elements/checks/gr-checks-runs.ts
+++ b/polygerrit-ui/app/elements/checks/gr-checks-runs.ts
@@ -12,6 +12,10 @@
 import {Action, Link, RunStatus} from '../../api/checks';
 import {sharedStyles} from '../../styles/shared-styles';
 import {
+  ALL_ATTEMPTS,
+  AttemptChoice,
+  attemptChoiceLabel,
+  LATEST_ATTEMPT,
   AttemptDetail,
   compareByWorstCategory,
   headerForStatus,
@@ -40,11 +44,7 @@
 } from '../../models/checks/checks-fakes';
 import {assertIsDefined} from '../../utils/common-util';
 import {modifierPressed, whenVisible} from '../../utils/dom-util';
-import {
-  fireAttemptSelected,
-  fireRunSelected,
-  fireRunSelectionReset,
-} from './gr-checks-util';
+import {fireRunSelected, fireRunSelectionReset} from './gr-checks-util';
 import {ChecksTabState} from '../../types/events';
 import {charsOnly} from '../../utils/string-util';
 import {getAppContext} from '../../services/app-context';
@@ -198,8 +198,8 @@
   @property({attribute: false})
   selected = false;
 
-  @property({attribute: false})
-  selectedAttempt?: number;
+  @state()
+  selectedAttempt: AttemptChoice = LATEST_ATTEMPT;
 
   @property({attribute: false})
   deselected = false;
@@ -212,26 +212,22 @@
 
   private readonly reporting = getAppContext().reportingService;
 
+  private getChecksModel = resolve(this, checksModelToken);
+
+  constructor() {
+    super();
+    subscribe(
+      this,
+      () => this.getChecksModel().checksSelectedAttemptNumber$,
+      x => (this.selectedAttempt = x)
+    );
+  }
+
   override firstUpdated() {
     assertIsDefined(this.chipElement, 'chip element');
     whenVisible(this.chipElement, () => (this.shouldRender = true), 200);
   }
 
-  protected override updated(changedProperties: PropertyValues) {
-    super.updated(changedProperties);
-
-    // For some reason the browser does not pick up the correct `checked` state
-    // that is set in renderAttempt(). So we have to set it programmatically
-    // here.
-    const selectedAttempt = this.selectedAttempt ?? this.run.attempt;
-    const inputToBeSelected = this.shadowRoot?.querySelector(
-      `.attemptDetails input#attempt-${selectedAttempt}`
-    ) as HTMLInputElement | undefined;
-    if (inputToBeSelected) {
-      inputToBeSelected.checked = true;
-    }
-  }
-
   override render() {
     if (!this.shouldRender) return html`<div class="chip">Loading ...</div>`;
 
@@ -280,31 +276,31 @@
         class="attemptDetails"
         ?hidden=${this.run.isSingleAttempt || !this.selected}
       >
+        ${this.renderAttempt({attempt: LATEST_ATTEMPT})}
+        ${this.renderAttempt({attempt: ALL_ATTEMPTS})}
         ${this.run.attemptDetails.map(a => this.renderAttempt(a))}
       </div>
     `;
   }
 
-  isSelected(detail: AttemptDetail) {
-    // this.selectedAttempt may be undefined, then choose the latest attempt,
-    // which is what this.run has.
-    const selectedAttempt = this.selectedAttempt ?? this.run.attempt;
-    return detail.attempt === selectedAttempt;
-  }
-
   renderAttempt(detail: AttemptDetail) {
+    const attempt = detail.attempt ?? 0;
     const checkNameId = charsOnly(this.run.checkName).toLowerCase();
     const id = `attempt-${detail.attempt}`;
-    const icon = detail.icon;
-    const wasNotRun = icon?.name === iconFor(RunStatus.RUNNABLE)?.name;
+    const icon = detail.icon ?? {name: ''};
+    const wasNotRun =
+      icon?.name === iconFor(RunStatus.RUNNABLE)?.name &&
+      attempt !== LATEST_ATTEMPT &&
+      attempt !== ALL_ATTEMPTS;
+    const selected = this.selectedAttempt === attempt;
     return html`<div class="attemptDetail">
       <input
         type="radio"
         id=${id}
         name=${`${checkNameId}-attempt-choice`}
-        ?checked=${this.isSelected(detail)}
-        ?disabled=${!this.isSelected(detail) && wasNotRun}
-        @change=${() => this.handleAttemptChange(detail)}
+        .checked=${selected}
+        ?disabled=${!selected && wasNotRun}
+        @change=${() => this.handleAttemptChange(attempt)}
       />
       <gr-icon
         icon=${icon.name}
@@ -312,15 +308,13 @@
         ?filled=${icon.filled}
       ></gr-icon>
       <label for=${id}>
-        Attempt ${detail.attempt}${wasNotRun ? ' (not run)' : ''}
+        ${attemptChoiceLabel(attempt)}${wasNotRun ? ' (not run)' : ''}
       </label>
     </div>`;
   }
 
-  handleAttemptChange(detail: AttemptDetail) {
-    if (!this.isSelected(detail)) {
-      fireAttemptSelected(this, this.run.checkName, detail.attempt);
-    }
+  handleAttemptChange(attempt: AttemptChoice) {
+    this.getChecksModel().updateStateSetAttempt(attempt);
   }
 
   renderETA() {
@@ -400,12 +394,8 @@
   @query('#filterInput')
   filterInput?: HTMLInputElement;
 
-  /**
-   * We prefer `undefined` over a RegExp with '', because `.source` yields
-   * a strange '(?:)' for ''.
-   */
   @state()
-  filterRegExp?: RegExp;
+  filterRegExp = '';
 
   @property({attribute: false})
   runs: CheckRun[] = [];
@@ -416,12 +406,8 @@
   @property({attribute: false})
   selectedRuns: string[] = [];
 
-  /** Maps checkName to selected attempt number. `undefined` means `latest`. */
-  @property({attribute: false})
-  selectedAttempts: Map<string, number | undefined> = new Map<
-    string,
-    number | undefined
-  >();
+  @state()
+  selectedAttempt: AttemptChoice = LATEST_ATTEMPT;
 
   @property({attribute: false})
   tabState?: ChecksTabState;
@@ -457,6 +443,16 @@
       () => this.getChecksModel().loginCallbackLatest$,
       x => (this.loginCallback = x)
     );
+    subscribe(
+      this,
+      () => this.getChecksModel().checksSelectedAttemptNumber$,
+      x => (this.selectedAttempt = x)
+    );
+    subscribe(
+      this,
+      () => this.getChecksModel().runFilterRegexp$,
+      x => (this.filterRegExp = x)
+    );
     this.addEventListener('click', () => {
       if (this.collapsed) this.toggleCollapsed();
     });
@@ -583,20 +579,7 @@
 
   protected override updated(changedProperties: PropertyValues) {
     super.updated(changedProperties);
-    // This update is done is response to setting this.filterRegExp below, but
-    // this.filterInput not yet being available at that point.
-    if (this.filterInput && !this.filterInput.value && this.filterRegExp) {
-      this.filterInput.value = this.filterRegExp.source;
-    }
     if (changedProperties.has('tabState') && this.tabState) {
-      // Note that tabState.select and tabState.attempt are processed by
-      // <gr-checks-tab>.
-      if (
-        this.tabState.filter &&
-        this.tabState.filter !== this.filterRegExp?.source
-      ) {
-        this.filterRegExp = new RegExp(this.tabState.filter, 'i');
-      }
       const {statusOrCategory} = this.tabState;
       if (
         statusOrCategory === RunStatus.RUNNING ||
@@ -624,6 +607,7 @@
         type="text"
         placeholder="Filter runs by regular expression"
         ?hidden=${!this.showFilter()}
+        .value=${this.filterRegExp}
         @input=${this.onInput}
       />
       ${this.renderSection(RunStatus.RUNNING)}
@@ -767,11 +751,8 @@
       {},
       {deduping: Deduping.EVENT_ONCE_PER_CHANGE}
     );
-    if (this.filterInput.value) {
-      this.filterRegExp = new RegExp(this.filterInput.value, 'i');
-    } else {
-      this.filterRegExp = undefined;
-    }
+    const value = this.filterInput.value;
+    this.getChecksModel().updateStateSetRunFilter(value ?? '');
   }
 
   toggle(
@@ -793,6 +774,7 @@
   }
 
   renderSection(status: RunStatus) {
+    const regExp = new RegExp(this.filterRegExp, 'i');
     const runs = this.runs
       .filter(r => r.isLatestAttempt)
       .filter(
@@ -800,7 +782,7 @@
           r.status === status ||
           (status === RunStatus.RUNNING && r.status === RunStatus.SCHEDULED)
       )
-      .filter(r => !this.filterRegExp || this.filterRegExp.test(r.checkName))
+      .filter(r => regExp.test(r.checkName))
       .sort(compareByWorstCategory);
     if (runs.length === 0) return;
     const expanded = this.isSectionExpanded.get(status) ?? true;
@@ -839,13 +821,11 @@
 
   renderRun(run: CheckRun) {
     const selectedRun = this.selectedRuns.includes(run.checkName);
-    const selectedAttempt = this.selectedAttempts.get(run.checkName);
     const deselected = !selectedRun && this.selectedRuns.length > 0;
     return html`<gr-checks-run
       .run=${run}
       ?condensed=${this.collapsed}
       .selected=${selectedRun}
-      .selectedAttempt=${selectedAttempt}
       .deselected=${deselected}
     ></gr-checks-run>`;
   }
diff --git a/polygerrit-ui/app/elements/checks/gr-checks-runs_test.ts b/polygerrit-ui/app/elements/checks/gr-checks-runs_test.ts
index 1244a2f..5bb2788 100644
--- a/polygerrit-ui/app/elements/checks/gr-checks-runs_test.ts
+++ b/polygerrit-ui/app/elements/checks/gr-checks-runs_test.ts
@@ -5,12 +5,13 @@
  */
 import '../../test/common-test-setup-karma';
 import './gr-checks-runs';
-import {GrChecksRuns} from './gr-checks-runs';
+import {GrChecksRun, GrChecksRuns} from './gr-checks-runs';
 import {html} from 'lit';
 import {fixture, assert} from '@open-wc/testing';
 import {checksModelToken} from '../../models/checks/checks-model';
-import {setAllFakeRuns} from '../../models/checks/checks-fakes';
+import {fakeRun0, setAllFakeRuns} from '../../models/checks/checks-fakes';
 import {resolve} from '../../models/dependency';
+import {queryAll} from '../../utils/common-util';
 
 suite('gr-checks-runs test', () => {
   let element: GrChecksRuns;
@@ -21,16 +22,20 @@
     );
     const getChecksModel = resolve(element, checksModelToken);
     setAllFakeRuns(getChecksModel());
+    await element.updateComplete;
   });
 
-  test('tabState filter', async () => {
-    element.tabState = {filter: 'fff'};
+  test('filterRegExp', async () => {
+    // Without a filter all 6 fake runs (0-5) will be rendered.
+    assert.equal(queryAll(element, 'gr-checks-run').length, 6);
+
+    // This filter will only match fakeRun2 (checkName: 'FAKE Mega Analysis').
+    element.filterRegExp = 'Mega';
     await element.updateComplete;
-    assert.equal(element.filterRegExp?.source, 'fff');
+    assert.equal(queryAll(element, 'gr-checks-run').length, 1);
   });
 
   test('renders', async () => {
-    await element.updateComplete;
     assert.equal(element.runs.length, 44);
     assert.shadowDom.equal(
       element,
@@ -92,7 +97,7 @@
     );
   });
 
-  test('renders', async () => {
+  test('renders collapsed', async () => {
     element.collapsed = true;
     await element.updateComplete;
     assert.equal(element.runs.length, 44);
@@ -154,3 +159,66 @@
     );
   });
 });
+
+suite('gr-checks-run test', () => {
+  let element: GrChecksRun;
+
+  setup(async () => {
+    element = await fixture<GrChecksRun>(html`<gr-checks-run></gr-checks-run>`);
+    const getChecksModel = resolve(element, checksModelToken);
+    setAllFakeRuns(getChecksModel());
+    await element.updateComplete;
+  });
+
+  test('renders loading', async () => {
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ ' <div class="chip">Loading ...</div> '
+    );
+  });
+
+  test('renders fakeRun0', async () => {
+    element.shouldRender = true;
+    element.run = fakeRun0;
+    await element.updateComplete;
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <div class="chip error" tabindex="0">
+          <div class="left">
+            <gr-hovercard-run> </gr-hovercard-run>
+            <gr-icon class="error" filled="" icon="error"> </gr-icon>
+            <span class="name">
+              FAKE Error Finder Finder Finder Finder Finder Finder Finder
+            </span>
+          </div>
+          <div class="middle">
+            <gr-checks-attempt> </gr-checks-attempt>
+          </div>
+          <div class="right"></div>
+          </div>
+          <div class="attemptDetails" hidden="">
+            <div class="attemptDetail">
+              <input
+                id="attempt-latest"
+                name="fakeerrorfinderfinderfinderfinderfinderfinderfinder-attempt-choice"
+                type="radio"
+              />
+              <gr-icon icon=""> </gr-icon>
+              <label for="attempt-latest"> Latest Attempt </label>
+            </div>
+            <div class="attemptDetail">
+              <input
+                id="attempt-all"
+                name="fakeerrorfinderfinderfinderfinderfinderfinderfinder-attempt-choice"
+                type="radio"
+              />
+              <gr-icon icon=""> </gr-icon>
+              <label for="attempt-all"> All Attempts </label>
+            </div>
+          </div>
+        </div>
+      `
+    );
+  });
+});
diff --git a/polygerrit-ui/app/elements/checks/gr-checks-tab.ts b/polygerrit-ui/app/elements/checks/gr-checks-tab.ts
index b36c66d..c72bccb 100644
--- a/polygerrit-ui/app/elements/checks/gr-checks-tab.ts
+++ b/polygerrit-ui/app/elements/checks/gr-checks-tab.ts
@@ -14,7 +14,7 @@
 import './gr-checks-runs';
 import './gr-checks-results';
 import {NumericChangeId, PatchSetNumber} from '../../types/common';
-import {AttemptSelectedEvent, RunSelectedEvent} from './gr-checks-util';
+import {RunSelectedEvent} from './gr-checks-util';
 import {TabState} from '../../types/events';
 import {getAppContext} from '../../services/app-context';
 import {subscribe} from '../lit/subscription-controller';
@@ -22,6 +22,7 @@
 import {Interaction} from '../../constants/reporting';
 import {resolve} from '../../models/dependency';
 import {GrChecksRuns} from './gr-checks-runs';
+import {LATEST_ATTEMPT} from '../../models/checks/checks-util';
 
 /**
  * The "Checks" tab on the Gerrit change page. Gets its data from plugins that
@@ -53,13 +54,6 @@
   @state()
   selectedRuns: string[] = [];
 
-  /** Maps checkName to selected attempt number. `undefined` means `latest`. */
-  @state()
-  selectedAttempts: Map<string, number | undefined> = new Map<
-    string,
-    number | undefined
-  >();
-
   private readonly getChangeModel = resolve(this, changeModelToken);
 
   private readonly getChecksModel = resolve(this, checksModelToken);
@@ -140,18 +134,14 @@
           ?collapsed=${this.offsetWidth < 1000}
           .runs=${this.runs}
           .selectedRuns=${this.selectedRuns}
-          .selectedAttempts=${this.selectedAttempts}
           .tabState=${this.tabState?.checksTab}
           @run-selected=${this.handleRunSelected}
-          @attempt-selected=${this.handleAttemptSelected}
         ></gr-checks-runs>
         <gr-checks-results
           class="results"
           .tabState=${this.tabState?.checksTab}
           .runs=${this.runs}
           .selectedRuns=${this.selectedRuns}
-          .selectedAttempts=${this.selectedAttempts}
-          @run-selected=${this.handleRunSelected}
         ></gr-checks-results>
       </div>
     `;
@@ -159,63 +149,23 @@
 
   protected override updated(changedProperties: PropertyValues) {
     super.updated(changedProperties);
-    if (changedProperties.has('tabState')) this.applyTabState();
-    if (changedProperties.has('runs')) this.applyTabState();
+    if (changedProperties.has('tabState')) this.tabStateUpdated();
   }
 
-  /**
-   * Clearing the tabState means that from now on the user interaction counts,
-   * not the content of the URL (which is where tabState is populated from).
-   */
-  private clearTabState() {
-    this.tabState = {};
-  }
-
-  /**
-   * We want to keep applying the tabState to newly incoming check runs until
-   * the user explicitly interacts with the selection or the attempts, which
-   * will result in clearTabState() being called.
-   */
-  private applyTabState() {
+  private tabStateUpdated() {
     if (!this.tabState?.checksTab) return;
-    // Note that .filter is processed by <gr-checks-runs>.
-    const {select, filter, attempt} = this.tabState.checksTab;
-    if (!select) {
-      this.selectedRuns = [];
-      this.selectedAttempts = new Map<string, number>();
-      return;
-    }
-    const regexpSelect = new RegExp(select, 'i');
-    // We do not allow selection of runs that are invisible because of the
-    // filter.
-    const regexpFilter = new RegExp(filter ?? '', 'i');
-    const selectedRuns = this.runs.filter(
-      run =>
-        regexpSelect.test(run.checkName) && regexpFilter.test(run.checkName)
-    );
-    this.selectedRuns = selectedRuns.map(run => run.checkName);
-    const selectedAttempts = new Map<string, number>();
-    if (attempt) {
-      for (const run of selectedRuns) {
-        if (run.isSingleAttempt) continue;
-        const hasAttempt = run.attemptDetails.some(
-          detail => detail.attempt === attempt
-        );
-        if (hasAttempt) selectedAttempts.set(run.checkName, attempt);
-      }
-    }
-    this.selectedAttempts = selectedAttempts;
+    const {attempt, filter} = this.tabState.checksTab;
+    this.getChecksModel().updateStateSetAttempt(attempt ?? LATEST_ATTEMPT);
+    this.getChecksModel().updateStateSetRunFilter(filter ?? '');
   }
 
   handleRunSelected(e: RunSelectedEvent) {
-    this.clearTabState();
     this.reporting.reportInteraction(Interaction.CHECKS_RUN_SELECTED, {
       checkName: e.detail.checkName,
       reset: e.detail.reset,
     });
     if (e.detail.reset) {
       this.selectedRuns = [];
-      this.selectedAttempts = new Map();
       return;
     }
     if (e.detail.checkName) {
@@ -223,24 +173,9 @@
     }
   }
 
-  handleAttemptSelected(e: AttemptSelectedEvent) {
-    this.clearTabState();
-    this.reporting.reportInteraction(Interaction.CHECKS_ATTEMPT_SELECTED, {
-      checkName: e.detail.checkName,
-      attempt: e.detail.attempt,
-    });
-    const {checkName, attempt} = e.detail;
-    this.selectedAttempts.set(checkName, attempt);
-    // Force property update.
-    this.selectedAttempts = new Map(this.selectedAttempts);
-  }
-
   toggleSelected(checkName: string) {
-    this.clearTabState();
     if (this.selectedRuns.includes(checkName)) {
       this.selectedRuns = this.selectedRuns.filter(r => r !== checkName);
-      this.selectedAttempts.set(checkName, undefined);
-      this.selectedAttempts = new Map(this.selectedAttempts);
     } else {
       this.selectedRuns = [...this.selectedRuns, checkName];
     }
diff --git a/polygerrit-ui/app/elements/checks/gr-checks-tab_test.ts b/polygerrit-ui/app/elements/checks/gr-checks-tab_test.ts
index e797265..e554e1e 100644
--- a/polygerrit-ui/app/elements/checks/gr-checks-tab_test.ts
+++ b/polygerrit-ui/app/elements/checks/gr-checks-tab_test.ts
@@ -9,9 +9,8 @@
 import {GrChecksTab} from './gr-checks-tab';
 import {fixture, assert} from '@open-wc/testing';
 import {checksModelToken} from '../../models/checks/checks-model';
-import {fakeRun4_3, setAllFakeRuns} from '../../models/checks/checks-fakes';
+import {setAllFakeRuns} from '../../models/checks/checks-fakes';
 import {resolve} from '../../models/dependency';
-import {Category} from '../../api/checks';
 
 suite('gr-checks-tab test', () => {
   let element: GrChecksTab;
@@ -35,18 +34,4 @@
       `
     );
   });
-
-  test('select from tab state', async () => {
-    element.tabState = {
-      checksTab: {
-        statusOrCategory: Category.ERROR,
-        filter: 'elim',
-        select: 'fake',
-        attempt: 3,
-      },
-    };
-    await element.updateComplete;
-    assert.equal(element.selectedRuns.length, 39);
-    assert.equal(element.selectedAttempts.get(fakeRun4_3.checkName), 3);
-  });
 });
diff --git a/polygerrit-ui/app/elements/checks/gr-checks-util.ts b/polygerrit-ui/app/elements/checks/gr-checks-util.ts
index cd09a45..939a87e 100644
--- a/polygerrit-ui/app/elements/checks/gr-checks-util.ts
+++ b/polygerrit-ui/app/elements/checks/gr-checks-util.ts
@@ -4,33 +4,11 @@
  * SPDX-License-Identifier: Apache-2.0
  */
 import {CheckRun, RunResult} from '../../models/checks/checks-model';
-
-export interface AttemptSelectedEventDetail {
-  checkName: string;
-  attempt: number | undefined;
-}
-
-export type AttemptSelectedEvent = CustomEvent<AttemptSelectedEventDetail>;
-
-declare global {
-  interface HTMLElementEventMap {
-    'attempt-selected': AttemptSelectedEvent;
-  }
-}
-
-export function fireAttemptSelected(
-  target: EventTarget,
-  checkName: string,
-  attempt: number | undefined
-) {
-  target.dispatchEvent(
-    new CustomEvent('attempt-selected', {
-      detail: {checkName, attempt},
-      composed: true,
-      bubbles: true,
-    })
-  );
-}
+import {
+  ALL_ATTEMPTS,
+  AttemptChoice,
+  LATEST_ATTEMPT,
+} from '../../models/checks/checks-util';
 
 export interface RunSelectedEventDetail {
   reset: boolean;
@@ -66,13 +44,12 @@
 }
 
 export function isAttemptSelected(
-  selectedAttempts: Map<string, number | undefined>,
+  selectedAttempt: AttemptChoice,
   run: CheckRun
 ) {
-  const selected = selectedAttempts.get(run.checkName);
-  return (
-    (selected === undefined && run.isLatestAttempt) || selected === run.attempt
-  );
+  if (selectedAttempt === LATEST_ATTEMPT) return run.isLatestAttempt;
+  if (selectedAttempt === ALL_ATTEMPTS) return true;
+  return selectedAttempt === (run.attempt ?? 0);
 }
 
 export function matches(result: RunResult, regExp: RegExp) {
diff --git a/polygerrit-ui/app/elements/checks/gr-hovercard-run.ts b/polygerrit-ui/app/elements/checks/gr-hovercard-run.ts
index 0e2194f..9aa837d 100644
--- a/polygerrit-ui/app/elements/checks/gr-hovercard-run.ts
+++ b/polygerrit-ui/app/elements/checks/gr-hovercard-run.ts
@@ -213,13 +213,18 @@
   }
 
   private renderAttempt(attempt: AttemptDetail) {
+    const attemptNumber = attempt.attempt;
+    const icon = attempt.icon ?? {name: ''};
+    if (attemptNumber !== undefined && typeof attemptNumber !== 'number') {
+      return;
+    }
     return html`
       <div>
         <div class="attemptIcon">
-          <gr-icon icon=${attempt.icon.name} ?filled=${attempt.icon.filled}>
+          <gr-icon class=${icon.name} icon=${icon.name} ?filled=${icon.filled}>
           </gr-icon>
         </div>
-        <div class="attemptNumber">${ordinal(attempt.attempt)}</div>
+        <div class="attemptNumber">${ordinal(attemptNumber)}</div>
       </div>
     `;
   }
@@ -355,8 +360,8 @@
   }
 
   computeAttempts(): AttemptDetail[] {
-    const details = this.run?.attemptDetails ?? [];
-    const more =
+    const details: AttemptDetail[] = this.run?.attemptDetails ?? [];
+    const more: AttemptDetail[] =
       details.length > 7
         ? [{icon: {name: 'more_horiz'}, attempt: undefined}]
         : [];
diff --git a/polygerrit-ui/app/elements/checks/gr-hovercard-run_test.ts b/polygerrit-ui/app/elements/checks/gr-hovercard-run_test.ts
index da57d5a..358ab18 100644
--- a/polygerrit-ui/app/elements/checks/gr-hovercard-run_test.ts
+++ b/polygerrit-ui/app/elements/checks/gr-hovercard-run_test.ts
@@ -7,14 +7,16 @@
 import './gr-hovercard-run';
 import {fixture, html, assert} from '@open-wc/testing';
 import {GrHovercardRun} from './gr-hovercard-run';
-import {fakeRun0} from '../../models/checks/checks-fakes';
+import {fakeRun4Att, fakeRun4_4} from '../../models/checks/checks-fakes';
+import {createAttemptMap} from '../../models/checks/checks-util';
+import {CheckRun} from '../../models/checks/checks-model';
 
 suite('gr-hovercard-run tests', () => {
   let element: GrHovercardRun;
 
   setup(async () => {
     element = await fixture<GrHovercardRun>(html`
-      <gr-hovercard-run class="hovered" .run=${fakeRun0}></gr-hovercard-run>
+      <gr-hovercard-run class="hovered"></gr-hovercard-run>
     `);
   });
 
@@ -22,7 +24,12 @@
     element.mouseHide(new MouseEvent('click'));
   });
 
-  test('render', () => {
+  test('render fakeRun4', async () => {
+    const attemptMap = createAttemptMap(fakeRun4Att);
+    const attemptDetails = attemptMap.get(fakeRun4_4.checkName)!.attempts;
+    const run: CheckRun = {...fakeRun4_4, attemptDetails};
+    element.run = run;
+    await element.updateComplete;
     assert.shadowDom.equal(
       element,
       /* HTML */ `
@@ -37,22 +44,149 @@
           </div>
           <div class="section">
             <div class="sectionIcon">
-              <gr-icon icon="error" filled class="error"></gr-icon>
+              <gr-icon class="info" icon="info"></gr-icon>
             </div>
             <div class="sectionContent">
               <h3 class="heading-3 name">
-                <span>
-                  FAKE Error Finder Finder Finder Finder Finder Finder Finder
-                </span>
+                <span> FAKE Elimination Long Long Long Long Long </span>
               </h3>
             </div>
           </div>
+          <div class="section">
+            <div class="sectionIcon">
+              <gr-icon class="small" icon="info"> </gr-icon>
+            </div>
+            <div class="sectionContent">
+              <div class="row">
+                <div class="title">Status</div>
+                <div>
+                  <a href="https://www.google.com" target="_blank">
+                    <gr-icon
+                      aria-label="external link to check status"
+                      class="link small"
+                      icon="open_in_new"
+                    >
+                    </gr-icon>
+                    www.google.com
+                  </a>
+                </div>
+              </div>
+              <div class="row">
+                <div class="title">Message</div>
+                <div>Everything was eliminated already.</div>
+              </div>
+            </div>
+          </div>
+          <div class="section">
+            <div class="sectionIcon">
+              <gr-icon class="small" icon="arrow_forward"> </gr-icon>
+            </div>
+            <div class="sectionContent">
+              <div class="attempts row">
+                <div class="title">Attempt</div>
+                <div>
+                  <div class="attemptIcon">
+                    <gr-icon class="more_horiz" icon="more_horiz"> </gr-icon>
+                  </div>
+                  <div class="attemptNumber"></div>
+                </div>
+                <div>
+                  <div class="attemptIcon">
+                    <gr-icon class="error" filled="" icon="error"> </gr-icon>
+                  </div>
+                  <div class="attemptNumber">34th</div>
+                </div>
+                <div>
+                  <div class="attemptIcon">
+                    <gr-icon class="check_circle" icon="check_circle">
+                    </gr-icon>
+                  </div>
+                  <div class="attemptNumber">35th</div>
+                </div>
+                <div>
+                  <div class="attemptIcon">
+                    <gr-icon class="error" filled="" icon="error"> </gr-icon>
+                  </div>
+                  <div class="attemptNumber">36th</div>
+                </div>
+                <div>
+                  <div class="attemptIcon">
+                    <gr-icon class="check_circle" icon="check_circle">
+                    </gr-icon>
+                  </div>
+                  <div class="attemptNumber">37th</div>
+                </div>
+                <div>
+                  <div class="attemptIcon">
+                    <gr-icon class="error" filled="" icon="error"> </gr-icon>
+                  </div>
+                  <div class="attemptNumber">38th</div>
+                </div>
+                <div>
+                  <div class="attemptIcon">
+                    <gr-icon class="check_circle" icon="check_circle">
+                    </gr-icon>
+                  </div>
+                  <div class="attemptNumber">39th</div>
+                </div>
+                <div>
+                  <div class="attemptIcon">
+                    <gr-icon class="info" icon="info"> </gr-icon>
+                  </div>
+                  <div class="attemptNumber">40th</div>
+                </div>
+              </div>
+            </div>
+          </div>
+          <div class="section">
+            <div class="sectionIcon">
+              <gr-icon class="small" icon="schedule"> </gr-icon>
+            </div>
+            <div class="sectionContent">
+              <div class="row">
+                <div class="title">Started</div>
+                <div>1 year 5 m ago</div>
+              </div>
+              <div class="row">
+                <div class="title">Ended</div>
+                <div>1 year 5 m ago</div>
+              </div>
+              <div class="row">
+                <div class="title">Completion</div>
+                <div>1 minute</div>
+              </div>
+            </div>
+          </div>
+          <div class="section">
+            <div class="sectionIcon">
+              <gr-icon class="small" icon="link"> </gr-icon>
+            </div>
+            <div class="sectionContent">
+              <div class="row">
+                <div class="title">Description</div>
+                <div>Shows you the possible eliminations.</div>
+              </div>
+              <div class="row">
+                <div class="title">Documentation</div>
+                <div>
+                  <a href="https://www.google.com" target="_blank">
+                    <gr-icon
+                      aria-label="external link to check documentation"
+                      class="link small"
+                      icon="open_in_new"
+                    >
+                    </gr-icon>
+                    www.google.com
+                  </a>
+                </div>
+              </div>
+            </div>
+          </div>
+          <div class="action">
+            <gr-checks-action context="hovercard"> </gr-checks-action>
+          </div>
         </div>
       `
     );
   });
-
-  test('hovercard is shown with error icon', () => {
-    assert.equal(element.computeIcon().name, 'error');
-  });
 });
diff --git a/polygerrit-ui/app/elements/core/gr-router/gr-router.ts b/polygerrit-ui/app/elements/core/gr-router/gr-router.ts
index 360eaa3..0e88b06 100644
--- a/polygerrit-ui/app/elements/core/gr-router/gr-router.ts
+++ b/polygerrit-ui/app/elements/core/gr-router/gr-router.ts
@@ -57,6 +57,10 @@
   GroupDetailView,
   RepoDetailView,
 } from '../../../utils/router-util';
+import {
+  LATEST_ATTEMPT,
+  stringToAttemptChoice,
+} from '../../../models/checks/checks-util';
 
 const RoutePattern = {
   ROOT: '/',
@@ -1466,15 +1470,8 @@
     if (tab) params.tab = tab;
     const filter = ctx.queryMap.get('filter');
     if (filter) params.filter = filter;
-    const select = ctx.queryMap.get('select');
-    if (select) params.select = select;
-    const attempt = ctx.queryMap.get('attempt');
-    if (attempt) {
-      const attemptInt = parseInt(attempt);
-      if (!isNaN(attemptInt) && attemptInt > 0) {
-        params.attempt = attemptInt;
-      }
-    }
+    const attempt = stringToAttemptChoice(ctx.queryMap.get('attempt'));
+    if (attempt && attempt !== LATEST_ATTEMPT) params.attempt = attempt;
 
     this.reporting.setRepoName(params.project);
     this.reporting.setChangeId(changeNum);
diff --git a/polygerrit-ui/app/elements/core/gr-router/gr-router_test.ts b/polygerrit-ui/app/elements/core/gr-router/gr-router_test.ts
index c8581df..29b8e7a 100644
--- a/polygerrit-ui/app/elements/core/gr-router/gr-router_test.ts
+++ b/polygerrit-ui/app/elements/core/gr-router/gr-router_test.ts
@@ -1264,7 +1264,6 @@
             patchNum: 7 as RevisionPatchSetNum,
             attempt: 1,
             filter: 'fff',
-            select: 'sss',
             tab: 'checks',
           });
         });
diff --git a/polygerrit-ui/app/elements/gr-app-types.ts b/polygerrit-ui/app/elements/gr-app-types.ts
index 9f0857e..6abcedf 100644
--- a/polygerrit-ui/app/elements/gr-app-types.ts
+++ b/polygerrit-ui/app/elements/gr-app-types.ts
@@ -19,6 +19,7 @@
   GroupDetailView,
   RepoDetailView,
 } from '../utils/router-util';
+import {AttemptChoice} from '../models/checks/checks-util';
 
 export interface AppElement extends HTMLElement {
   params: AppElementParams | GenerateUrlParameters;
@@ -120,10 +121,8 @@
   tab?: string;
   /** regular expression for filtering check runs */
   filter?: string;
-  /** regular expression for selecting check runs */
-  select?: string;
   /** selected attempt for selected check runs */
-  attempt?: number;
+  attempt?: AttemptChoice;
 }
 
 export interface AppElementJustRegisteredParams {
diff --git a/polygerrit-ui/app/models/checks/checks-model.ts b/polygerrit-ui/app/models/checks/checks-model.ts
index 9f50b8d..28dec3c 100644
--- a/polygerrit-ui/app/models/checks/checks-model.ts
+++ b/polygerrit-ui/app/models/checks/checks-model.ts
@@ -3,7 +3,13 @@
  * Copyright 2020 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
-import {AttemptDetail, createAttemptMap} from './checks-util';
+import {
+  AttemptChoice,
+  AttemptDetail,
+  createAttemptMap,
+  LATEST_ATTEMPT,
+  sortAttemptDetails,
+} from './checks-util';
 import {assertIsDefined} from '../../utils/common-util';
 import {select} from '../../utils/observable-util';
 import {Finalizable} from '../../services/registry';
@@ -136,6 +142,15 @@
    * can be picked up from the change model.
    */
   patchsetNumberSelected?: PatchSetNumber;
+  /**
+   * This is the attempt number selected by the user. If this is `undefined`
+   * (default), then for each run the latest attempt is displayed.
+   */
+  attemptNumberSelected: AttemptChoice;
+  /**
+   * Current filter set by the user in the runs panel or via URL.
+   */
+  runFilterRegexp: string;
   /** Checks data for the latest patchset. */
   pluginStateLatest: {
     [name: string]: ChecksProviderState;
@@ -200,6 +215,13 @@
     state => state.patchsetNumberSelected
   );
 
+  public checksSelectedAttemptNumber$ = select(
+    this.state$,
+    state => state.attemptNumberSelected
+  );
+
+  public runFilterRegexp$ = select(this.state$, state => state.runFilterRegexp);
+
   public checksLatest$ = select(this.state$, state => state.pluginStateLatest);
 
   public checksSelected$ = select(this.state$, state =>
@@ -362,6 +384,9 @@
     readonly pluginsModel: PluginsModel
   ) {
     super({
+      patchsetNumberSelected: undefined,
+      attemptNumberSelected: LATEST_ATTEMPT,
+      runFilterRegexp: '',
       pluginStateLatest: {},
       pluginStateSelected: {},
     });
@@ -552,11 +577,7 @@
   ) {
     const attemptMap = createAttemptMap(runs);
     for (const attemptInfo of attemptMap.values()) {
-      // Per run only one attempt can be undefined, so the '?? -1' is not really
-      // relevant for sorting.
-      attemptInfo.attempts.sort(
-        (a, b) => (a.attempt ?? -1) - (b.attempt ?? -1)
-      );
+      attemptInfo.attempts.sort(sortAttemptDetails);
     }
     const nextState = {...this.subject$.getValue()};
     const pluginState = this.getPluginState(nextState, patchset);
@@ -573,9 +594,10 @@
         assertIsDefined(attemptInfo, 'attemptInfo');
         return {
           ...run,
+          attempt: run.attempt ?? 0,
           pluginName,
           internalRunId: runId,
-          isLatestAttempt: attemptInfo.latestAttempt === run.attempt,
+          isLatestAttempt: attemptInfo.latestAttempt === (run.attempt ?? 0),
           isSingleAttempt: attemptInfo.isSingleAttempt,
           attemptDetails: attemptInfo.attempts,
           results: (run.results ?? []).map((result, i) => {
@@ -638,6 +660,18 @@
     this.subject$.next(nextState);
   }
 
+  updateStateSetAttempt(attemptNumber: AttemptChoice) {
+    const nextState = {...this.subject$.getValue()};
+    nextState.attemptNumberSelected = attemptNumber;
+    this.subject$.next(nextState);
+  }
+
+  updateStateSetRunFilter(runFilter: string) {
+    const nextState = {...this.subject$.getValue()};
+    nextState.runFilterRegexp = runFilter;
+    this.subject$.next(nextState);
+  }
+
   setPatchset(num?: PatchSetNumber) {
     this.updateStateSetPatchset(num === this.latestPatchNum ? undefined : num);
   }
diff --git a/polygerrit-ui/app/models/checks/checks-util.ts b/polygerrit-ui/app/models/checks/checks-util.ts
index f6dac8a..09c9273 100644
--- a/polygerrit-ui/app/models/checks/checks-util.ts
+++ b/polygerrit-ui/app/models/checks/checks-util.ts
@@ -15,7 +15,7 @@
 import {PatchSetNumber} from '../../api/rest-api';
 import {OpenFixPreviewEventDetail} from '../../types/events';
 import {PROVIDED_FIX_ID} from '../../utils/comment-util';
-import {assertNever} from '../../utils/common-util';
+import {assert, assertNever} from '../../utils/common-util';
 import {fire} from '../../utils/event-util';
 import {CheckResult, CheckRun, RunResult} from './checks-model';
 
@@ -328,27 +328,79 @@
 }
 
 export interface AttemptDetail {
-  attempt: number | undefined;
-  icon: ChecksIcon;
+  attempt?: AttemptChoice;
+  icon?: ChecksIcon;
 }
 
 export interface AttemptInfo {
-  latestAttempt: number | undefined;
+  latestAttempt: AttemptChoice;
   isSingleAttempt: boolean;
   attempts: AttemptDetail[];
 }
 
+export type AttemptChoice = number | 'latest' | 'all';
+export const ALL_ATTEMPTS = 'all' as AttemptChoice;
+export const LATEST_ATTEMPT = 'latest' as AttemptChoice;
+
+export function isAttemptChoice(x: number | string): x is AttemptChoice {
+  if (typeof x === 'string') {
+    return x === ALL_ATTEMPTS || x === LATEST_ATTEMPT;
+  }
+  if (typeof x === 'number') {
+    return x >= 0;
+  }
+  return false;
+}
+
+export function stringToAttemptChoice(
+  s?: string | null
+): AttemptChoice | undefined {
+  if (s === undefined) return undefined;
+  if (s === null) return undefined;
+  if (s === '') return undefined;
+  if (isAttemptChoice(s)) return s;
+  const n = Number(s);
+  if (isAttemptChoice(n)) return n;
+  return undefined;
+}
+
+export function attemptChoiceLabel(attempt: AttemptChoice): string {
+  if (attempt === LATEST_ATTEMPT) return 'Latest Attempt';
+  if (attempt === ALL_ATTEMPTS) return 'All Attempts';
+  return `Attempt ${attempt}`;
+}
+
+export function sortAttemptDetails(a: AttemptDetail, b: AttemptDetail): number {
+  return sortAttemptChoices(a.attempt, b.attempt);
+}
+
+export function sortAttemptChoices(
+  a?: AttemptChoice,
+  b?: AttemptChoice
+): number {
+  if (a === b) return 0;
+  if (a === undefined) return -1;
+  if (b === undefined) return 1;
+  if (a === LATEST_ATTEMPT) return -1;
+  if (b === LATEST_ATTEMPT) return 1;
+  if (a === ALL_ATTEMPTS) return -1;
+  if (b === ALL_ATTEMPTS) return 1;
+  assert(typeof a === 'number', `unexpected attempt ${a}`);
+  assert(typeof b === 'number', `unexpected attempt ${b}`);
+  return a - b;
+}
+
 export function createAttemptMap(runs: CheckRunApi[]) {
   const map = new Map<string, AttemptInfo>();
   for (const run of runs) {
     const value = map.get(run.checkName);
-    const detail = {
-      attempt: run.attempt,
+    const detail: AttemptDetail = {
+      attempt: run.attempt ?? 0,
       icon: iconForRun(fromApiToInternalRun(run)),
     };
     if (value === undefined) {
       map.set(run.checkName, {
-        latestAttempt: run.attempt,
+        latestAttempt: run.attempt ?? 0,
         isSingleAttempt: true,
         attempts: [detail],
       });
diff --git a/polygerrit-ui/app/models/checks/checks-util_test.ts b/polygerrit-ui/app/models/checks/checks-util_test.ts
new file mode 100644
index 0000000..e53c29f
--- /dev/null
+++ b/polygerrit-ui/app/models/checks/checks-util_test.ts
@@ -0,0 +1,57 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../test/common-test-setup-karma';
+import './checks-model';
+import {assert} from '@open-wc/testing';
+import {
+  ALL_ATTEMPTS,
+  AttemptChoice,
+  LATEST_ATTEMPT,
+  sortAttemptChoices,
+  stringToAttemptChoice,
+} from './checks-util';
+
+suite('checks-util tests', () => {
+  setup(() => {});
+
+  teardown(() => {});
+
+  test('stringToAttemptChoice', () => {
+    assert.equal(stringToAttemptChoice('0'), 0);
+    assert.equal(stringToAttemptChoice('1'), 1);
+    assert.equal(stringToAttemptChoice('999'), 999);
+    assert.equal(stringToAttemptChoice('latest'), 'latest');
+    assert.equal(stringToAttemptChoice('all'), 'all');
+
+    assert.equal(stringToAttemptChoice(undefined), undefined);
+    assert.equal(stringToAttemptChoice(''), undefined);
+    assert.equal(stringToAttemptChoice('asdf'), undefined);
+    assert.equal(stringToAttemptChoice('-1'), undefined);
+    assert.equal(stringToAttemptChoice('1x'), undefined);
+  });
+
+  test('sortAttemptChoices', () => {
+    const unsorted: (AttemptChoice | undefined)[] = [
+      3,
+      1,
+      LATEST_ATTEMPT,
+      ALL_ATTEMPTS,
+      undefined,
+      0,
+      999,
+    ];
+    const sortedExpected: (AttemptChoice | undefined)[] = [
+      LATEST_ATTEMPT,
+      ALL_ATTEMPTS,
+      0,
+      1,
+      3,
+      999,
+      undefined,
+    ];
+    assert.deepEqual(unsorted.sort(sortAttemptChoices), sortedExpected);
+  });
+});
diff --git a/polygerrit-ui/app/types/events.ts b/polygerrit-ui/app/types/events.ts
index 508b188..23acff4 100644
--- a/polygerrit-ui/app/types/events.ts
+++ b/polygerrit-ui/app/types/events.ts
@@ -8,6 +8,7 @@
 import {FetchRequest} from './types';
 import {LineNumberEventDetail, MovedLinkClickedEventDetail} from '../api/diff';
 import {Category, RunStatus} from '../api/checks';
+import {AttemptChoice} from '../models/checks/checks-util';
 
 export enum EventType {
   BIND_VALUE_CHANGED = 'bind-value-changed',
@@ -232,10 +233,8 @@
   checkName?: string;
   /** regular expression for filtering runs */
   filter?: string;
-  /** regular expression for selecting runs */
-  select?: string;
   /** selected attempt for selected runs */
-  attempt?: number;
+  attempt?: AttemptChoice;
 }
 export type SwitchTabEvent = CustomEvent<SwitchTabEventDetail>;
 
diff --git a/polygerrit-ui/app/utils/router-util.ts b/polygerrit-ui/app/utils/router-util.ts
index ec7993c..97e9ae0 100644
--- a/polygerrit-ui/app/utils/router-util.ts
+++ b/polygerrit-ui/app/utils/router-util.ts
@@ -21,6 +21,7 @@
 import {assertNever} from './common-util';
 import {GerritView} from '../services/router/router-model';
 import {addQuotesWhen} from './string-util';
+import {AttemptChoice} from '../models/checks/checks-util';
 
 export interface DashboardSection {
   name: string;
@@ -73,10 +74,8 @@
   tab?: string;
   /** regular expression for filtering check runs */
   filter?: string;
-  /** regular expression for selecting check runs */
-  select?: string;
   /** selected attempt for selected check runs */
-  attempt?: number;
+  attempt?: AttemptChoice;
   usp?: string;
 }