ui: remove JobStatusUpdate and simplify sidebar logic

This CL simplifies the way deferred conversion jobs
are handled AND the sidebar code.
The end user feature is the following: some of the actions
accessible by the sidebar (convert to json or legacy ui)
take several seconds and are offloaded to a worker. Today
we show a spinner on the sidebar when these are in progress.
Today the way the progress tracking works involves a lot of
plumbing all over the places (globals, publish, etc).
Instead I'm changing this as follows:
- I rewrote the long conversion operations as promises, which
  resolve when the conversion is done (which is notified by
  a postmessage from the dedicated worker)
- I updated the spinner logic in the sidebar to apply to any
  case when the action of the menu item returns a promise.
  This removes a lot of special-case handling for those spinners.

Change-Id: I41fc99be10cb789d230551220da66b0f00db7531
diff --git a/ui/src/common/conversion_jobs.ts b/ui/src/common/conversion_jobs.ts
deleted file mode 100644
index 6805bc7..0000000
--- a/ui/src/common/conversion_jobs.ts
+++ /dev/null
@@ -1,30 +0,0 @@
-// Copyright (C) 2021 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.
-
-export enum ConversionJobStatus {
-  InProgress = 'InProgress',
-  NotRunning = 'NotRunning',
-}
-
-export type ConversionJobName =
-  | 'convert_systrace'
-  | 'convert_json'
-  | 'open_in_legacy'
-  | 'convert_pprof'
-  | 'create_permalink';
-
-export interface ConversionJobStatusUpdate {
-  jobName: ConversionJobName;
-  jobStatus: ConversionJobStatus;
-}
diff --git a/ui/src/core_plugins/commands/index.ts b/ui/src/core_plugins/commands/index.ts
index f539cd3..4170338 100644
--- a/ui/src/core_plugins/commands/index.ts
+++ b/ui/src/core_plugins/commands/index.ts
@@ -347,10 +347,9 @@
     'Open trace in Legacy UI',
   );
   if (await isLegacyTrace(file)) {
-    openFileWithLegacyTraceViewer(file);
-    return;
+    return await openFileWithLegacyTraceViewer(file);
   }
-  openInOldUIWithSizeCheck(file);
+  return await openInOldUIWithSizeCheck(file);
 }
 
 export const plugin: PluginDescriptor = {
diff --git a/ui/src/frontend/globals.ts b/ui/src/frontend/globals.ts
index 347bb91..063dd32 100644
--- a/ui/src/frontend/globals.ts
+++ b/ui/src/frontend/globals.ts
@@ -16,10 +16,6 @@
 import {createStore, Store} from '../base/store';
 import {Actions, DeferredAction} from '../common/actions';
 import {CommandManagerImpl} from '../core/command_manager';
-import {
-  ConversionJobName,
-  ConversionJobStatus,
-} from '../common/conversion_jobs';
 import {createEmptyState} from '../common/empty_state';
 import {State} from '../common/state';
 import {setPerfHooks} from '../core/perf';
@@ -49,7 +45,6 @@
   private _trackDataStore?: TrackDataStore = undefined;
   private _bufferUsage?: number = undefined;
   private _recordingLog?: string = undefined;
-  private _jobStatus?: Map<ConversionJobName, ConversionJobStatus> = undefined;
   httpRpcState: HttpRpcState = {connected: false};
   showPanningHint = false;
   permalinkHash?: string;
@@ -153,26 +148,6 @@
     return this._recordingLog;
   }
 
-  getConversionJobStatus(name: ConversionJobName): ConversionJobStatus {
-    return this.getJobStatusMap().get(name) ?? ConversionJobStatus.NotRunning;
-  }
-
-  setConversionJobStatus(name: ConversionJobName, status: ConversionJobStatus) {
-    const map = this.getJobStatusMap();
-    if (status === ConversionJobStatus.NotRunning) {
-      map.delete(name);
-    } else {
-      map.set(name, status);
-    }
-  }
-
-  private getJobStatusMap(): Map<ConversionJobName, ConversionJobStatus> {
-    if (!this._jobStatus) {
-      this._jobStatus = new Map();
-    }
-    return this._jobStatus;
-  }
-
   setBufferUsage(bufferUsage: number) {
     this._bufferUsage = bufferUsage;
   }
diff --git a/ui/src/frontend/legacy_trace_viewer.ts b/ui/src/frontend/legacy_trace_viewer.ts
index 3246713..04668f0 100644
--- a/ui/src/frontend/legacy_trace_viewer.ts
+++ b/ui/src/frontend/legacy_trace_viewer.ts
@@ -172,16 +172,21 @@
   });
 }
 
-export function openInOldUIWithSizeCheck(trace: Blob) {
+export async function openInOldUIWithSizeCheck(trace: Blob): Promise<void> {
   // Perfetto traces smaller than 50mb can be safely opened in the legacy UI.
   if (trace.size < 1024 * 1024 * 50) {
-    convertToJson(trace, openBufferWithLegacyTraceViewer);
-    return;
+    return await convertToJson(trace, openBufferWithLegacyTraceViewer);
   }
 
   // Give the user the option to truncate larger perfetto traces.
   const size = Math.round(trace.size / (1024 * 1024));
-  showModal({
+
+  // If the user presses one of the buttons below, remember the promise that
+  // they trigger, so we await for it before returning.
+  let nextPromise: Promise<void> | undefined;
+  const setNextPromise = (p: Promise<void>) => (nextPromise = p);
+
+  await showModal({
     title: 'Legacy UI may fail to open this trace',
     content: m(
       'div',
@@ -206,30 +211,38 @@
     buttons: [
       {
         text: 'Open full trace (not recommended)',
-        action: () => convertToJson(trace, openBufferWithLegacyTraceViewer),
+        action: () =>
+          setNextPromise(convertToJson(trace, openBufferWithLegacyTraceViewer)),
       },
       {
         text: 'Open beginning of trace',
         action: () =>
-          convertToJson(
-            trace,
-            openBufferWithLegacyTraceViewer,
-            /* truncate*/ 'start',
+          setNextPromise(
+            convertToJson(
+              trace,
+              openBufferWithLegacyTraceViewer,
+              /* truncate*/ 'start',
+            ),
           ),
       },
       {
         text: 'Open end of trace',
         primary: true,
         action: () =>
-          convertToJson(
-            trace,
-            openBufferWithLegacyTraceViewer,
-            /* truncate*/ 'end',
+          setNextPromise(
+            convertToJson(
+              trace,
+              openBufferWithLegacyTraceViewer,
+              /* truncate*/ 'end',
+            ),
           ),
       },
     ],
   });
-  return;
+  // nextPromise is undefined if the user just dimisses the dialog with ESC.
+  if (nextPromise !== undefined) {
+    await nextPromise;
+  }
 }
 
 // TraceViewer method that we wire up to trigger the file load.
diff --git a/ui/src/frontend/permalink.ts b/ui/src/frontend/permalink.ts
index 9590347..17ecb8f 100644
--- a/ui/src/frontend/permalink.ts
+++ b/ui/src/frontend/permalink.ts
@@ -15,7 +15,6 @@
 import m from 'mithril';
 import {assertExists} from '../base/logging';
 import {Actions} from '../common/actions';
-import {ConversionJobStatus} from '../common/conversion_jobs';
 import {
   JsonSerialize,
   parseAppState,
@@ -29,10 +28,6 @@
 } from '../common/gcs_uploader';
 import {globals} from './globals';
 import {
-  publishConversionJobStatusUpdate,
-  publishPermalinkHash,
-} from './publish';
-import {
   SERIALIZED_STATE_VERSION,
   SerializedAppState,
 } from '../public/state_serialization_schema';
@@ -40,6 +35,7 @@
 import {showModal} from '../widgets/modal';
 import {AppImpl} from '../core/app_impl';
 import {Router} from '../core/router';
+import {publishPermalinkHash} from './publish';
 
 // Permalink serialization has two layers:
 // 1. Serialization of the app state (state_serialization.ts):
@@ -76,21 +72,8 @@
 }
 
 export async function createPermalink(opts: PermalinkOptions): Promise<void> {
-  const jobName = 'create_permalink';
-  publishConversionJobStatusUpdate({
-    jobName,
-    jobStatus: ConversionJobStatus.InProgress,
-  });
-
-  try {
-    const hash = await createPermalinkInternal(opts);
-    publishPermalinkHash(hash);
-  } finally {
-    publishConversionJobStatusUpdate({
-      jobName,
-      jobStatus: ConversionJobStatus.NotRunning,
-    });
-  }
+  const hash = await createPermalinkInternal(opts);
+  publishPermalinkHash(hash);
 }
 
 // Returns the file name, not the full url (i.e. the name of the GCS object).
diff --git a/ui/src/frontend/publish.ts b/ui/src/frontend/publish.ts
index 5eff236..26e0a4c 100644
--- a/ui/src/frontend/publish.ts
+++ b/ui/src/frontend/publish.ts
@@ -12,7 +12,6 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-import {ConversionJobStatusUpdate} from '../common/conversion_jobs';
 import {raf} from '../core/raf_scheduler';
 import {HttpRpcState} from '../trace_processor/http_rpc_engine';
 import {globals} from './globals';
@@ -27,13 +26,6 @@
   raf.scheduleFullRedraw();
 }
 
-export function publishConversionJobStatusUpdate(
-  job: ConversionJobStatusUpdate,
-) {
-  globals.setConversionJobStatus(job.jobName, job.jobStatus);
-  globals.publishRedraw();
-}
-
 export function publishBufferUsage(args: {percentage: number}) {
   globals.setBufferUsage(args.percentage);
   globals.publishRedraw();
diff --git a/ui/src/frontend/sidebar.ts b/ui/src/frontend/sidebar.ts
index 0218d65..f5980c6 100644
--- a/ui/src/frontend/sidebar.ts
+++ b/ui/src/frontend/sidebar.ts
@@ -17,7 +17,6 @@
 import {isString} from '../base/object_utils';
 import {getCurrentChannel} from '../core/channels';
 import {TRACE_SUFFIX} from '../common/constants';
-import {ConversionJobStatus} from '../common/conversion_jobs';
 import {
   disableMetatracingAndGetTrace,
   enableMetatracing,
@@ -47,7 +46,6 @@
 import {SidebarMenuItem} from '../public/sidebar';
 import {AppImpl} from '../core/app_impl';
 import {Trace} from '../public/trace';
-import {Router} from '../core/router';
 
 const GITILES_URL =
   'https://android.googlesource.com/platform/external/perfetto';
@@ -112,10 +110,9 @@
 
 interface SectionItem {
   t: string;
-  a: string | ((e: Event) => void);
+  a: string | (() => void | Promise<void>);
   i: string;
-  title?: string;
-  isPending?: () => boolean;
+  tooltip?: string;
   isVisible?: () => boolean;
   internalUserOnly?: boolean;
   checkDownloadDisabled?: boolean;
@@ -150,10 +147,7 @@
         : cmd.name;
       return {
         t: cmd.name,
-        a: (e: Event) => {
-          e.preventDefault();
-          cmd.callback();
-        },
+        a: cmd.callback,
         i: item.icon,
         title,
       };
@@ -170,18 +164,18 @@
         ...insertSidebarMenuitems('navigation'),
         {
           t: 'Record new trace',
-          a: (e: Event) => navigateToPage(e, 'record'),
+          a: '#!/record',
           i: 'fiber_smart_record',
         },
         {
           t: 'Widgets',
-          a: (e: Event) => navigateToPage(e, 'widgets'),
+          a: '#!/widgets',
           i: 'widgets',
           isVisible: () => WIDGETS_PAGE_IN_NAV_FLAG.get(),
         },
         {
           t: 'Plugins',
-          a: (e: Event) => navigateToPage(e, 'plugins'),
+          a: '#!/plugins',
           i: 'extension',
           isVisible: () => PLUGINS_PAGE_IN_NAV_FLAG.get(),
         },
@@ -194,59 +188,48 @@
       hideIfNoTraceLoaded: true,
       appendOpenedTraceTitle: true,
       items: [
-        {
-          t: 'Show timeline',
-          a: (e: Event) => navigateToPage(e, 'viewer'),
-          i: 'line_style',
-        },
+        {t: 'Show timeline', a: '#!/viewer', i: 'line_style'},
         {
           t: 'Share',
-          a: handleShareTrace,
+          a: shareTrace,
           i: 'share',
           internalUserOnly: true,
-          isPending: () =>
-            globals.getConversionJobStatus('create_permalink') ===
-            ConversionJobStatus.InProgress,
         },
         {
           t: 'Download',
-          a: (e: Event) => trace && downloadTrace(e, trace),
+          a: () => {
+            if (trace) {
+              downloadTrace(trace);
+            }
+          },
           i: 'file_download',
           checkDownloadDisabled: true,
         },
         {
           t: 'Query (SQL)',
-          a: (e: Event) => navigateToPage(e, 'query'),
+          a: '#!/query',
           i: 'database',
         },
         {
           t: 'Explore',
-          a: (e: Event) => navigateToPage(e, 'explore'),
+          a: '#!/explore',
           i: 'data_exploration',
           isVisible: () => EXPLORE_PAGE_IN_NAV_FLAG.get(),
         },
         {
           t: 'Insights',
-          a: (e: Event) => navigateToPage(e, 'insights'),
+          a: '#!/insights',
           i: 'insights',
           isVisible: () => INSIGHTS_PAGE_IN_NAV_FLAG.get(),
         },
         {
           t: 'Viz',
-          a: (e: Event) => navigateToPage(e, 'viz'),
+          a: '#!/viz',
           i: 'area_chart',
           isVisible: () => VIZ_PAGE_IN_NAV_FLAG.get(),
         },
-        {
-          t: 'Metrics',
-          a: (e: Event) => navigateToPage(e, 'metrics'),
-          i: 'speed',
-        },
-        {
-          t: 'Info and stats',
-          a: (e: Event) => navigateToPage(e, 'info'),
-          i: 'info',
-        },
+        {t: 'Metrics', a: '#!/metrics', i: 'speed'},
+        {t: 'Info and stats', a: '#!/info', i: 'info'},
       ],
     },
 
@@ -260,17 +243,11 @@
           t: 'Switch to legacy UI',
           a: openCurrentTraceWithOldUI,
           i: 'filter_none',
-          isPending: () =>
-            globals.getConversionJobStatus('open_in_legacy') ===
-            ConversionJobStatus.InProgress,
         },
         {
           t: 'Convert to .json',
           a: convertTraceToJson,
           i: 'file_download',
-          isPending: () =>
-            globals.getConversionJobStatus('convert_json') ===
-            ConversionJobStatus.InProgress,
           checkDownloadDisabled: true,
         },
 
@@ -279,9 +256,6 @@
           a: convertTraceToSystrace,
           i: 'file_download',
           isVisible: () => Boolean(trace?.traceInfo.hasFtrace),
-          isPending: () =>
-            globals.getConversionJobStatus('convert_systrace') ===
-            ConversionJobStatus.InProgress,
           checkDownloadDisabled: true,
         },
       ],
@@ -299,13 +273,9 @@
       expanded: true,
       summary: 'Documentation & Bugs',
       items: [
-        {t: 'Keyboard shortcuts', a: openHelp, i: 'help'},
+        {t: 'Keyboard shortcuts', a: toggleHelp, i: 'help'},
         {t: 'Documentation', a: 'https://perfetto.dev/docs', i: 'find_in_page'},
-        {
-          t: 'Flags',
-          a: (e: Event) => navigateToPage(e, 'flags'),
-          i: 'emoji_flags',
-        },
+        {t: 'Flags', a: '#!/flags', i: 'emoji_flags'},
         {
           t: 'Report a bug',
           a: getBugReportUrl(),
@@ -315,13 +285,13 @@
           ? [
               {
                 t: 'Record metatrace',
-                a: (e: Event) => recordMetatrace(e, trace.engine),
+                a: () => recordMetatrace(trace.engine),
                 i: 'fiber_smart_record',
                 checkMetatracingDisabled: true,
               },
               {
                 t: 'Finalise metatrace',
-                a: (e: Event) => finaliseMetatrace(e, trace.engine),
+                a: () => finaliseMetatrace(trace.engine),
                 i: 'file_download',
                 checkMetatracingEnabled: true,
               },
@@ -332,11 +302,6 @@
   ];
 }
 
-function openHelp(e: Event) {
-  e.preventDefault();
-  toggleHelp();
-}
-
 function downloadTraceFromUrl(url: string): Promise<File> {
   return m.request({
     method: 'GET',
@@ -365,69 +330,40 @@
   } else if (src.type === 'FILE') {
     return src.file;
   } else if (src.type === 'URL') {
-    return downloadTraceFromUrl(src.url);
+    return await downloadTraceFromUrl(src.url);
   } else {
     throw new Error(`Loading to catapult from source with type ${src.type}`);
   }
 }
 
-function openCurrentTraceWithOldUI(e: Event) {
-  e.preventDefault();
+async function openCurrentTraceWithOldUI(): Promise<void> {
   assertTrue(isTraceLoaded());
   AppImpl.instance.analytics.logEvent(
     'Trace Actions',
     'Open current trace in legacy UI',
   );
   if (!isTraceLoaded()) return;
-  getCurrentTrace()
-    .then((file) => {
-      openInOldUIWithSizeCheck(file);
-    })
-    .catch((error) => {
-      throw new Error(`Failed to get current trace ${error}`);
-    });
+  const file = await getCurrentTrace();
+  await openInOldUIWithSizeCheck(file);
 }
 
-function convertTraceToSystrace(e: Event) {
-  e.preventDefault();
+async function convertTraceToSystrace(): Promise<void> {
   assertTrue(isTraceLoaded());
   AppImpl.instance.analytics.logEvent('Trace Actions', 'Convert to .systrace');
   if (!isTraceLoaded()) return;
-  getCurrentTrace()
-    .then((file) => {
-      convertTraceToSystraceAndDownload(file);
-    })
-    .catch((error) => {
-      throw new Error(`Failed to get current trace ${error}`);
-    });
+  const file = await getCurrentTrace();
+  await convertTraceToSystraceAndDownload(file);
 }
 
-function convertTraceToJson(e: Event) {
-  e.preventDefault();
+async function convertTraceToJson(): Promise<void> {
   assertTrue(isTraceLoaded());
   AppImpl.instance.analytics.logEvent('Trace Actions', 'Convert to .json');
   if (!isTraceLoaded()) return;
-  getCurrentTrace()
-    .then((file) => {
-      convertTraceToJsonAndDownload(file);
-    })
-    .catch((error) => {
-      throw new Error(`Failed to get current trace ${error}`);
-    });
+  const file = await getCurrentTrace();
+  await convertTraceToJsonAndDownload(file);
 }
 
-function navigateToPage(e: Event, pageName: string) {
-  e.preventDefault();
-  Router.navigate(`#!/${pageName}`);
-}
-
-function handleShareTrace(e: Event) {
-  e.preventDefault();
-  shareTrace();
-}
-
-function downloadTrace(e: Event, trace: Trace) {
-  e.preventDefault();
+function downloadTrace(trace: Trace) {
   if (!isDownloadable() || !isTraceLoaded()) return;
   AppImpl.instance.analytics.logEvent('Trace Actions', 'Download trace');
 
@@ -467,8 +403,7 @@
   );
 }
 
-function recordMetatrace(e: Event, engine: Engine) {
-  e.preventDefault();
+function recordMetatrace(engine: Engine) {
   AppImpl.instance.analytics.logEvent('Trace Actions', 'Record metatrace');
 
   if (!highPrecisionTimersAvailable()) {
@@ -505,8 +440,7 @@
   }
 }
 
-async function finaliseMetatrace(e: Event, engine: Engine) {
-  e.preventDefault();
+async function finaliseMetatrace(engine: Engine) {
   AppImpl.instance.analytics.logEvent('Trace Actions', 'Finalise metatrace');
 
   const jsEvents = disableMetatracingAndGetTrace();
@@ -692,10 +626,57 @@
 
 export class Sidebar implements m.ClassComponent<OptionalTraceAttrs> {
   private _redrawWhileAnimating = new Animation(() => raf.scheduleFullRedraw());
+  private _asyncJobPending = new Set<string>();
+  private _onClickHandlers = new Map<string, Function>();
+
   view({attrs}: m.CVnode<OptionalTraceAttrs>) {
     if (AppImpl.instance.sidebar.sidebarHidden) return null;
+
+    // The code below iterates through the sections and SectionActions provided
+    // by getSections() and creates the onClick handlers for the items where
+    // a (async)function is provided.
+    // We do it in view() and not in the constructor because new sidebar items
+    // can be added later by plugins.
+    // What we want to achieve here is the following:
+    // - We want to allow plugins that contribute to the sidebar to just specify
+    //   either string URLs or (async) functions as actions for a sidebar menu.
+    // - When they specify an async function, we want to render a spinner, next
+    //   to the menu item, until the promise is resolved.
+    // - [Minor] we want to call e.preventDefault() to override the behaviour of
+    //   the <a href='#'> which gets rendered for accessibility reasons.
+    const sections = getSections(attrs.trace);
+    for (const section of sections) {
+      for (const item of section.items) {
+        const itemId = item.t;
+        // We call this on every render pass. Don't re-create wrappers on each
+        // render cycle if we did it already as that is wasteful.
+        if (this._onClickHandlers.has(itemId)) continue;
+
+        const itemAction = item.a;
+
+        // item.a can be either a function or a URL. In the latter case, we
+        // don't need to generate any onclick handler.
+        if (typeof itemAction !== 'function') continue;
+        const onClickHandler = (e: Event) => {
+          e.preventDefault(); // Make the <a href="#"> a no-op.
+          const res = itemAction();
+          if (!(res instanceof Promise)) return;
+          if (this._asyncJobPending.has(itemId)) {
+            return; // Don't queue up another action if not yet finished.
+          }
+          this._asyncJobPending.add(itemId);
+          raf.scheduleFullRedraw();
+          res.finally(() => {
+            this._asyncJobPending.delete(itemId);
+            raf.scheduleFullRedraw();
+          });
+        };
+        this._onClickHandlers.set(itemId, onClickHandler);
+      }
+    }
+
     const vdomSections = [];
-    for (const section of getSections(attrs.trace)) {
+    for (const section of sections) {
       if (section.hideIfNoTraceLoaded && !isTraceLoaded()) continue;
       const vdomItems = [];
       for (const item of section.items) {
@@ -704,14 +685,14 @@
         }
         let css = '';
         let attrs = {
-          onclick: typeof item.a === 'function' ? item.a : null,
+          onclick: this._onClickHandlers.get(item.t),
           href: isString(item.a) ? item.a : '#',
-          target: isString(item.a) ? '_blank' : null,
+          target: isString(item.a) && !item.a.startsWith('#') ? '_blank' : null,
           disabled: false,
           id: item.t.toLowerCase().replace(/[^\w]/g, '_'),
         };
-        if (item.isPending && item.isPending()) {
-          attrs.onclick = (e) => e.preventDefault();
+
+        if (this._asyncJobPending.has(item.t)) {
           css = '.pending';
         }
         if (item.internalUserOnly && !globals.isInternalUser) {
@@ -739,7 +720,7 @@
         }
         if (item.checkDownloadDisabled && !isDownloadable()) {
           attrs = {
-            onclick: (e) => {
+            onclick: (e: Event) => {
               e.preventDefault();
               alert('Can not download external trace.');
             },
@@ -754,7 +735,7 @@
             'li',
             m(
               `a${css}`,
-              {...attrs, title: item.title},
+              {...attrs, title: item.tooltip},
               m('i.material-icons', item.i),
               item.t,
             ),
diff --git a/ui/src/frontend/trace_attrs.ts b/ui/src/frontend/trace_attrs.ts
index b996265..dd0f1ab 100644
--- a/ui/src/frontend/trace_attrs.ts
+++ b/ui/src/frontend/trace_attrs.ts
@@ -39,7 +39,7 @@
   return true;
 }
 
-export function shareTrace() {
+export async function shareTrace() {
   const traceSource = assertExists(AppImpl.instance.trace?.traceInfo.source);
   const traceUrl = (traceSource as TraceUrlSource).url ?? '';
 
@@ -74,7 +74,7 @@
   );
   if (result) {
     AppImpl.instance.analytics.logEvent('Trace Actions', 'Create permalink');
-    createPermalink({mode: 'APP_STATE'});
+    return await createPermalink({mode: 'APP_STATE'});
   }
 }
 
diff --git a/ui/src/frontend/trace_converter.ts b/ui/src/frontend/trace_converter.ts
index be518d8..f220120 100644
--- a/ui/src/frontend/trace_converter.ts
+++ b/ui/src/frontend/trace_converter.ts
@@ -13,20 +13,17 @@
 // limitations under the License.
 
 import {download} from '../base/clipboard';
+import {defer} from '../base/deferred';
 import {ErrorDetails} from '../base/logging';
 import {utf8Decode} from '../base/string_utils';
 import {time} from '../base/time';
-import {
-  ConversionJobName,
-  ConversionJobStatus,
-} from '../common/conversion_jobs';
 import {AppImpl} from '../core/app_impl';
 import {maybeShowErrorDialog} from './error_dialog';
 import {globals} from './globals';
 
 type Args =
   | UpdateStatusArgs
-  | UpdateJobStatusArgs
+  | JobCompletedArgs
   | DownloadFileArgs
   | OpenTraceInLegacyArgs
   | ErrorArgs;
@@ -36,10 +33,8 @@
   status: string;
 }
 
-interface UpdateJobStatusArgs {
-  kind: 'updateJobStatus';
-  name: ConversionJobName;
-  status: ConversionJobStatus;
+interface JobCompletedArgs {
+  kind: 'jobCompleted';
 }
 
 interface DownloadFileArgs {
@@ -64,16 +59,18 @@
   size: number,
 ) => void;
 
-function makeWorkerAndPost(
+async function makeWorkerAndPost(
   msg: unknown,
   openTraceInLegacy?: OpenTraceInLegacyCallback,
 ) {
+  const promise = defer<void>();
+
   function handleOnMessage(msg: MessageEvent): void {
     const args: Args = msg.data;
     if (args.kind === 'updateStatus') {
       AppImpl.instance.omnibox.showStatusMessage(args.status);
-    } else if (args.kind === 'updateJobStatus') {
-      globals.setConversionJobStatus(args.name, args.status);
+    } else if (args.kind === 'jobCompleted') {
+      promise.resolve();
     } else if (args.kind === 'downloadFile') {
       download(new File([new Blob([args.buffer])], args.name));
     } else if (args.kind === 'openTraceInLegacy') {
@@ -89,18 +86,19 @@
   const worker = new Worker(globals.root + 'traceconv_bundle.js');
   worker.onmessage = handleOnMessage;
   worker.postMessage(msg);
+  return promise;
 }
 
-export function convertTraceToJsonAndDownload(trace: Blob) {
-  makeWorkerAndPost({
+export function convertTraceToJsonAndDownload(trace: Blob): Promise<void> {
+  return makeWorkerAndPost({
     kind: 'ConvertTraceAndDownload',
     trace,
     format: 'json',
   });
 }
 
-export function convertTraceToSystraceAndDownload(trace: Blob) {
-  makeWorkerAndPost({
+export function convertTraceToSystraceAndDownload(trace: Blob): Promise<void> {
+  return makeWorkerAndPost({
     kind: 'ConvertTraceAndDownload',
     trace,
     format: 'systrace',
@@ -111,8 +109,8 @@
   trace: Blob,
   openTraceInLegacy: OpenTraceInLegacyCallback,
   truncate?: 'start' | 'end',
-) {
-  makeWorkerAndPost(
+): Promise<void> {
+  return makeWorkerAndPost(
     {
       kind: 'ConvertTraceAndOpenInLegacy',
       trace,
@@ -126,8 +124,8 @@
   trace: Blob,
   pid: number,
   ts: time,
-) {
-  makeWorkerAndPost({
+): Promise<void> {
+  return makeWorkerAndPost({
     kind: 'ConvertTraceToPprof',
     trace,
     pid,
diff --git a/ui/src/traceconv/index.ts b/ui/src/traceconv/index.ts
index 9181920..cc721ba 100644
--- a/ui/src/traceconv/index.ts
+++ b/ui/src/traceconv/index.ts
@@ -20,10 +20,6 @@
   reportError,
 } from '../base/logging';
 import {time} from '../base/time';
-import {
-  ConversionJobName,
-  ConversionJobStatus,
-} from '../common/conversion_jobs';
 import traceconv from '../gen/traceconv';
 
 const selfWorker = self as {} as Worker;
@@ -44,12 +40,8 @@
   });
 }
 
-function updateJobStatus(name: ConversionJobName, status: ConversionJobStatus) {
-  selfWorker.postMessage({
-    kind: 'updateJobStatus',
-    name,
-    status,
-  });
+function notifyJobCompleted() {
+  selfWorker.postMessage({kind: 'jobCompleted'});
 }
 
 function downloadFile(buffer: Uint8Array, name: string) {
@@ -131,8 +123,6 @@
   format: Format,
   truncate?: 'start' | 'end',
 ): Promise<void> {
-  const jobName = format === 'json' ? 'convert_json' : 'convert_systrace';
-  updateJobStatus(jobName, ConversionJobStatus.InProgress);
   const outPath = '/trace.json';
   const args: string[] = [format];
   if (truncate !== undefined) {
@@ -145,7 +135,7 @@
     downloadFile(fsNodeToBuffer(fsNode), `trace.${format}`);
     module.FS.unlink(outPath);
   } finally {
-    updateJobStatus(jobName, ConversionJobStatus.NotRunning);
+    notifyJobCompleted();
   }
 }
 
@@ -168,8 +158,6 @@
   trace: Blob,
   truncate?: 'start' | 'end',
 ) {
-  const jobName = 'open_in_legacy';
-  updateJobStatus(jobName, ConversionJobStatus.InProgress);
   const outPath = '/trace.json';
   const args: string[] = ['json'];
   if (truncate !== undefined) {
@@ -185,7 +173,7 @@
     openTraceInLegacy(buffer);
     module.FS.unlink(outPath);
   } finally {
-    updateJobStatus(jobName, ConversionJobStatus.NotRunning);
+    notifyJobCompleted();
   }
 }
 
@@ -204,8 +192,6 @@
 }
 
 async function ConvertTraceToPprof(trace: Blob, pid: number, ts: time) {
-  const jobName = 'convert_pprof';
-  updateJobStatus(jobName, ConversionJobStatus.InProgress);
   const args = [
     'profile',
     `--pid`,
@@ -232,7 +218,7 @@
       downloadFile(fsNodeToBuffer(fileNode), fileName);
     }
   } finally {
-    updateJobStatus(jobName, ConversionJobStatus.NotRunning);
+    notifyJobCompleted();
   }
 }