Use DetailsShell in ftrace panel, logs panel, and query panel.

Provides a more consistent look and feel to details panels.
Also fixes bug where horizontal scrolling in analyse page was broken.

Bug: 286842471
Change-Id: I789cdc0bc6117b8e522ae5bfb0d3fd75c1b17e95
diff --git a/ui/src/assets/common.scss b/ui/src/assets/common.scss
index 94a9c0e..ad91f01 100644
--- a/ui/src/assets/common.scss
+++ b/ui/src/assets/common.scss
@@ -262,14 +262,22 @@
   }
 }
 
-.query-table {
-  width: 100%;
+.pf-query-panel {
+  display: contents;
+  .pf-query-warning {
+    padding: 4px;
+    position: sticky;
+    left: 0;
+  }
+}
+
+.pf-query-table {
+  min-width: 100%;
   font-size: 14px;
   border: 0;
   thead td {
-    // TODO(stevegolton): Get sticky working again.
-    // position: sticky;
-    // top: 0;
+    position: sticky;
+    top: 0;
     background-color: hsl(214, 22%, 90%);
     color: #262f3c;
     text-align: center;
diff --git a/ui/src/assets/perfetto.scss b/ui/src/assets/perfetto.scss
index 95ed422..0b1e705 100644
--- a/ui/src/assets/perfetto.scss
+++ b/ui/src/assets/perfetto.scss
@@ -44,3 +44,5 @@
 @import "widgets/switch";
 @import "widgets/text_input";
 @import "widgets/tree";
+@import "widgets/virtual_scroll_container";
+@import "widgets/callout";
diff --git a/ui/src/assets/widgets/callout.scss b/ui/src/assets/widgets/callout.scss
new file mode 100644
index 0000000..da47edf
--- /dev/null
+++ b/ui/src/assets/widgets/callout.scss
@@ -0,0 +1,33 @@
+// Copyright (C) 2023 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+@import "theme";
+
+.pf-callout {
+  display: flex;
+  font-family: inherit;
+  font-size: inherit;
+  line-height: 1;
+  color: $pf-minimal-foreground;
+  background: rgb(239, 239, 239);
+  border-radius: $pf-border-radius;
+  padding: 4px;
+  border: solid lightgray 1px;
+
+  & > .pf-left-icon {
+    font-size: inherit;
+    margin-right: 6px;
+    align-items: baseline;
+  }
+}
diff --git a/ui/src/assets/widgets/details_shell.scss b/ui/src/assets/widgets/details_shell.scss
index 75c6af9..a71f237 100644
--- a/ui/src/assets/widgets/details_shell.scss
+++ b/ui/src/assets/widgets/details_shell.scss
@@ -18,17 +18,10 @@
   font-family: $pf-font;
   display: flex;
   flex-direction: column;
-  min-height: 100%;
-
-  &.pf-match-parent {
-    height: 100%;
-  }
 
   .pf-header-bar {
-    z-index: 1; // HACK: Make the header bar appear above the content
-    position: sticky;
-    top: 0;
-    left: 0;
+    z-index: 1;
+
     display: flex;
     flex-direction: row;
     align-items: baseline;
@@ -36,7 +29,6 @@
     background-color: white;
     color: black;
     padding: 8px 8px 5px 8px;
-    box-shadow: 0px 1px 4px rgba(0, 0, 0, 0.2);
     border-bottom: 1px solid rgba(0, 0, 0, 0.2);
 
     .pf-header-title {
@@ -58,6 +50,7 @@
       display: flex;
       min-width: min-content;
       gap: 4px;
+      align-items: baseline;
     }
   }
 
@@ -65,45 +58,13 @@
     font-size: smaller;
     flex-grow: 1;
     font-weight: 300;
+    overflow-x: auto;
+  }
 
-    table {
-      @include transition(0.1s);
-      @include table-font-size;
-      width: 100%;
-      // Aggregation panel uses multiple table elements that need to be aligned,
-      // which is done by using fixed table layout.
-      table-layout: fixed;
-      word-wrap: break-word;
-      padding: 0 10px;
-      tr:hover {
-        td,
-        th {
-          background-color: $table-hover-color;
-
-          &.no-highlight {
-            background-color: white;
-          }
-        }
-      }
-      th {
-        text-align: left;
-        width: 30%;
-        font-weight: normal;
-        vertical-align: top;
-      }
-      td.value {
-        white-space: pre-wrap;
-      }
-      td.padding {
-        min-width: 10px;
-      }
-      .array-index {
-        text-align: right;
-      }
-    }
-
-    .auto-layout {
-      table-layout: auto;
+  &.pf-fill-parent {
+    height: 100%;
+    .pf-content {
+      overflow-y: auto;
     }
   }
 }
diff --git a/ui/src/assets/widgets/details_table.scss b/ui/src/assets/widgets/details_table.scss
new file mode 100644
index 0000000..297c7ae
--- /dev/null
+++ b/ui/src/assets/widgets/details_table.scss
@@ -0,0 +1,53 @@
+// Copyright (C) 2023 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+table.pf-details-table {
+  @include transition(0.1s);
+  @include table-font-size;
+  width: 100%;
+  // Aggregation panel uses multiple table elements that need to be aligned,
+  // which is done by using fixed table layout.
+  table-layout: fixed;
+  word-wrap: break-word;
+  padding: 0 10px;
+  tr:hover {
+    td,
+    th {
+      background-color: $table-hover-color;
+
+      &.no-highlight {
+        background-color: white;
+      }
+    }
+  }
+  th {
+    text-align: left;
+    width: 30%;
+    font-weight: normal;
+    vertical-align: top;
+  }
+  td.value {
+    white-space: pre-wrap;
+  }
+  td.padding {
+    min-width: 10px;
+  }
+  .array-index {
+    text-align: right;
+  }
+}
+
+.auto-layout {
+  table-layout: auto;
+}
diff --git a/ui/src/assets/widgets/virtual_scroll_container.scss b/ui/src/assets/widgets/virtual_scroll_container.scss
new file mode 100644
index 0000000..d48fbeb
--- /dev/null
+++ b/ui/src/assets/widgets/virtual_scroll_container.scss
@@ -0,0 +1,20 @@
+// Copyright (C) 2023 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+@import "theme";
+
+.pf-virtual-scroll-container {
+  overflow: auto;
+  height: 100%;
+}
diff --git a/ui/src/assets/widgets_page.scss b/ui/src/assets/widgets_page.scss
index 96286bc..4d3309a 100644
--- a/ui/src/assets/widgets_page.scss
+++ b/ui/src/assets/widgets_page.scss
@@ -58,6 +58,7 @@
   .widget-container {
     display: flex;
     min-width: 300px;
+    max-width: 600px;
     min-height: 250px;
     border-radius: 3px;
     box-shadow: inset 2px 2px 10px #00000020;
diff --git a/ui/src/frontend/analyze_page.ts b/ui/src/frontend/analyze_page.ts
index 85ad308..dc0025f 100644
--- a/ui/src/frontend/analyze_page.ts
+++ b/ui/src/frontend/analyze_page.ts
@@ -220,6 +220,7 @@
             state.queryResult = undefined;
             globals.rafScheduler.scheduleFullRedraw();
           },
+          fillParent: false,
         }),
         m(QueryHistoryComponent));
   },
diff --git a/ui/src/frontend/ftrace_panel.ts b/ui/src/frontend/ftrace_panel.ts
index 84337a9..1fb0c2c 100644
--- a/ui/src/frontend/ftrace_panel.ts
+++ b/ui/src/frontend/ftrace_panel.ts
@@ -15,7 +15,6 @@
 import m from 'mithril';
 import {StringListPatch} from 'src/common/state';
 
-import {assertExists} from '../base/logging';
 import {Actions} from '../common/actions';
 import {colorForString} from '../common/colorizer';
 import {TPTime} from '../common/time';
@@ -31,6 +30,7 @@
 } from './widgets/multiselect';
 import {PopupPosition} from './widgets/popup';
 import {Timestamp} from './widgets/timestamp';
+import {VirtualScrollContainer} from './widgets/virtual_scroll_container';
 
 const ROW_H = 20;
 const PAGE_SIZE = 250;
@@ -63,23 +63,14 @@
           title: this.renderTitle(),
           buttons: this.renderFilterPanel(),
         },
-        m('.ftrace-panel', this.renderRows()));
-  }
-
-  private scrollContainer(dom: Element): HTMLElement {
-    const el = dom.parentElement;
-    return assertExists(el);
-  }
-
-  oncreate({dom}: m.CVnodeDOM) {
-    const sc = this.scrollContainer(dom);
-    sc.addEventListener('scroll', this.onScroll);
-    this.recomputeVisibleRowsAndUpdate(sc);
-  }
-
-  onupdate({dom}: m.CVnodeDOM) {
-    const sc = this.scrollContainer(dom);
-    this.recomputeVisibleRowsAndUpdate(sc);
+        m(
+            VirtualScrollContainer,
+            {
+              onScroll: this.onScroll,
+            },
+            m('.ftrace-panel', this.renderRows()),
+            ),
+    );
   }
 
   recomputeVisibleRowsAndUpdate(scrollContainer: HTMLElement) {
@@ -101,19 +92,15 @@
     }
   }
 
-  onremove({dom}: m.CVnodeDOM) {
-    const sc = this.scrollContainer(dom);
-    sc.removeEventListener('scroll', this.onScroll);
-
+  onremove(_: m.CVnodeDOM) {
     globals.dispatch(Actions.updateFtracePagination({
       offset: 0,
       count: 0,
     }));
   }
 
-  onScroll = (e: Event) => {
-    const scrollContainer = e.target as HTMLElement;
-    this.recomputeVisibleRowsAndUpdate(scrollContainer);
+  onScroll = (container: HTMLElement) => {
+    this.recomputeVisibleRowsAndUpdate(container);
   };
 
   onRowOver(ts: TPTime) {
@@ -153,7 +140,6 @@
         {
           label: 'Filter',
           minimal: true,
-          compact: true,
           icon: 'filter_list_alt',
           popupPosition: PopupPosition.Top,
           options,
diff --git a/ui/src/frontend/logs_filters.ts b/ui/src/frontend/logs_filters.ts
index 432913f..dff5ef3 100644
--- a/ui/src/frontend/logs_filters.ts
+++ b/ui/src/frontend/logs_filters.ts
@@ -15,7 +15,11 @@
 import m from 'mithril';
 
 import {Actions} from '../common/actions';
+
 import {globals} from './globals';
+import {Button} from './widgets/button';
+import {Select} from './widgets/select';
+import {TextInput} from './widgets/text_input';
 
 export const LOG_PRIORITIES =
     ['-', '-', 'Verbose', 'Debug', 'Info', 'Warn', 'Error', 'Fatal'];
@@ -50,7 +54,7 @@
           m('option', {value: i, selected}, attrs.options[i]));
     }
     return m(
-        'select',
+        Select,
         {
           onchange: (e: InputEvent) => {
             const selectionValue = (e.target as HTMLSelectElement).value;
@@ -64,16 +68,11 @@
 
 class LogTagChip implements m.ClassComponent<LogTagChipAttrs> {
   view({attrs}: m.CVnode<LogTagChipAttrs>) {
-    return m(
-        '.chip',
-        m('.chip-text', attrs.name),
-        m('button.chip-button',
-          {
-            onclick: () => {
-              attrs.removeTag(attrs.name);
-            },
-          },
-          '×'));
+    return m(Button, {
+      label: attrs.name,
+      rightIcon: 'close',
+      onclick: () => attrs.removeTag(attrs.name),
+    });
   }
 }
 
@@ -84,13 +83,14 @@
 
   view(vnode: m.Vnode<LogTagsWidgetAttrs>) {
     const tags = vnode.attrs.tags;
-    return m(
-        '.tag-container',
-        m('.chips', tags.map((tag) => m(LogTagChip, {
-                               name: tag,
-                               removeTag: this.removeTag.bind(this),
-                             }))),
-        m(`input.chip-input[placeholder='Add new tag']`, {
+    return [
+      tags.map((tag) => m(LogTagChip, {
+                 name: tag,
+                 removeTag: this.removeTag.bind(this),
+               })),
+      m(TextInput,
+        {
+          placeholder: 'Filter by tag...',
           onkeydown: (e: KeyboardEvent) => {
             // This is to avoid zooming on 'w'(and other unexpected effects
             // of key presses in this input field).
@@ -115,14 +115,16 @@
                 Actions.addLogTag({tag: htmlElement.value.trim()}));
             htmlElement.value = '';
           },
-        }));
+        }),
+    ];
   }
 }
 
 class LogTextWidget implements m.ClassComponent {
   view() {
     return m(
-        '.tag-container', m(`input.chip-input[placeholder='Search log text']`, {
+        TextInput, {
+          placeholder: 'Search logs...',
           onkeydown: (e: KeyboardEvent) => {
             // This is to avoid zooming on 'w'(and other unexpected effects
             // of key presses in this input field).
@@ -136,7 +138,7 @@
             globals.dispatch(
                 Actions.updateLogFilterText({textEntry: htmlElement.value}));
           },
-        }));
+        });
   }
 }
 
@@ -145,35 +147,32 @@
     const icon = attrs.hideNonMatching ? 'unfold_less' : 'unfold_more';
     const tooltip = attrs.hideNonMatching ? 'Expand all and view highlighted' :
                                             'Collapse all';
-    return m(
-        '.filter-widget',
-        m('.tooltip', tooltip),
-        m('i.material-icons',
-          {
-            onclick: () => {
-              globals.dispatch(Actions.toggleCollapseByTextEntry({}));
-            },
-          },
-          icon));
+    return m(Button, {
+      icon,
+      title: tooltip,
+      disabled: globals.state.logFilteringCriteria.textEntry === '',
+      minimal: true,
+      onclick: () => globals.dispatch(Actions.toggleCollapseByTextEntry({})),
+    });
   }
 }
 
 export class LogsFilters implements m.ClassComponent {
   view(_: m.CVnode<{}>) {
-    return m(
-        '.log-filters',
-        m('.log-label', 'Log Level'),
-        m(LogPriorityWidget, {
-          options: LOG_PRIORITIES,
-          selectedIndex: globals.state.logFilteringCriteria.minimumLevel,
-          onSelect: (minimumLevel) => {
-            globals.dispatch(Actions.setMinimumLogLevel({minimumLevel}));
-          },
-        }),
-        m(LogTagsWidget, {tags: globals.state.logFilteringCriteria.tags}),
-        m(LogTextWidget),
-        m(FilterByTextWidget, {
-          hideNonMatching: globals.state.logFilteringCriteria.hideNonMatching,
-        }));
+    return [
+      m('.log-label', 'Log Level'),
+      m(LogPriorityWidget, {
+        options: LOG_PRIORITIES,
+        selectedIndex: globals.state.logFilteringCriteria.minimumLevel,
+        onSelect: (minimumLevel) => {
+          globals.dispatch(Actions.setMinimumLogLevel({minimumLevel}));
+        },
+      }),
+      m(LogTagsWidget, {tags: globals.state.logFilteringCriteria.tags}),
+      m(LogTextWidget),
+      m(FilterByTextWidget, {
+        hideNonMatching: globals.state.logFilteringCriteria.hideNonMatching,
+      }),
+    ];
   }
 }
diff --git a/ui/src/frontend/logs_panel.ts b/ui/src/frontend/logs_panel.ts
index eb9a5c2..71c3b20 100644
--- a/ui/src/frontend/logs_panel.ts
+++ b/ui/src/frontend/logs_panel.ts
@@ -14,7 +14,6 @@
 
 import m from 'mithril';
 
-import {assertExists} from '../base/logging';
 import {Actions} from '../common/actions';
 import {HighPrecisionTimeSpan} from '../common/high_precision_time';
 import {
@@ -30,21 +29,20 @@
 import {LOG_PRIORITIES, LogsFilters} from './logs_filters';
 import {Panel} from './panel';
 import {asTPTimestamp} from './sql_types';
+import {DetailsShell} from './widgets/details_shell';
 import {Timestamp} from './widgets/timestamp';
+import {VirtualScrollContainer} from './widgets/virtual_scroll_container';
 
 const ROW_H = 20;
 
 export class LogPanel extends Panel<{}> {
-  private scrollContainer?: HTMLElement;
   private bounds?: LogBounds;
   private entries?: LogEntries;
 
   private visibleRowOffset = 0;
   private visibleRowCount = 0;
 
-  recomputeVisibleRowsAndUpdate() {
-    const scrollContainer = assertExists(this.scrollContainer);
-
+  recomputeVisibleRowsAndUpdate(scrollContainer: HTMLElement) {
     const prevOffset = this.visibleRowOffset;
     const prevCount = this.visibleRowCount;
     this.visibleRowOffset = Math.floor(scrollContainer.scrollTop / ROW_H);
@@ -59,15 +57,11 @@
     }
   }
 
-  oncreate({dom}: m.CVnodeDOM) {
-    this.scrollContainer = assertExists(dom.parentElement as HTMLElement);
-    this.scrollContainer.addEventListener(
-        'scroll', this.onScroll.bind(this), {passive: true});
+  oncreate(_: m.CVnodeDOM) {
     // TODO(stevegolton): Type assersions are a source of bugs.
     // Let's try to find another way of doing this.
     this.bounds = globals.trackDataStore.get(LogBoundsKey) as LogBounds;
     this.entries = globals.trackDataStore.get(LogEntriesKey) as LogEntries;
-    this.recomputeVisibleRowsAndUpdate();
   }
 
   onbeforeupdate(_: m.CVnodeDOM) {
@@ -75,14 +69,12 @@
     // Let's try to find another way of doing this.
     this.bounds = globals.trackDataStore.get(LogBoundsKey) as LogBounds;
     this.entries = globals.trackDataStore.get(LogEntriesKey) as LogEntries;
-    this.recomputeVisibleRowsAndUpdate();
   }
 
-  onScroll() {
-    if (this.scrollContainer === undefined) return;
-    this.recomputeVisibleRowsAndUpdate();
+  onScroll = (scrollContainer: HTMLElement) => {
+    this.recomputeVisibleRowsAndUpdate(scrollContainer);
     globals.rafScheduler.scheduleFullRedraw();
-  }
+  };
 
   onRowOver(ts: TPTime) {
     globals.dispatch(Actions.setHoverCursorTimestamp({ts}));
@@ -167,18 +159,22 @@
       }
     }
 
+    // TODO(stevegolton): Add a 'loading' state to DetailsShell, which shows a
+    // scrolling scrolly bar at the bottom of the banner & map isStale to it
     return m(
-        '.log-panel',
-        m('header',
-          {
-            'class': isStale ? 'stale' : '',
-          },
-          [
-            m('.log-rows-label',
-              `Logs rows [${offset}, ${offset + count}] / ${total}`),
-            m(LogsFilters),
-          ]),
-        m('.rows', {style: {height: `${total * ROW_H}px`}}, rows));
+        DetailsShell,
+        {
+          title: 'Android Logs',
+          description: `[${offset}, ${offset + count}] / ${total}`,
+          buttons: m(LogsFilters),
+        },
+        m(
+            VirtualScrollContainer,
+            {onScroll: this.onScroll},
+            m('.log-panel',
+              m('.rows', {style: {height: `${total * ROW_H}px`}}, rows)),
+            ),
+    );
   }
 
   renderCanvas() {}
diff --git a/ui/src/frontend/query_result_tab.ts b/ui/src/frontend/query_result_tab.ts
index e0eb644..7f370f6 100644
--- a/ui/src/frontend/query_result_tab.ts
+++ b/ui/src/frontend/query_result_tab.ts
@@ -105,6 +105,7 @@
     return m(QueryTable, {
       query: this.config.query,
       resp: this.queryResponse,
+      fillParent: true,
       onClose: () => closeTab(this.uuid),
       contextButtons: [
         this.sqlViewName === undefined ?
diff --git a/ui/src/frontend/query_table.ts b/ui/src/frontend/query_table.ts
index be864fc..ad75cb6 100644
--- a/ui/src/frontend/query_table.ts
+++ b/ui/src/frontend/query_table.ts
@@ -14,8 +14,8 @@
 
 
 import m from 'mithril';
-import {BigintMath} from '../base/bigint_math';
 
+import {BigintMath} from '../base/bigint_math';
 import {Actions} from '../common/actions';
 import {QueryResponse} from '../common/queries';
 import {Row} from '../common/query_result';
@@ -28,6 +28,9 @@
 import {Router} from './router';
 import {reveal} from './scroll_helper';
 import {Button} from './widgets/button';
+import {Callout} from './widgets/callout';
+import {DetailsShell} from './widgets/details_shell';
+import {exists} from './widgets/utils';
 
 interface QueryTableRowAttrs {
   row: Row;
@@ -183,7 +186,8 @@
     if (resp.error) {
       return m('.query-error', `SQL error: ${resp.error}`);
     } else {
-      return m('table.query-table', m('thead', tableHeader), m('tbody', rows));
+      return m(
+          'table.pf-query-table', m('thead', tableHeader), m('tbody', rows));
     }
   }
 }
@@ -193,59 +197,81 @@
   onClose: () => void;
   resp?: QueryResponse;
   contextButtons?: m.Child[];
+  fillParent: boolean;
 }
 
 export class QueryTable extends Panel<QueryTableAttrs> {
-  view(vnode: m.CVnode<QueryTableAttrs>) {
-    const resp = vnode.attrs.resp;
+  view({attrs}: m.CVnode<QueryTableAttrs>) {
+    const {
+      resp,
+      query,
+      onClose,
+      contextButtons = [],
+      fillParent,
+    } = attrs;
 
-    const header: m.Child[] = [
-      m('span',
-        resp ? `Query result - ${Math.round(resp.durationMs)} ms` :
-               `Query - running`),
-      m('span.code.text-select', vnode.attrs.query),
-      m('span.spacer'),
-      ...(vnode.attrs.contextButtons ?? []),
+    return m(
+        DetailsShell,
+        {
+          title: this.renderTitle(resp),
+          description: query,
+          buttons: this.renderButtons(query, onClose, contextButtons, resp),
+          fillParent,
+        },
+        resp && this.renderTableContent(resp),
+    );
+  }
+
+  renderTitle(resp?: QueryResponse) {
+    if (exists(resp)) {
+      return `Query result - ${Math.round(resp.durationMs)} ms`;
+    } else {
+      return 'Query - running';
+    }
+  }
+
+  renderButtons(
+      query: string, onClose: () => void, contextButtons: m.Child[],
+      resp?: QueryResponse) {
+    return [
+      contextButtons,
       m(Button, {
         label: 'Copy query',
         minimal: true,
         onclick: () => {
-          copyToClipboard(vnode.attrs.query);
+          copyToClipboard(query);
         },
       }),
+      (resp && resp.error === undefined) && m(Button, {
+        label: 'Copy result (.tsv)',
+        minimal: true,
+        onclick: () => {
+          queryResponseToClipboard(resp);
+        },
+      }),
+      m(Button, {
+        minimal: true,
+        label: 'Close',
+        onclick: onClose,
+      }),
     ];
-    if (resp) {
-      if (resp.error === undefined) {
-        header.push(m(Button, {
-          label: 'Copy result (.tsv)',
-          minimal: true,
-          onclick: () => {
-            queryResponseToClipboard(resp);
-          },
-        }));
-      }
-    }
-    header.push(m(Button, {
-      label: 'Close',
-      minimal: true,
-      onclick: () => vnode.attrs.onClose(),
-    }));
+  }
 
-    const headers = [m('header.overview', ...header)];
-
-    if (resp === undefined) {
-      return headers;
-    }
-
-    if (resp.statementWithOutputCount > 1) {
-      headers.push(
-          m('header.overview',
-            `${resp.statementWithOutputCount} out of ${resp.statementCount} ` +
-                `statements returned a result. Only the results for the last ` +
-                `statement are displayed in the table below.`));
-    }
-
-    return [...headers, m(QueryTableContent, {resp})];
+  renderTableContent(resp: QueryResponse) {
+    return m(
+        '.pf-query-panel',
+        resp.statementWithOutputCount > 1 &&
+            m('.pf-query-warning',
+              m(
+                  Callout,
+                  {icon: 'warning'},
+                  `${resp.statementWithOutputCount} out of ${
+                      resp.statementCount} `,
+                  'statements returned a result. ',
+                  'Only the results for the last statement are displayed.',
+                  )),
+        m(QueryTableContent, {resp}),
+    );
   }
 
   renderCanvas() {}
diff --git a/ui/src/frontend/widgets/callout.ts b/ui/src/frontend/widgets/callout.ts
new file mode 100644
index 0000000..cd583af
--- /dev/null
+++ b/ui/src/frontend/widgets/callout.ts
@@ -0,0 +1,36 @@
+// Copyright (C) 2023 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import m from 'mithril';
+
+import {Icon} from './icon';
+
+interface CalloutAttrs {
+  icon?: string;
+  // Remaining attributes forwarded to the underlying HTML <button>.
+  [htmlAttrs: string]: any;
+}
+
+export class Callout implements m.ClassComponent<CalloutAttrs> {
+  view({attrs, children}: m.CVnode<CalloutAttrs>) {
+    const {icon, ...htmlAttrs} = attrs;
+
+    return m(
+        '.pf-callout',
+        {...htmlAttrs},
+        icon && m(Icon, {className: 'pf-left-icon', icon}),
+        children,
+    );
+  }
+}
diff --git a/ui/src/frontend/widgets/details_shell.ts b/ui/src/frontend/widgets/details_shell.ts
index 7fe684b..8b6aa41 100644
--- a/ui/src/frontend/widgets/details_shell.ts
+++ b/ui/src/frontend/widgets/details_shell.ts
@@ -19,10 +19,8 @@
   title: m.Children;
   description?: m.Children;
   buttons?: m.Children;
-  // If true, this container will fill the parent, and content scrolling is
-  // expected to be handled internally.
-  // Defaults to false.
-  matchParent?: boolean;
+  // Stretch/shrink the content to fill the parent vertically.
+  fillParent?: boolean;
 }
 
 // A shell for details panels to be more visually consistent.
@@ -33,12 +31,12 @@
       title,
       description,
       buttons,
-      matchParent,
+      fillParent = true,
     } = attrs;
 
     return m(
         'section.pf-details-shell',
-        {class: classNames(matchParent && 'pf-match-parent')},
+        {class: classNames(fillParent && 'pf-fill-parent')},
         m(
             'header.pf-header-bar',
             m('h1.pf-header-title', title),
diff --git a/ui/src/frontend/widgets/virtual_scroll_container.ts b/ui/src/frontend/widgets/virtual_scroll_container.ts
new file mode 100644
index 0000000..2e210ff
--- /dev/null
+++ b/ui/src/frontend/widgets/virtual_scroll_container.ts
@@ -0,0 +1,51 @@
+// Copyright (C) 2023 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import m from 'mithril';
+
+import {findRef, toHTMLElement} from './utils';
+
+interface VirtualScrollContainerAttrs {
+  // Called when the scrolling element is created, updates, or scrolls.
+  onScroll?: (dom: HTMLElement) => void;
+}
+
+export class VirtualScrollContainer implements
+    m.ClassComponent<VirtualScrollContainerAttrs> {
+  private readonly REF = 'virtual-scroll-container';
+  view({attrs, children}: m.Vnode<VirtualScrollContainerAttrs>) {
+    const {
+      onScroll = () => {},
+    } = attrs;
+
+    return m(
+        '.pf-virtual-scroll-container',
+        {
+          ref: this.REF,
+          onscroll: (e: Event) => onScroll(e.target as HTMLElement),
+        },
+        children);
+  }
+
+  oncreate({dom, attrs}: m.VnodeDOM<VirtualScrollContainerAttrs, this>) {
+    const {
+      onScroll = () => {},
+    } = attrs;
+
+    const element = findRef(dom, this.REF);
+    if (element) {
+      onScroll(toHTMLElement(element));
+    }
+  }
+}
diff --git a/ui/src/frontend/widgets_page.ts b/ui/src/frontend/widgets_page.ts
index e3fc2e3..ea0e632 100644
--- a/ui/src/frontend/widgets_page.ts
+++ b/ui/src/frontend/widgets_page.ts
@@ -23,6 +23,7 @@
 import {Icons} from './semantic_icons';
 import {TableShowcase} from './tables/table_showcase';
 import {Button} from './widgets/button';
+import {Callout} from './widgets/callout';
 import {Checkbox} from './widgets/checkbox';
 import {EmptyState} from './widgets/empty_state';
 import {Form, FormButtonBar, FormLabel} from './widgets/form';
@@ -649,6 +650,20 @@
               }),
             ),
           }),
+          m('h2', 'Callout'),
+          m(
+            WidgetShowcase, {
+              renderWidget: () => m(
+                Callout,
+                {
+                  icon: 'info',
+                },
+                'Lorem ipsum dolor sit amet, consectetur adipiscing elit. ' +
+                'Nulla rhoncus tempor neque, sed malesuada eros dapibus vel. ' +
+                'Aliquam in ligula vitae tortor porttitor laoreet iaculis ' +
+                'finibus est.',
+              ),
+            }),
     );
   },
 });