[logging] Introduce play/pause functionality

Screencast:
https://screencast.googleplex.com/cast/NDg2MjA3MjI0OTY0NzEwNHwzNGZlZTE5NC0zYg

Bug: 109719

Change-Id: Ic4e2f19a503fa0e180a409a0868ae5b07286cb67
Reviewed-on: https://fuchsia-review.googlesource.com/c/vscode-plugins/+/731663
Kokoro: Kokoro <noreply+kokoro@google.com>
Reviewed-by: Solly Ross <sollyross@google.com>
Reviewed-by: Amy Hu <amyhu@google.com>
diff --git a/media/webview-logging.css b/media/webview-logging.css
index 16293fc..f07c533 100644
--- a/media/webview-logging.css
+++ b/media/webview-logging.css
@@ -69,6 +69,11 @@
   color: var(--vscode-list-errorForeground);
 }
 
+.log-entry[data-moniker="<VSCode>"] {
+  line-height: 75%;
+  opacity: 70%;
+}
+
 #log-action-container {
   display: flex;
   height: var(--log-action-container-height);
diff --git a/src/ffx.ts b/src/ffx.ts
index 47a89d8..81e5b3d 100644
--- a/src/ffx.ts
+++ b/src/ffx.ts
@@ -311,17 +311,23 @@
    * Runs ffx --target [device] --machine json log
    *
    * @param device the name of the target device to use on ffx log
+   * @param args additional arguments for the `ffx log` process.
    * @param onData when data is received this command will be called
    * @returns the ffx log process
    */
-  public runLog(device: string | undefined, onData: (data: Object) => void): FfxLog {
-    let args = (device) ? ['--target', device] : [];
-    args = args.concat(['--machine', 'json', 'log']);
+  public runLog(
+    device: string | undefined,
+    args: string[],
+    onData: (data: Object) => void
+  ): FfxLog {
+    let ffxArgs = (device ? ['--target', device] : []);
+    ffxArgs.push('--machine', 'json', 'log');
+    ffxArgs.push(...args);
     return new JsonStreamProcess(
-      this.runFfxStreaming(args),
+      this.runFfxStreaming(ffxArgs),
       onData,
       (data) => {
-        logger.warn(`Error [${this.path} ${args}]: ${data}`);
+        logger.warn(`Error [${this.path} ${ffxArgs}]: ${data}`);
       });
   }
 
diff --git a/src/logging/view_provider.ts b/src/logging/view_provider.ts
index a70a9a1..430049a 100644
--- a/src/logging/view_provider.ts
+++ b/src/logging/view_provider.ts
@@ -59,8 +59,15 @@
 
     webviewView.webview.html = this._getHtmlForWebview(webviewView.webview);
 
-    webviewView.webview.onDidReceiveMessage(data => {
-      // No messages expected yet coming out of the webview.
+    webviewView.webview.onDidReceiveMessage(message => {
+      switch (message.command) {
+        case 'pauseLogStreaming':
+          this.process?.stop();
+          break;
+        case 'resumeLogStreaming':
+          this.startListeningForLogs('now');
+          break;
+      }
     });
 
     this.lastVisibilitySeen = this.view.visible;
@@ -111,9 +118,10 @@
     await this.view?.webview.postMessage({ type: 'reset' });
   }
 
-  private startListeningForLogs() {
+  private startListeningForLogs(since?: string) {
     this.process?.stop();
-    this.process = this.ffx.runLog(this.currentDevice, (data: Object) => {
+    const args = since ? ['--since', since] : [];
+    this.process = this.ffx.runLog(this.currentDevice, args, (data: Object) => {
       // this was... not marked async before, but postMessage is an async operation
       // so there's not much to do about this here.  We've gotta just go async & hope.
       this.addLog(data).catch((err) => logger.error('unable to show log line', undefined, err));
diff --git a/src/test/suite/ffx.test.ts b/src/test/suite/ffx.test.ts
index 53bb938..7269e86 100644
--- a/src/test/suite/ffx.test.ts
+++ b/src/test/suite/ffx.test.ts
@@ -577,7 +577,7 @@
       let received;
       const TEST_DATA: Object = { 'foo': 'bar' };
       stubbedSpawn.spawnStubInfo.callsFake(() => stubbedSpawn.spawnEvent);
-      ffx.runLog('foo', (data) => {
+      ffx.runLog('foo', [], (data) => {
         received = data;
       });
       assert.deepStrictEqual(
@@ -592,7 +592,7 @@
       // spy on the singleton instead of the module method, since we can't
       // modify singletons and such with ESM
       let spiedWarn = sandbox.spy(logger.logger, 'warn');
-      ffx.runLog('foo', (_) => { });
+      ffx.runLog('foo', [], (_) => { });
       stubbedSpawn.spawnEvent.stderr?.emit('data', 'oh no');
       assert(spiedWarn.calledOnce);
       assert(
diff --git a/src/test/suite/logging/view_provider.test.ts b/src/test/suite/logging/view_provider.test.ts
index eaf171c..d118e9b 100644
--- a/src/test/suite/logging/view_provider.test.ts
+++ b/src/test/suite/logging/view_provider.test.ts
@@ -39,8 +39,9 @@
       let fakeProcess = sandbox.createStubInstance(JsonStreamProcess);
       ffx.runLog.returns(fakeProcess);
       let provider = new LoggingViewProvider(uri, ffx);
-      let emitter = new vscode.EventEmitter<void>();
-      let view = fakeWebviewView(sandbox, true, emitter.event);
+      let visibilityEmitter = new vscode.EventEmitter<void>();
+      let view = fakeWebviewView(
+        sandbox, true, visibilityEmitter.event, new vscode.EventEmitter<any>().event);
       provider.resolveWebviewView(view, {
         state: undefined
       }, {
@@ -49,30 +50,32 @@
       });
 
       // This should trigger the creation of a process.
-      emitter.fire();
+      visibilityEmitter.fire();
 
       (view as any).visible = false;
-      emitter.fire();
+      visibilityEmitter.fire();
       assert.ok(fakeProcess.stop.calledOnce);
     });
 
     it('spawns an ffx process when the view appears', () => {
-      let emitter = new vscode.EventEmitter<void>();
-      let view = fakeWebviewView(sandbox, true, emitter.event);
+      let visibilityEmitter = new vscode.EventEmitter<void>();
+      let view = fakeWebviewView(
+        sandbox, true, visibilityEmitter.event, new vscode.EventEmitter<any>().event);
       provider.resolveWebviewView(view, {
         state: undefined
       }, {
         isCancellationRequested: false,
         onCancellationRequested: sandbox.stub()
       });
-      emitter.fire();
+      visibilityEmitter.fire();
       assert.ok(ffx.runLog.calledOnce);
       assert.deepStrictEqual(ffx.runLog.getCall(0).args[0], undefined);
     });
 
     it('spawns an ffx process when the view is resolved', () => {
-      let emitter = new vscode.EventEmitter<void>();
-      let view = fakeWebviewView(sandbox, true, emitter.event);
+      let visibilityEmitter = new vscode.EventEmitter<void>();
+      let view = fakeWebviewView(
+        sandbox, true, visibilityEmitter.event, new vscode.EventEmitter<any>().event);
       provider.resolveWebviewView(view, {
         state: undefined
       }, {
@@ -88,11 +91,13 @@
     it('pushes a log to the webview when received', () => {
       let process = new StubbedSpawn(sandbox);
       let ffx = new Ffx('foo');
-      sandbox.stub(ffx, 'runFfxStreaming').returns(process.spawnEvent as ChildProcessWithoutNullStreams);
+      sandbox.stub(ffx, 'runFfxStreaming').returns(
+        process.spawnEvent as ChildProcessWithoutNullStreams);
       let provider = new LoggingViewProvider(uri, ffx);
 
-      let emitter = new vscode.EventEmitter<void>();
-      let view = fakeWebviewView(sandbox, true, emitter.event);
+      let visibilityEmitter = new vscode.EventEmitter<void>();
+      let view = fakeWebviewView(
+        sandbox, true, visibilityEmitter.event, new vscode.EventEmitter<any>().event);
       provider.resolveWebviewView(view, {
         state: undefined
       }, {
@@ -101,7 +106,7 @@
       });
 
       // The view should be visible now.
-      emitter.fire();
+      visibilityEmitter.fire();
 
       const data = '{"data":{"TargetLog": {}}}';
       process.spawnEvent.stdout?.emit('data', data);
@@ -117,11 +122,13 @@
     it('handles logs that contain new lines', () => {
       let process = new StubbedSpawn(sandbox);
       let ffx = new Ffx('foo');
-      sandbox.stub(ffx, 'runFfxStreaming').returns(process.spawnEvent as ChildProcessWithoutNullStreams);
+      sandbox.stub(ffx, 'runFfxStreaming').returns(
+        process.spawnEvent as ChildProcessWithoutNullStreams);
       let provider = new LoggingViewProvider(uri, ffx);
 
-      let emitter = new vscode.EventEmitter<void>();
-      let view = fakeWebviewView(sandbox, true, emitter.event);
+      let visibilityEmitter = new vscode.EventEmitter<void>();
+      let view = fakeWebviewView(
+        sandbox, true, visibilityEmitter.event, new vscode.EventEmitter<any>().event);
       provider.resolveWebviewView(view, {
         state: undefined
       }, {
@@ -130,7 +137,7 @@
       });
 
       // Creates the process
-      emitter.fire();
+      visibilityEmitter.fire();
 
       const data = `{"data":{"TargetLog": {"payload": {"message": "foo\\nbar"}}}}
       {"data": {"TargetLog": {"payload": {"message": "baz"}}}}`;
@@ -169,7 +176,8 @@
     });
 
     it('resets the view when the device changes and the view is visible', async () => {
-      let view = fakeWebviewView(sandbox, true, new vscode.EventEmitter<any>().event);
+      let view = fakeWebviewView(
+        sandbox, true, new vscode.EventEmitter<any>().event, new vscode.EventEmitter<any>().event);
       provider.resolveWebviewView(view, {
         state: undefined
       }, {
@@ -187,7 +195,8 @@
     });
 
     it('shows the view when the device changes and the view is not visible', async () => {
-      let view = fakeWebviewView(sandbox, false, new vscode.EventEmitter<any>().event);
+      let view = fakeWebviewView(
+        sandbox, false, new vscode.EventEmitter<any>().event, new vscode.EventEmitter<any>().event);
       provider.resolveWebviewView(view, {
         state: undefined
       }, {
@@ -201,7 +210,8 @@
 
   describe('on default target change', () => {
     it('changes the current device and resets the view when visible', () => {
-      let view = fakeWebviewView(sandbox, true, new vscode.EventEmitter<any>().event);
+      let view = fakeWebviewView(
+        sandbox, true, new vscode.EventEmitter<any>().event, new vscode.EventEmitter<any>().event);
       provider.resolveWebviewView(view, {
         state: undefined
       }, {
@@ -217,7 +227,8 @@
     });
 
     it('changes the current device when the view is not visible', () => {
-      let view = fakeWebviewView(sandbox, false, new vscode.EventEmitter<any>().event);
+      let view = fakeWebviewView(
+        sandbox, false, new vscode.EventEmitter<any>().event, new vscode.EventEmitter<any>().event);
       provider.resolveWebviewView(view, {
         state: undefined
       }, {
@@ -233,7 +244,8 @@
     });
 
     it('does nothing when the device is the same', async () => {
-      let view = fakeWebviewView(sandbox, false, new vscode.EventEmitter<any>().event);
+      let view = fakeWebviewView(
+        sandbox, false, new vscode.EventEmitter<any>().event, new vscode.EventEmitter<any>().event);
       provider.resolveWebviewView(view, {
         state: undefined
       }, {
@@ -249,6 +261,51 @@
       assert.ok(!webview.postMessage.calledOnce);
     });
   });
+
+  describe('when receiving messages from the webview', () => {
+    it('handles pause commands and stops the ffx log process', () => {
+      let fakeProcess = sandbox.createStubInstance(JsonStreamProcess);
+      ffx.runLog.returns(fakeProcess);
+      let provider = new LoggingViewProvider(uri, ffx);
+      let webviewMessagesEmitter = new vscode.EventEmitter<{ command: string }>();
+      let view = fakeWebviewView(
+        sandbox, true, new vscode.EventEmitter<void>().event, webviewMessagesEmitter.event);
+
+      provider.resolveWebviewView(view, {
+        state: undefined
+      }, {
+        isCancellationRequested: false,
+        onCancellationRequested: sandbox.stub()
+      });
+      assert.ok(ffx.runLog.calledOnce);
+      assert.ok(fakeProcess.stop.notCalled);
+
+      webviewMessagesEmitter.fire({ command: 'pauseLogStreaming' });
+
+      // No more calls should have happened on runLog and the process should have been stopped.
+      assert.ok(ffx.runLog.calledOnce);
+      assert.ok(fakeProcess.stop.calledOnce);
+    });
+
+    it('handles resume commands and starts a ffx log process', () => {
+      let webviewMessagesEmitter = new vscode.EventEmitter<{ command: string }>();
+      let view = fakeWebviewView(
+        sandbox, true, new vscode.EventEmitter<any>().event, webviewMessagesEmitter.event);
+
+      provider.resolveWebviewView(view, {
+        state: undefined
+      }, {
+        isCancellationRequested: false,
+        onCancellationRequested: sandbox.stub()
+      });
+      assert.ok(ffx.runLog.calledOnce);
+
+      webviewMessagesEmitter.fire({ command: 'resumeLogStreaming' });
+
+      assert.ok(ffx.runLog.calledTwice);
+      assert.deepStrictEqual(ffx.runLog.getCall(1).args[1], ['--since', 'now']);
+    });
+  });
 });
 
 /**
@@ -261,13 +318,13 @@
  */
 function fakeWebviewView(
   sandbox: SinonSandbox, visible: boolean,
-  onDidChangeVisibility: vscode.Event<any>,
+  onDidChangeVisibility: vscode.Event<void>,
+  onDidReceiveMessage: vscode.Event<any>,
 ): vscode.WebviewView {
   let webview = sandbox.createStubInstance(FakeWebview);
-  webview.onDidReceiveMessage = sandbox.stub();
+  webview.onDidReceiveMessage = onDidReceiveMessage as any;
   webview.postMessage = sandbox.stub();
   webview.asWebviewUri.returns(vscode.Uri.file('/fake/file'));
-  sandbox.spy(webview.onDidReceiveMessage);
 
   let view = <vscode.WebviewView>{ visible };
   // These exist in the WebviewView interface, but when doing
diff --git a/webviews/logging/components/log_row.ts b/webviews/logging/components/log_row.ts
index 6714da6..01c3841 100644
--- a/webviews/logging/components/log_row.ts
+++ b/webviews/logging/components/log_row.ts
@@ -25,6 +25,8 @@
     } else if (this.log.data.SymbolizedTargetLog !== undefined) {
       this.symbolized = this.log.data.SymbolizedTargetLog[1];
       this.formatRow(this.log.data.SymbolizedTargetLog[0]);
+    } else if (this.log.data.ViewerEvent !== undefined) {
+      this.formatViewerEventRow(this.log.data.ViewerEvent);
     }
   }
 
@@ -84,6 +86,7 @@
 
   /**
    * Formats row for Malformed Logs.
+   * @param malformedLog the malformed log to render as a message.
    */
   private formatMalformedRow(malformedLog: string) {
     const fields = { 'message': formatMalformedLog(malformedLog) };
@@ -92,6 +95,16 @@
   }
 
   /**
+   *  Formats row for an viewer synthesizedd log.
+   * @param message  the message to display.
+   */
+  private formatViewerEventRow(message: string) {
+    const fields = { message, moniker: constant.VSCODE_SYNTHETIC_MONIKER };
+    this.createLogRowAttributes(fields);
+    this.createLogInnerHtml(fields);
+  }
+
+  /**
    * Formats row for Target Log and Symbolized Logs.
    */
   private formatRow(log: LogData) {
diff --git a/webviews/logging/components/log_view_actions.ts b/webviews/logging/components/log_view_actions.ts
index 0782fe8..03b3f7d 100644
--- a/webviews/logging/components/log_view_actions.ts
+++ b/webviews/logging/components/log_view_actions.ts
@@ -8,8 +8,16 @@
 export const DONT_WRAP_LOGS: string = 'Don\'t wrap logs';
 export const WRAP_LOGS: string = 'Wrap logs';
 
+export const RESUME_STREAMING_LOGS: string = 'Resume streaming logs';
+export const PAUSE_STREAMING_LOGS: string = 'Pause streaming logs';
+
 const ACTION_ICON_CLASSES: string = 'codicon icons action-icon';
 
+export interface LogViewActionsParameters {
+  wrappingLogs?: boolean;
+  playActive?: boolean;
+}
+
 /**
  * Class to create action buttons that control the log list webview.
  */
@@ -47,19 +55,33 @@
   @property({ attribute: true, reflect: true, type: Boolean })
   public wrappingLogs: boolean;
 
+  @property({ attribute: true, reflect: true, type: Boolean })
+  public playActive: boolean = false;
+
   public static readonly wrapLogsChangeEvent: string = 'wrap-logs-change';
   public static readonly clearRequestedEvent: string = 'clear-requested';
+  public static readonly playPauseChangeEvent: string = 'play-pause-change';
 
-  constructor(wrappingLogs: boolean) {
+  constructor(params: LogViewActionsParameters | undefined) {
     super();
-    this.wrappingLogs = wrappingLogs;
+    this.wrappingLogs = params?.wrappingLogs ?? false;
+    this.playActive = params?.playActive ?? false;
   }
 
   render() {
     const wrapLogsTitle = this.wrappingLogs ? DONT_WRAP_LOGS : WRAP_LOGS;
+    const playPauseTitle = this.playActive ? PAUSE_STREAMING_LOGS : RESUME_STREAMING_LOGS;
+    const playPauseIcon = this.playActive ? 'debug-pause' : 'play';
 
     return html`
       <div
+        id="play-pause"
+        title="${playPauseTitle}"
+        class="${ACTION_ICON_CLASSES} codicon-${playPauseIcon}"
+        @click="${this.onPlayPauseButtonClick}"
+      ></div>
+
+      <div
         id="clear"
         title="Clear Logs"
         class="${ACTION_ICON_CLASSES} codicon-clear-all"
@@ -90,4 +112,13 @@
       }
     }));
   }
+
+  private onPlayPauseButtonClick() {
+    this.playActive = !this.playActive;
+    this.dispatchEvent(new CustomEvent(LogViewActions.playPauseChangeEvent, {
+      detail: {
+        playActive: this.playActive,
+      }
+    }));
+  }
 }
diff --git a/webviews/logging/components/view.ts b/webviews/logging/components/view.ts
index d1ef29a..3daa5f7 100644
--- a/webviews/logging/components/view.ts
+++ b/webviews/logging/components/view.ts
@@ -8,14 +8,24 @@
 import { State } from '../src/state';
 import { LogList } from './log_list';
 
-export class LoggingView {
+export const RESUMING_LOG_STREAMING_LOG = 'Resuming log streaming, press [pause icon] to pause.';
+export const PAUSING_LOG_STREAMING_LOG = 'Playback has been paused, press [play icon] to resume.';
+
+export type ExternalActionRequestEvent =
+  | { type: 'pause-log-streaming' }
+  | { type: 'resume-log-streaming' };
+
+export class LoggingView extends EventTarget {
   private logActionContainer: HTMLDivElement;
   private logControl: LogControl;
   private logList: LogList;
   private logListContainer: HTMLDivElement;
   private logViewActions: LogViewActions;
 
+  public static readonly externalActionRequestEvent: string = 'external-action-request-event';
+
   constructor(private state: State, private root: HTMLElement) {
+    super();
     this.logList = new LogList(state);
 
     this.logControl = new LogControl(this.state.currentFilterText);
@@ -25,7 +35,14 @@
       this.state.registerFilter(filter, text);
     });
 
-    this.logViewActions = new LogViewActions(this.state.shouldWrapLogs);
+    this.logViewActions = new LogViewActions({
+      wrappingLogs: this.state.shouldWrapLogs,
+      /**
+       * TODO(fxbug.dev/109719): make this behavior persisted and automatic
+       * with focus changes
+       */
+      playActive: true
+    });
     this.logViewActions.addEventListener(LogViewActions.clearRequestedEvent, (e) => {
       this.logList.reset();
     });
@@ -35,6 +52,11 @@
       this.logList.logWrapping = wrapLogs;
       this.state.shouldWrapLogs = wrapLogs;
     });
+    this.logViewActions.addEventListener(LogViewActions.playPauseChangeEvent, (e) => {
+      const event = e as CustomEvent;
+      const { playActive } = event.detail;
+      this.onPlayPauseChangeEvent(playActive);
+    });
 
     this.logActionContainer = document.createElement('div');
     this.logActionContainer.id = 'log-action-container';
@@ -65,4 +87,29 @@
     this.logList.reset();
     this.logControl.reset();
   }
+
+  /**
+   * Handles a play/pause event emitted by the log actions view.
+   * @param playActive whether or not the streaming of logs is active
+   */
+  private onPlayPauseChangeEvent(playActive: boolean) {
+    const detail: ExternalActionRequestEvent =
+      playActive
+        ? { type: 'resume-log-streaming' }
+        : { type: 'pause-log-streaming' };
+    this.dispatchEvent(new CustomEvent(LoggingView.externalActionRequestEvent, {
+      detail,
+    }));
+
+    this.logList.addLog({
+      data: {
+        // eslint-disable-next-line @typescript-eslint/naming-convention
+        ViewerEvent: playActive
+          ? RESUMING_LOG_STREAMING_LOG
+          : PAUSING_LOG_STREAMING_LOG
+      },
+      timestamp: Date.now(),
+      version: 0
+    });
+  }
 }
diff --git a/webviews/logging/index.ts b/webviews/logging/index.ts
index dfe859e..e379cfc 100644
--- a/webviews/logging/index.ts
+++ b/webviews/logging/index.ts
@@ -2,25 +2,51 @@
 // Use of this source code is governed by a BSD-style license that can be
 // found in the LICENSE file.
 
-import { LoggingView } from './components/view';
-import { State } from './src/state';
+import { WebviewApi } from 'vscode-webview';
+import { ExternalActionRequestEvent, LoggingView } from './components/view';
+import { PersistedState, State } from './src/state';
 
 //@ts-check
 
 // This script will be run within the webview itself
 // It cannot access the main VS Code APIs directly.
 (function () {
-  const view = new LoggingView(new State(acquireVsCodeApi()), document.body);
+  const vscode = acquireVsCodeApi() as WebviewApi<PersistedState>;
+  const view = new LoggingView(new State(vscode), document.body);
+
+  // TODO(fxbug.dev/109719): move this to the persisted state.
+  let playing = true;
 
   // Handle messages sent from the extension to the webview
   window.addEventListener('message', event => {
     const message = event.data;
     switch (message.type) {
       case 'addLog':
-        view.addLog(message.log);
+        if (playing) {
+          view.addLog(message.log);
+        }
         break;
       case 'reset':
         view.reset();
     }
   });
+
+  // Forward webview messages to the extension.
+  view.addEventListener(LoggingView.externalActionRequestEvent, (e) => {
+    const event = (e as CustomEvent).detail as ExternalActionRequestEvent;
+    switch (event.type) {
+      case 'pause-log-streaming':
+        playing = false;
+        vscode.postMessage({
+          command: 'pauseLogStreaming'
+        });
+        break;
+      case 'resume-log-streaming':
+        playing = true;
+        vscode.postMessage({
+          command: 'resumeLogStreaming'
+        });
+        break;
+    }
+  });
 }());
diff --git a/webviews/logging/src/constants.ts b/webviews/logging/src/constants.ts
index c352e72..234a846 100644
--- a/webviews/logging/src/constants.ts
+++ b/webviews/logging/src/constants.ts
@@ -8,6 +8,7 @@
 
 export const MONIKER_FILTER_LABEL = 'Moniker:';
 export const FFX_MONIKER = '<ffx>';
+export const VSCODE_SYNTHETIC_MONIKER = '<VSCode>';
 
 export const SEARCH_PLACEHOLDER = 'Filter logs...';
 
diff --git a/webviews/logging/src/log_data.ts b/webviews/logging/src/log_data.ts
index 8f9e16c..aede082 100644
--- a/webviews/logging/src/log_data.ts
+++ b/webviews/logging/src/log_data.ts
@@ -14,6 +14,7 @@
   FfxEvent?: FfxEventData,
   MalformedTargetLog?: string;
   SymbolizedTargetLog?: [LogData, string];
+  ViewerEvent?: string;
 }
 
 export type FfxEventData = 'TargetDisconnected' | 'LoggingStarted';
diff --git a/webviews/logging/test/log_row.test.ts b/webviews/logging/test/log_row.test.ts
index fc56f75..efad7f7 100644
--- a/webviews/logging/test/log_row.test.ts
+++ b/webviews/logging/test/log_row.test.ts
@@ -185,5 +185,21 @@
       msgEl.innerText.should.equal(msg);
     });
 
+    it('formats viewer synthesized messages', () => {
+      const msg = 'Logs are cool';
+      const data = {
+        data: {
+          // eslint-disable-next-line @typescript-eslint/naming-convention
+          ViewerEvent: msg,
+        },
+        timestamp: 0,
+        version: 0
+      };
+      const logRow = new LogRow(data, false).element;
+      const monikerEl = logRow.children[3] as HTMLElement;
+      const msgEl = logRow.children[6] as HTMLElement;
+      monikerEl.innerText.should.equal(constant.VSCODE_SYNTHETIC_MONIKER);
+      msgEl.innerText.should.equal(msg);
+    });
   });
 });
diff --git a/webviews/logging/test/log_view_actions.test.ts b/webviews/logging/test/log_view_actions.test.ts
index 2beeb9b..ecf9ecb 100644
--- a/webviews/logging/test/log_view_actions.test.ts
+++ b/webviews/logging/test/log_view_actions.test.ts
@@ -4,7 +4,7 @@
 
 import chai from 'chai'; // not esm
 import chaiDom from 'chai-dom'; // not esm
-import { DONT_WRAP_LOGS, LogViewActions, WRAP_LOGS } from '../components/log_view_actions';
+import { DONT_WRAP_LOGS, LogViewActions, PAUSE_STREAMING_LOGS, RESUME_STREAMING_LOGS, WRAP_LOGS } from '../components/log_view_actions';
 
 before(function () {
   chai.should();
@@ -26,13 +26,14 @@
   describe('#constructor', () => {
     it('crates log view actions buttons', async () => {
       await logViewActions.updateComplete;
-      logViewActions.shadowRoot!.children.length.should.equal(2);
-      logViewActions.shadowRoot!.children[0].id.should.equal('clear');
-      logViewActions.shadowRoot!.children[1].id.should.equal('wrap-logs');
+      logViewActions.shadowRoot!.children.length.should.equal(3);
+      logViewActions.shadowRoot!.children[0].id.should.equal('play-pause');
+      logViewActions.shadowRoot!.children[1].id.should.equal('clear');
+      logViewActions.shadowRoot!.children[2].id.should.equal('wrap-logs');
     });
 
-    it('takes state into account when creating the log wrap btn', async () => {
-      let logViewActions = new LogViewActions(false);
+    it('correctly initializes the log-wrap btn', async () => {
+      let logViewActions = new LogViewActions({ wrappingLogs: false });
       document.body.appendChild(logViewActions);
       await logViewActions.updateComplete;
       let wrapLogsButton = logViewActions.shadowRoot!.querySelector('#wrap-logs') as HTMLDivElement;
@@ -40,13 +41,32 @@
       wrapLogsButton.title.should.equal(WRAP_LOGS);
       logViewActions.remove();
 
-      logViewActions = new LogViewActions(true);
+      logViewActions = new LogViewActions({ wrappingLogs: true });
       document.body.appendChild(logViewActions);
       await logViewActions.updateComplete;
       wrapLogsButton = logViewActions.shadowRoot!.querySelector('#wrap-logs') as HTMLDivElement;
       logViewActions.wrappingLogs.should.be.true;
       wrapLogsButton.title.should.equal(DONT_WRAP_LOGS);
     });
+
+    it('correctly initializes the play-pause btn', async () => {
+      let logViewActions = new LogViewActions({ playActive: true });
+      document.body.appendChild(logViewActions);
+      await logViewActions.updateComplete;
+      let playPauseButton = logViewActions.shadowRoot!.querySelector('#play-pause') as HTMLDivElement;
+      logViewActions.playActive.should.be.true;
+      playPauseButton.title.should.equal(PAUSE_STREAMING_LOGS);
+      playPauseButton.classList.contains('codicon-debug-pause').should.be.true;
+      logViewActions.remove();
+
+      logViewActions = new LogViewActions({ playActive: false });
+      document.body.appendChild(logViewActions);
+      await logViewActions.updateComplete;
+      playPauseButton = logViewActions.shadowRoot!.querySelector('#play-pause') as HTMLDivElement;
+      logViewActions.playActive.should.be.false;
+      playPauseButton.title.should.equal(RESUME_STREAMING_LOGS);
+      playPauseButton.classList.contains('codicon-play').should.be.true;
+    });
   });
 
   describe('on clear button press', () => {
@@ -99,4 +119,44 @@
       wrapLogsButton.title.should.equal(WRAP_LOGS);
     });
   });
+
+  describe('on play/pause button press', () => {
+    it('emits a play-pause-change event and updates itself', async () => {
+      await logViewActions.updateComplete;
+      const playPauseButton = logViewActions
+        .shadowRoot!.querySelector('#play-pause') as HTMLDivElement;
+      let promise = new Promise<{ playActive: boolean }>((resolve) => {
+        logViewActions.addEventListener(LogViewActions.playPauseChangeEvent, (e) => {
+          const event = e as CustomEvent;
+          resolve(event.detail);
+        });
+      });
+      playPauseButton.click();
+
+      let payload = await promise;
+      payload.playActive.should.be.true;
+      await logViewActions.updateComplete;
+      logViewActions.playActive.should.be.true;
+      playPauseButton.title.should.equal(PAUSE_STREAMING_LOGS);
+      playPauseButton.classList.contains('codicon-debug-pause').should.be.true;
+      playPauseButton.classList.contains('codicon-play').should.be.false;
+
+      // Clicking again should toggle the state.
+
+      promise = new Promise<{ playActive: boolean }>((resolve) => {
+        logViewActions.addEventListener(LogViewActions.playPauseChangeEvent, (e) => {
+          const event = e as CustomEvent;
+          resolve(event.detail);
+        });
+      });
+      playPauseButton.click();
+
+      payload = await promise;
+      payload.playActive.should.be.false;
+      await logViewActions.updateComplete;
+      playPauseButton.title.should.equal(RESUME_STREAMING_LOGS);
+      playPauseButton.classList.contains('codicon-play').should.be.true;
+      playPauseButton.classList.contains('codicon-pause').should.be.false;
+    });
+  });
 });
diff --git a/webviews/logging/test/view.test.ts b/webviews/logging/test/view.test.ts
index 37b5b9d..e86cb8c 100644
--- a/webviews/logging/test/view.test.ts
+++ b/webviews/logging/test/view.test.ts
@@ -4,7 +4,7 @@
 
 import { CURRENT_VERSION, State } from '../src/state';
 import { FakeWebviewAPi, logDataForTest } from './util';
-import { LoggingView } from '../components/view';
+import { ExternalActionRequestEvent, LoggingView, PAUSING_LOG_STREAMING_LOG, RESUMING_LOG_STREAMING_LOG } from '../components/view';
 import * as constant from '../src/constants';
 import chai from 'chai';
 import chaiDom from 'chai-dom'; // not esm
@@ -148,5 +148,52 @@
       state.shouldWrapLogs.should.be.true;
       logList.hasAttribute(WRAP_LOG_TEXT_ATTR).should.be.true;
     });
+
+    it('renders a synthetic log about pause/play and emits events', async () => {
+      const view = new LoggingView(state, root);
+      const logList = root.querySelector('#log-list') as HTMLElement;
+
+      let logViewActions = root.getElementsByTagName('log-view-actions')[0] as LogViewActions;
+      await logViewActions.updateComplete;
+      let playPauseButton =
+        logViewActions.shadowRoot!.querySelector('#play-pause') as HTMLDivElement;
+
+      let promise = new Promise<ExternalActionRequestEvent>((resolve) => {
+        view.addEventListener(LoggingView.externalActionRequestEvent, (e) => {
+          const event = e as CustomEvent;
+          resolve(event.detail);
+        });
+      });
+
+      playPauseButton.click();
+      let event = await promise;
+      await logViewActions.updateComplete;
+
+      event.should.deep.equal({ type: 'pause-log-streaming' });
+      let logLine = logList.children[1] as HTMLElement;
+      (logLine.querySelector('#message')! as HTMLTableCellElement)
+        .innerText.should.equal(PAUSING_LOG_STREAMING_LOG);
+      (logLine.querySelector('#moniker')! as HTMLTableCellElement)
+        .innerText.should.equal(constant.VSCODE_SYNTHETIC_MONIKER);
+
+      // Clicking the button again should show the "Play" state.
+      promise = new Promise<ExternalActionRequestEvent>((resolve) => {
+        view.addEventListener(LoggingView.externalActionRequestEvent, (e) => {
+          const event = e as CustomEvent;
+          resolve(event.detail);
+        });
+      });
+
+      playPauseButton.click();
+      event = await promise;
+      await logViewActions.updateComplete;
+
+      event.should.deep.equal({ type: 'resume-log-streaming' });
+      logLine = logList.children[2] as HTMLElement;
+      (logLine.querySelector('#message')! as HTMLTableCellElement)
+        .innerText.should.equal(RESUMING_LOG_STREAMING_LOG);
+      (logLine.querySelector('#moniker')! as HTMLTableCellElement)
+        .innerText.should.equal(constant.VSCODE_SYNTHETIC_MONIKER);
+    });
   });
 });