Merge "Optimise create_view_function to avoid recreating statements on every iteration"
diff --git a/ui/src/assets/common.scss b/ui/src/assets/common.scss
index ad91f01..d0e33c4 100644
--- a/ui/src/assets/common.scss
+++ b/ui/src/assets/common.scss
@@ -312,8 +312,10 @@
 .query-error {
   padding: 20px 10px;
   color: hsl(-10, 50%, 50%);
-  font-family: "Roboto Condensed", sans-serif;
+  font-family: "Roboto Mono", sans-serif;
+  font-size: 12px;
   font-weight: 300;
+  white-space: pre;
 }
 
 .dropdown {
diff --git a/ui/src/common/actions.ts b/ui/src/common/actions.ts
index 2f525e6..19954e4 100644
--- a/ui/src/common/actions.ts
+++ b/ui/src/common/actions.ts
@@ -38,7 +38,13 @@
 } from './dragndrop_logic';
 import {createEmptyState} from './empty_state';
 import {DEFAULT_VIEWING_OPTION, PERF_SAMPLES_KEY} from './flamegraph_util';
-import {traceEventBegin, traceEventEnd, TraceEventScope} from './metatracing';
+import {
+  MetatraceTrackId,
+  traceEvent,
+  traceEventBegin,
+  traceEventEnd,
+  TraceEventScope,
+} from './metatracing';
 import {
   AdbRecordingTarget,
   Area,
@@ -538,7 +544,8 @@
     if (statusTraceEvent) {
       traceEventEnd(statusTraceEvent);
     }
-    statusTraceEvent = traceEventBegin(args.msg);
+    statusTraceEvent =
+        traceEventBegin(args.msg, {track: MetatraceTrackId.kOmniboxStatus});
     state.status = args;
   },
 
@@ -1061,7 +1068,13 @@
   },
 
   setCurrentTab(state: StateDraft, args: {tab: string|undefined}) {
-    state.currentTab = args.tab;
+    traceEvent('setCurrentTab', () => {
+      state.currentTab = args.tab;
+    }, {
+      args: {
+        tab: args.tab ?? '<undefined>',
+      },
+    });
   },
 
   toggleAllTrackGroups(state: StateDraft, args: {collapsed: boolean}) {
diff --git a/ui/src/common/metatracing.ts b/ui/src/common/metatracing.ts
index c26371d..d1032ce 100644
--- a/ui/src/common/metatracing.ts
+++ b/ui/src/common/metatracing.ts
@@ -19,7 +19,15 @@
 import {toNs} from './time';
 
 const METATRACING_BUFFER_SIZE = 100000;
-const JS_THREAD_ID = 2;
+
+export enum MetatraceTrackId {
+  // 1 is reserved for the Trace Processor track.
+  // Events emitted by the JS main thread.
+  kMainThread = 2,
+  // Async track for the status (e.g. "loading tracks") shown to the user
+  // in the omnibox.
+  kOmniboxStatus = 3,
+}
 
 import MetatraceCategories = perfetto.protos.MetatraceCategories;
 
@@ -67,20 +75,29 @@
   eventName: string;
   startNs: number;
   durNs: number;
+  track: MetatraceTrackId;
+  args?: {[key: string]: string};
 }
 
 const traceEvents: TraceEvent[] = [];
 
 function readMetatrace(): Uint8Array {
   const eventToPacket = (e: TraceEvent): TracePacket => {
+    const metatraceEvent = PerfettoMetatrace.create({
+      eventName: e.eventName,
+      threadId: e.track,
+      eventDurationNs: e.durNs,
+    });
+    for (const [key, value] of Object.entries(e.args ?? {})) {
+      metatraceEvent.args.push(PerfettoMetatrace.Arg.create({
+        key,
+        value,
+      }));
+    }
     return TracePacket.create({
       timestamp: e.startNs,
       timestampClockId: 1,
-      perfettoMetatrace: PerfettoMetatrace.create({
-        eventName: e.eventName,
-        threadId: JS_THREAD_ID,
-        eventDurationNs: e.durNs,
-      }),
+      perfettoMetatrace: metatraceEvent,
     });
   };
   const packets: TracePacket[] = [];
@@ -93,8 +110,14 @@
   return Trace.encode(trace).finish();
 }
 
+interface TraceEventParams {
+  track?: MetatraceTrackId;
+  args?: {[key: string]: string};
+}
+
 export type TraceEventScope = {
-  startNs: number, eventName: string;
+  startNs: number; eventName: string;
+  params?: TraceEventParams;
 };
 
 const correctedTimeOrigin = new Date().getTime() - performance.now();
@@ -103,10 +126,23 @@
   return toNs((correctedTimeOrigin + performance.now()) / 1000);
 }
 
-export function traceEventBegin(eventName: string): TraceEventScope {
+export function traceEvent<T>(
+    name: string, event: () => T, params?: TraceEventParams): T {
+  const scope = traceEventBegin(name, params);
+  try {
+    const result = event();
+    return result;
+  } finally {
+    traceEventEnd(scope);
+  }
+}
+
+export function traceEventBegin(
+    eventName: string, params?: TraceEventParams): TraceEventScope {
   return {
     eventName,
     startNs: now(),
+    params: params,
   };
 }
 
@@ -117,6 +153,8 @@
     eventName: traceEvent.eventName,
     startNs: traceEvent.startNs,
     durNs: now() - traceEvent.startNs,
+    track: traceEvent.params?.track ?? MetatraceTrackId.kMainThread,
+    args: traceEvent.params?.args,
   });
   while (traceEvents.length > METATRACING_BUFFER_SIZE) {
     traceEvents.shift();
diff --git a/ui/src/frontend/analyze_page.ts b/ui/src/frontend/analyze_page.ts
index dc0025f..e062888 100644
--- a/ui/src/frontend/analyze_page.ts
+++ b/ui/src/frontend/analyze_page.ts
@@ -65,6 +65,7 @@
       globals.rafScheduler.scheduleFullRedraw();
     });
   }
+  globals.rafScheduler.scheduleDelayedFullRedraw();
 }
 
 function getEngine(): EngineProxy|undefined {
diff --git a/ui/src/frontend/bottom_tab.ts b/ui/src/frontend/bottom_tab.ts
index e192969..0e28ba6 100644
--- a/ui/src/frontend/bottom_tab.ts
+++ b/ui/src/frontend/bottom_tab.ts
@@ -17,9 +17,10 @@
 
 import {Actions} from '../common/actions';
 import {EngineProxy} from '../common/engine';
+import {traceEvent} from '../common/metatracing';
 import {Registry} from '../common/registry';
-import {globals} from './globals';
 
+import {globals} from './globals';
 import {Panel, PanelSize, PanelVNode} from './panel';
 
 export interface NewBottomTabArgs {
@@ -211,23 +212,34 @@
   // created panel (which can be used in the future to close it).
   addTab(args: AddTabArgs): AddTabResult {
     const uuid = uuidv4();
-    const newPanel = bottomTabRegistry.get(args.kind).create({
-      engine: this.engine,
-      uuid,
-      config: args.config,
-      tag: args.tag,
-    });
+    return traceEvent('addTab', () => {
+      const newPanel = bottomTabRegistry.get(args.kind).create({
+        engine: this.engine,
+        uuid,
+        config: args.config,
+        tag: args.tag,
+      });
 
-    this.pendingTabs.push({
-      tab: newPanel,
-      args,
-      startTime: window.performance.now(),
-    });
-    this.flushPendingTabs();
+      this.pendingTabs.push({
+        tab: newPanel,
+        args,
+        startTime: window.performance.now(),
+      });
+      this.flushPendingTabs();
 
-    return {
-      uuid,
-    };
+      return {
+        uuid,
+      };
+    }, {
+      args: {
+        'uuid': uuid,
+        'kind': args.kind,
+        'tag': args.tag ?? '<undefined>',
+        'config': JSON.stringify(
+            args.config,
+            (_, value) => typeof value === 'bigint' ? value.toString() : value),
+      },
+    });
   }
 
   closeTabByTag(tag: string) {
@@ -301,19 +313,30 @@
         // The first tab is not ready yet, wait.
         return;
       }
-      this.pendingTabs.shift();
 
-      const index =
-          args.tag ? this.tabs.findIndex((tab) => tab.tag === args.tag) : -1;
-      if (index === -1) {
-        this.tabs.push(tab);
-      } else {
-        this.tabs[index] = tab;
-      }
+      traceEvent('addPendingTab', () => {
+        this.pendingTabs.shift();
 
-      if (args.select === undefined || args.select === true) {
-        globals.dispatch(Actions.setCurrentTab({tab: tabSelectionKey(tab)}));
-      }
+        const index =
+            args.tag ? this.tabs.findIndex((tab) => tab.tag === args.tag) : -1;
+        if (index === -1) {
+          this.tabs.push(tab);
+        } else {
+          this.tabs[index] = tab;
+        }
+
+        if (args.select === undefined || args.select === true) {
+          globals.dispatch(Actions.setCurrentTab({tab: tabSelectionKey(tab)}));
+        }
+        // setCurrentTab will usually schedule a redraw, but not if we replace
+        // the tab with the same tag, so we force an update here.
+        globals.rafScheduler.scheduleFullRedraw();
+      }, {
+        args: {
+          'uuid': tab.uuid,
+          'is_loading': tab.isLoading().toString(),
+        },
+      });
     }
   }
 
diff --git a/ui/src/frontend/query_table.ts b/ui/src/frontend/query_table.ts
index ad75cb6..1239ef4 100644
--- a/ui/src/frontend/query_table.ts
+++ b/ui/src/frontend/query_table.ts
@@ -19,6 +19,7 @@
 import {Actions} from '../common/actions';
 import {QueryResponse} from '../common/queries';
 import {Row} from '../common/query_result';
+import {formatDurationShort, tpDurationFromNanos} from '../common/time';
 
 import {Anchor} from './anchor';
 import {copyToClipboard, queryResponseToClipboard} from './clipboard';
@@ -30,7 +31,6 @@
 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;
@@ -223,11 +223,14 @@
   }
 
   renderTitle(resp?: QueryResponse) {
-    if (exists(resp)) {
-      return `Query result - ${Math.round(resp.durationMs)} ms`;
-    } else {
+    if (!resp) {
       return 'Query - running';
     }
+    const result = resp.error ? 'error' : `${resp.rows.length} rows`;
+    const msToNs = 1e6;
+    const dur =
+        formatDurationShort(tpDurationFromNanos(resp.durationMs * msToNs));
+    return `Query result (${result}) - ${dur}`;
   }
 
   renderButtons(
diff --git a/ui/src/frontend/raf_scheduler.ts b/ui/src/frontend/raf_scheduler.ts
index 4571e7e..1c7f0c5 100644
--- a/ui/src/frontend/raf_scheduler.ts
+++ b/ui/src/frontend/raf_scheduler.ts
@@ -109,6 +109,19 @@
     this.maybeScheduleAnimationFrame(true);
   }
 
+  // Schedule a full redraw to happen after a short delay (50 ms).
+  // This is done to prevent flickering / visual noise and allow the UI to fetch
+  // the initial data from the Trace Processor.
+  // There is a chance that someone else schedules a full redraw in the
+  // meantime, forcing the flicker, but in practice it works quite well and
+  // avoids a lot of complexity for the callers.
+  scheduleDelayedFullRedraw() {
+    // 50ms is half of the responsiveness threshold (100ms):
+    // https://web.dev/rail/#response-process-events-in-under-50ms
+    const delayMs = 50;
+    setTimeout(() => this.scheduleFullRedraw(), delayMs);
+  }
+
   syncDomRedraw(nowMs: number) {
     const redrawStart = debugNow();
     this._syncDomRedraw(nowMs);
diff --git a/ui/src/tracks/debug/slice_track.ts b/ui/src/tracks/debug/slice_track.ts
index d7d1c20..5d0b54b 100644
--- a/ui/src/tracks/debug/slice_track.ts
+++ b/ui/src/tracks/debug/slice_track.ts
@@ -110,10 +110,10 @@
         select
           row_number() over () as id,
           ${sliceColumns.ts} as ts,
-          cast(${dur} as int) as dur,
+          ifnull(cast(${dur} as int), -1) as dur,
           printf('%s', ${sliceColumns.name}) as name
           ${argColumns.length > 0 ? ',' : ''}
-          ${argColumns.map((c) => `${c} as ${ARG_PREFIX}${c}`).join(',')}
+          ${argColumns.map((c) => `${c} as ${ARG_PREFIX}${c}`).join(',\n')}
         from ${sqlViewName}
       )
       select