Make string timestamps interactive.

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

Changes made in:
- transitions send time and dispatch time (dispatch time added to table as this is what it used to set timestamps of transitions).
- protolog log timestamp
- transactions log timestamp

Fixes: 301920476
Test: npm run test:unit:ci

Change-Id: Ie85ed349758fa8ede6d3d34006f9766ca0f4b4ad
diff --git a/tools/winscope/src/app/mediator.ts b/tools/winscope/src/app/mediator.ts
index 3796c62..72a206e 100644
--- a/tools/winscope/src/app/mediator.ts
+++ b/tools/winscope/src/app/mediator.ts
@@ -149,6 +149,9 @@
     });
 
     await event.visit(WinscopeEventType.TRACE_POSITION_UPDATE, async (event) => {
+      if (event.updateTimeline) {
+        this.timelineData.setPosition(event.position);
+      }
       await this.propagateTracePosition(event.position, false);
     });
 
diff --git a/tools/winscope/src/app/mediator_test.ts b/tools/winscope/src/app/mediator_test.ts
index 58a59bf..be21df4 100644
--- a/tools/winscope/src/app/mediator_test.ts
+++ b/tools/winscope/src/app/mediator_test.ts
@@ -14,6 +14,7 @@
  * limitations under the License.
  */
 
+import {assertDefined} from 'common/assert_utils';
 import {FunctionUtils} from 'common/function_utils';
 import {NO_TIMEZONE_OFFSET_FACTORY, TimestampFactory} from 'common/timestamp_factory';
 import {ProgressListener} from 'messaging/progress_listener';
@@ -245,6 +246,26 @@
     );
   });
 
+  it('propagates trace position update and updates timeline data', async () => {
+    await loadFiles();
+    await loadTraceView();
+
+    // notify position
+    resetSpyCalls();
+    const finalTimestampNs = timelineData.getFullTimeRange().to.getValueNs();
+    const timestamp = NO_TIMEZONE_OFFSET_FACTORY.makeRealTimestamp(finalTimestampNs);
+    const position = TracePosition.fromTimestamp(timestamp);
+
+    await mediator.onWinscopeEvent(new TracePositionUpdate(position, true));
+    checkTracePositionUpdateEvents(
+      [viewerStub0, viewerOverlay, timelineComponent, crossToolProtocol],
+      position
+    );
+    expect(assertDefined(timelineData.getCurrentPosition()).timestamp.getValueNs()).toEqual(
+      finalTimestampNs
+    );
+  });
+
   it("initializes viewers' trace position also when loaded traces have no valid timestamps", async () => {
     const dumpFile = await UnitTestUtils.getFixtureFile('traces/dump_WindowManager.pb');
     await mediator.onWinscopeEvent(new AppFilesUploaded([dumpFile]));
diff --git a/tools/winscope/src/messaging/winscope_event.ts b/tools/winscope/src/messaging/winscope_event.ts
index 86a52a6..2284048 100644
--- a/tools/winscope/src/messaging/winscope_event.ts
+++ b/tools/winscope/src/messaging/winscope_event.ts
@@ -151,20 +151,22 @@
 export class TracePositionUpdate extends WinscopeEvent {
   override readonly type = WinscopeEventType.TRACE_POSITION_UPDATE;
   readonly position: TracePosition;
+  readonly updateTimeline: boolean;
 
-  constructor(position: TracePosition) {
+  constructor(position: TracePosition, updateTimeline = false) {
     super();
     this.position = position;
+    this.updateTimeline = updateTimeline;
   }
 
-  static fromTimestamp(timestamp: Timestamp): TracePositionUpdate {
+  static fromTimestamp(timestamp: Timestamp, updateTimeline = false): TracePositionUpdate {
     const position = TracePosition.fromTimestamp(timestamp);
-    return new TracePositionUpdate(position);
+    return new TracePositionUpdate(position, updateTimeline);
   }
 
-  static fromTraceEntry(entry: TraceEntry<object>): TracePositionUpdate {
+  static fromTraceEntry(entry: TraceEntry<object>, updateTimeline = false): TracePositionUpdate {
     const position = TracePosition.fromTraceEntry(entry);
-    return new TracePositionUpdate(position);
+    return new TracePositionUpdate(position, updateTimeline);
   }
 }
 
diff --git a/tools/winscope/src/trace/transition.ts b/tools/winscope/src/trace/transition.ts
index f35f552..1e2ebec 100644
--- a/tools/winscope/src/trace/transition.ts
+++ b/tools/winscope/src/trace/transition.ts
@@ -19,8 +19,8 @@
 export interface Transition {
   id: number;
   type: string;
-  sendTime?: string;
-  finishTime?: string;
+  sendTime?: PropertyTreeNode;
+  dispatchTime?: PropertyTreeNode;
   duration?: string;
   merged: boolean;
   aborted: boolean;
diff --git a/tools/winscope/src/viewers/components/ime_additional_properties_component.ts b/tools/winscope/src/viewers/components/ime_additional_properties_component.ts
index 0fc78e0..1eafa7e 100644
--- a/tools/winscope/src/viewers/components/ime_additional_properties_component.ts
+++ b/tools/winscope/src/viewers/components/ime_additional_properties_component.ts
@@ -22,6 +22,7 @@
 import {ImeAdditionalProperties} from 'viewers/common/ime_additional_properties';
 import {ImeContainerProperties, InputMethodSurfaceProperties} from 'viewers/common/ime_utils';
 import {ViewerEvents} from 'viewers/common/viewer_events';
+import {selectedElementStyle} from './styles/selected_element.styles';
 
 @Component({
   selector: 'ime-additional-properties',
@@ -320,10 +321,10 @@
       }
 
       .selected {
-        background-color: #87acec;
         color: black;
       }
     `,
+    selectedElementStyle,
   ],
 })
 export class ImeAdditionalPropertiesComponent {
diff --git a/tools/winscope/src/viewers/components/styles/current_element.styles.ts b/tools/winscope/src/viewers/components/styles/current_element.styles.ts
new file mode 100644
index 0000000..ac96b93
--- /dev/null
+++ b/tools/winscope/src/viewers/components/styles/current_element.styles.ts
@@ -0,0 +1,22 @@
+/*
+ * Copyright (C) 2024 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 const currentElementStyle = `
+    .current {
+        color: white;
+        background-color: #365179;
+    }
+`;
diff --git a/tools/winscope/src/viewers/components/styles/node.styles.ts b/tools/winscope/src/viewers/components/styles/node.styles.ts
index 0777e9c..c9bd563 100644
--- a/tools/winscope/src/viewers/components/styles/node.styles.ts
+++ b/tools/winscope/src/viewers/components/styles/node.styles.ts
@@ -13,7 +13,11 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-export const nodeStyles = `
+
+import {selectedElementStyle} from './selected_element.styles';
+
+export const nodeStyles =
+  `
     .node {
         position: relative;
         display: inline-flex;
@@ -52,11 +56,7 @@
         padding: 3px;
         color: white;
     }
-
-    .selected {
-        background-color: #87ACEC;
-    }
-`;
+` + selectedElementStyle;
 
 // FIXME: child-hover selector is not working.
 export const treeNodeDataViewStyles = `
diff --git a/tools/winscope/src/viewers/components/styles/selected_element.styles.ts b/tools/winscope/src/viewers/components/styles/selected_element.styles.ts
new file mode 100644
index 0000000..9b2d70e
--- /dev/null
+++ b/tools/winscope/src/viewers/components/styles/selected_element.styles.ts
@@ -0,0 +1,21 @@
+/*
+ * Copyright (C) 2024 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 const selectedElementStyle = `
+    .selected {
+        background-color: #87ACEC;
+    }
+`;
diff --git a/tools/winscope/src/viewers/components/styles/timestamp_button.styles.ts b/tools/winscope/src/viewers/components/styles/timestamp_button.styles.ts
new file mode 100644
index 0000000..d3694c2
--- /dev/null
+++ b/tools/winscope/src/viewers/components/styles/timestamp_button.styles.ts
@@ -0,0 +1,24 @@
+/*
+ * Copyright (C) 2024 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 const timeButtonStyle = `
+    .time button {
+        padding: 0px;
+        line-height: normal;
+        text-align: left;
+        white-space: normal;
+    }
+`;
diff --git a/tools/winscope/src/viewers/viewer_protolog/events.ts b/tools/winscope/src/viewers/viewer_protolog/events.ts
index 4cbe3a7..8f3f8b4 100644
--- a/tools/winscope/src/viewers/viewer_protolog/events.ts
+++ b/tools/winscope/src/viewers/viewer_protolog/events.ts
@@ -18,6 +18,7 @@
   static TagsFilterChanged = 'ViewerProtoLogEvent_TagsFilterChanged';
   static SourceFilesFilterChanged = 'ViewerProtoLogEvent_SourceFilesFilterChanged';
   static SearchStringFilterChanged = 'ViewerProtoLogEvent_SearchStringFilterChanged';
+  static TimestampSelected = 'ViewerProtoLogEvent_TimestampSelected';
 }
 
 export {Events};
diff --git a/tools/winscope/src/viewers/viewer_protolog/presenter.ts b/tools/winscope/src/viewers/viewer_protolog/presenter.ts
index 8f3f6b7..f6ee918 100644
--- a/tools/winscope/src/viewers/viewer_protolog/presenter.ts
+++ b/tools/winscope/src/viewers/viewer_protolog/presenter.ts
@@ -123,7 +123,7 @@
       return {
         originalIndex: index,
         text: assertDefined(messageNode.getChildByName('text')).formattedValue(),
-        time: assertDefined(messageNode.getChildByName('timestamp')).formattedValue(),
+        time: assertDefined(messageNode.getChildByName('timestamp')),
         tag: assertDefined(messageNode.getChildByName('tag')).formattedValue(),
         level: assertDefined(messageNode.getChildByName('level')).formattedValue(),
         at: assertDefined(messageNode.getChildByName('at')).formattedValue(),
diff --git a/tools/winscope/src/viewers/viewer_protolog/presenter_test.ts b/tools/winscope/src/viewers/viewer_protolog/presenter_test.ts
index 4f583e8..b422a42 100644
--- a/tools/winscope/src/viewers/viewer_protolog/presenter_test.ts
+++ b/tools/winscope/src/viewers/viewer_protolog/presenter_test.ts
@@ -45,33 +45,6 @@
     const elapsedTime20 = NO_TIMEZONE_OFFSET_FACTORY.makeElapsedTimestamp(20n);
     const elapsedTime30 = NO_TIMEZONE_OFFSET_FACTORY.makeElapsedTimestamp(30n);
 
-    inputMessages = [
-      {
-        originalIndex: 0,
-        text: 'text0',
-        time: '10ns',
-        tag: 'tag0',
-        level: 'level0',
-        at: 'sourcefile0',
-      },
-      {
-        originalIndex: 1,
-        text: 'text1',
-        time: '20ns',
-        tag: 'tag1',
-        level: 'level1',
-        at: 'sourcefile1',
-      },
-      {
-        originalIndex: 2,
-        text: 'text2',
-        time: '30ns',
-        tag: 'tag2',
-        level: 'level2',
-        at: 'sourcefile2',
-      },
-    ];
-
     const entries = [
       new PropertyTreeBuilder()
         .setRootId('ProtologTrace')
@@ -110,6 +83,33 @@
         .build(),
     ];
 
+    inputMessages = [
+      {
+        originalIndex: 0,
+        text: 'text0',
+        time: assertDefined(entries[0].getChildByName('timestamp')),
+        tag: 'tag0',
+        level: 'level0',
+        at: 'sourcefile0',
+      },
+      {
+        originalIndex: 1,
+        text: 'text1',
+        time: assertDefined(entries[1].getChildByName('timestamp')),
+        tag: 'tag1',
+        level: 'level1',
+        at: 'sourcefile1',
+      },
+      {
+        originalIndex: 2,
+        text: 'text2',
+        time: assertDefined(entries[2].getChildByName('timestamp')),
+        tag: 'tag2',
+        level: 'level2',
+        at: 'sourcefile2',
+      },
+    ];
+
     trace = new TraceBuilder<PropertyTreeNode>()
       .setEntries(entries)
       .setTimestamps([time10, time11, time12])
diff --git a/tools/winscope/src/viewers/viewer_protolog/scroll_strategy/protolog_scroll_strategy.ts b/tools/winscope/src/viewers/viewer_protolog/scroll_strategy/protolog_scroll_strategy.ts
index fa91c3f..d4afe7d 100644
--- a/tools/winscope/src/viewers/viewer_protolog/scroll_strategy/protolog_scroll_strategy.ts
+++ b/tools/winscope/src/viewers/viewer_protolog/scroll_strategy/protolog_scroll_strategy.ts
@@ -25,7 +25,10 @@
 
   protected override predictScrollItemHeight(message: UiDataMessage): number {
     const textHeight = this.subItemHeight(message.text, this.textCharsPerRow);
-    const timestampHeight = this.subItemHeight(message.time, this.timestampCharsPerRow);
+    const timestampHeight = this.subItemHeight(
+      message.time.formattedValue(),
+      this.timestampCharsPerRow
+    );
     const sourceFileHeight = this.subItemHeight(message.at, this.sourceFileCharsPerRow);
     return Math.max(textHeight, timestampHeight, sourceFileHeight);
   }
diff --git a/tools/winscope/src/viewers/viewer_protolog/ui_data.ts b/tools/winscope/src/viewers/viewer_protolog/ui_data.ts
index 0977b62..2032fc2 100644
--- a/tools/winscope/src/viewers/viewer_protolog/ui_data.ts
+++ b/tools/winscope/src/viewers/viewer_protolog/ui_data.ts
@@ -14,10 +14,12 @@
  * limitations under the License.
  */
 
+import {PropertyTreeNode} from 'trace/tree_node/property_tree_node';
+
 export interface UiDataMessage {
   readonly originalIndex: number;
   readonly text: string;
-  readonly time: string;
+  readonly time: PropertyTreeNode;
   readonly tag: string;
   readonly level: string;
   readonly at: string;
diff --git a/tools/winscope/src/viewers/viewer_protolog/viewer_protolog.ts b/tools/winscope/src/viewers/viewer_protolog/viewer_protolog.ts
index 882129e..925a8bd 100644
--- a/tools/winscope/src/viewers/viewer_protolog/viewer_protolog.ts
+++ b/tools/winscope/src/viewers/viewer_protolog/viewer_protolog.ts
@@ -14,9 +14,13 @@
  * limitations under the License.
  */
 
-import {WinscopeEvent} from 'messaging/winscope_event';
+import {FunctionUtils} from 'common/function_utils';
+import {Timestamp} from 'common/time';
+import {TracePositionUpdate, WinscopeEvent} from 'messaging/winscope_event';
+import {EmitEvent} from 'messaging/winscope_event_emitter';
 import {Traces} from 'trace/traces';
 import {TraceType} from 'trace/trace_type';
+import {PropertyTreeNode} from 'trace/tree_node/property_tree_node';
 import {View, Viewer, ViewType} from 'viewers/viewer';
 import {Events} from './events';
 import {Presenter} from './presenter';
@@ -28,6 +32,7 @@
   private readonly htmlElement: HTMLElement;
   private readonly presenter: Presenter;
   private readonly view: View;
+  private emitAppEvent: EmitEvent = FunctionUtils.DO_NOTHING_ASYNC;
 
   constructor(traces: Traces) {
     this.htmlElement = document.createElement('viewer-protolog');
@@ -48,6 +53,9 @@
     this.htmlElement.addEventListener(Events.SearchStringFilterChanged, (event) => {
       this.presenter.onSearchStringFilterChanged((event as CustomEvent).detail);
     });
+    this.htmlElement.addEventListener(Events.TimestampSelected, (event) => {
+      this.propagateTimestamp((event as CustomEvent).detail);
+    });
 
     this.view = new View(
       ViewType.TAB,
@@ -62,8 +70,13 @@
     await this.presenter.onAppEvent(event);
   }
 
-  setEmitEvent() {
-    // do nothing
+  setEmitEvent(callback: EmitEvent) {
+    this.emitAppEvent = callback;
+  }
+
+  async propagateTimestamp(timestampNode: PropertyTreeNode) {
+    const timestamp: Timestamp = timestampNode.getValue();
+    await this.emitAppEvent(TracePositionUpdate.fromTimestamp(timestamp, true));
   }
 
   getViews(): View[] {
diff --git a/tools/winscope/src/viewers/viewer_protolog/viewer_protolog_component.ts b/tools/winscope/src/viewers/viewer_protolog/viewer_protolog_component.ts
index 6c76d4d..3acbca8 100644
--- a/tools/winscope/src/viewers/viewer_protolog/viewer_protolog_component.ts
+++ b/tools/winscope/src/viewers/viewer_protolog/viewer_protolog_component.ts
@@ -16,6 +16,9 @@
 import {CdkVirtualScrollViewport} from '@angular/cdk/scrolling';
 import {Component, ElementRef, Inject, Input, ViewChild} from '@angular/core';
 import {MatSelectChange} from '@angular/material/select';
+import {PropertyTreeNode} from 'trace/tree_node/property_tree_node';
+import {currentElementStyle} from 'viewers/components/styles/current_element.styles';
+import {timeButtonStyle} from 'viewers/components/styles/timestamp_button.styles';
 import {Events} from './events';
 import {UiData} from './ui_data';
 
@@ -76,9 +79,14 @@
           *cdkVirtualFor="let message of uiData.messages; let i = index"
           class="message"
           [attr.item-id]="i"
-          [class.current-message]="isCurrentMessage(i)">
+          [class.current]="isCurrentMessage(i)">
           <div class="time">
-            <span class="mat-body-1">{{ message.time }}</span>
+            <button
+              mat-button
+              [color]="isCurrentMessage(i) ? 'secondary' : 'primary'"
+              (click)="onTimestampClicked(message.time)">
+              {{ message.time.formattedValue() }}
+            </button>
           </div>
           <div class="log-level">
             <span class="mat-body-1">{{ message.level }}</span>
@@ -122,11 +130,6 @@
         overflow-wrap: anywhere;
       }
 
-      .message.current-message {
-        background-color: #365179;
-        color: white;
-      }
-
       .time {
         flex: 2;
       }
@@ -175,12 +178,15 @@
         font-size: 12px;
       }
     `,
+    currentElementStyle,
+    timeButtonStyle,
   ],
 })
 export class ViewerProtologComponent {
   uiData: UiData = UiData.EMPTY;
 
   private searchString = '';
+  private lastClicked = '';
 
   @ViewChild(CdkVirtualScrollViewport) scrollComponent?: CdkVirtualScrollViewport;
 
@@ -189,7 +195,12 @@
   @Input()
   set inputData(data: UiData) {
     this.uiData = data;
-    if (this.uiData.currentMessageIndex !== undefined && this.scrollComponent) {
+    if (
+      this.uiData.currentMessageIndex !== undefined &&
+      this.scrollComponent &&
+      this.lastClicked !==
+        this.uiData.messages[this.uiData.currentMessageIndex].time.formattedValue()
+    ) {
       this.scrollComponent.scrollToIndex(this.uiData.currentMessageIndex);
     }
   }
@@ -216,6 +227,11 @@
     }
   }
 
+  onTimestampClicked(timestamp: PropertyTreeNode) {
+    this.lastClicked = timestamp.formattedValue();
+    this.emitEvent(Events.TimestampSelected, timestamp);
+  }
+
   isCurrentMessage(index: number): boolean {
     return index === this.uiData.currentMessageIndex;
   }
diff --git a/tools/winscope/src/viewers/viewer_protolog/viewer_protolog_component_test.ts b/tools/winscope/src/viewers/viewer_protolog/viewer_protolog_component_test.ts
index 297b86b..1f8ae22 100644
--- a/tools/winscope/src/viewers/viewer_protolog/viewer_protolog_component_test.ts
+++ b/tools/winscope/src/viewers/viewer_protolog/viewer_protolog_component_test.ts
@@ -22,6 +22,9 @@
 import {MatSelectModule} from '@angular/material/select';
 import {BrowserAnimationsModule} from '@angular/platform-browser/animations';
 import {assertDefined} from 'common/assert_utils';
+import {NO_TIMEZONE_OFFSET_FACTORY} from 'common/timestamp_factory';
+import {PropertyTreeBuilder} from 'test/unit/property_tree_builder';
+import {TIMESTAMP_FORMATTER} from 'trace/tree_node/formatters';
 import {executeScrollComponentTests} from 'viewers/common/scroll_component_test_utils';
 import {Events} from './events';
 import {ProtologScrollDirective} from './scroll_strategy/protolog_scroll_directive';
@@ -96,6 +99,21 @@
       goToCurrentTimeButton.click();
       expect(spy).toHaveBeenCalledWith(150);
     });
+
+    it('propagates timestamp on click', () => {
+      component.inputData = makeUiData();
+      fixture.detectChanges();
+      let timestamp = '';
+      htmlElement.addEventListener(Events.TimestampSelected, (event) => {
+        timestamp = (event as CustomEvent).detail.formattedValue();
+      });
+      const logTimestampButton = assertDefined(
+        htmlElement.querySelector('.time button')
+      ) as HTMLButtonElement;
+      logTimestampButton.click();
+
+      expect(timestamp).toEqual('10ns');
+    });
   });
 
   describe('Scroll component', () => {
@@ -123,6 +141,13 @@
     const allTags = ['WindowManager', 'INVALID'];
     const allSourceFiles = ['test_source_file.java', 'other_test_source_file.java'];
 
+    const time = new PropertyTreeBuilder()
+      .setRootId('ProtologMessage')
+      .setName('timestamp')
+      .setValue(NO_TIMEZONE_OFFSET_FACTORY.makeElapsedTimestamp(10n))
+      .setFormatter(TIMESTAMP_FORMATTER)
+      .build();
+
     const messages = [];
     const shortMessage = 'test information about message';
     const longMessage = shortMessage.repeat(10) + 'keep';
@@ -130,7 +155,7 @@
       const uiDataMessage: UiDataMessage = {
         originalIndex: i,
         text: i % 2 === 0 ? shortMessage : longMessage,
-        time: '2022-11-21T18:05:09.777144978',
+        time,
         tag: i % 2 === 0 ? allTags[0] : allTags[1],
         level: i % 2 === 0 ? allLogLevels[0] : allLogLevels[1],
         at: i % 2 === 0 ? allSourceFiles[0] : allSourceFiles[1],
diff --git a/tools/winscope/src/viewers/viewer_transactions/events.ts b/tools/winscope/src/viewers/viewer_transactions/events.ts
index 7af5bab..8a1302d 100644
--- a/tools/winscope/src/viewers/viewer_transactions/events.ts
+++ b/tools/winscope/src/viewers/viewer_transactions/events.ts
@@ -23,6 +23,7 @@
   static WhatFilterChanged = 'ViewerTransactionsEvent_WhatFilterChanged';
   static EntryClicked = 'ViewerTransactionsEvent_EntryClicked';
   static TransactionIdFilterChanged = 'ViewerTransactionsEvent_TransactionIdFilterChanged';
+  static TimestampSelected = 'ViewerTransactionsEvent_TimestampSelected';
 }
 
 export {Events};
diff --git a/tools/winscope/src/viewers/viewer_transactions/presenter.ts b/tools/winscope/src/viewers/viewer_transactions/presenter.ts
index d8b39c8..1bb09d8 100644
--- a/tools/winscope/src/viewers/viewer_transactions/presenter.ts
+++ b/tools/winscope/src/viewers/viewer_transactions/presenter.ts
@@ -17,13 +17,14 @@
 import {ArrayUtils} from 'common/array_utils';
 import {assertDefined} from 'common/assert_utils';
 import {PersistentStoreProxy} from 'common/persistent_store_proxy';
-import {TimeUtils} from 'common/time_utils';
 import {WinscopeEvent, WinscopeEventType} from 'messaging/winscope_event';
 import {Trace, TraceEntry} from 'trace/trace';
 import {Traces} from 'trace/traces';
 import {TraceEntryFinder} from 'trace/trace_entry_finder';
 import {TraceType} from 'trace/trace_type';
+import {TIMESTAMP_FORMATTER} from 'trace/tree_node/formatters';
 import {PropertyTreeNode} from 'trace/tree_node/property_tree_node';
+import {DEFAULT_PROPERTY_TREE_NODE_FACTORY} from 'trace/tree_node/property_tree_node_factory';
 import {Filter} from 'viewers/common/operations/filter';
 import {UiPropertyTreeNode} from 'viewers/common/ui_property_tree_node';
 import {UiTreeFormatter} from 'viewers/common/ui_tree_formatter';
@@ -348,7 +349,13 @@
       const entry = this.trace.getEntry(originalIndex);
       const entryNode = entryProtos[originalIndex];
       const vsyncId = Number(assertDefined(entryNode.getChildByName('vsyncId')).getValue());
-      const entryTimestamp = TimeUtils.format(entry.getTimestamp());
+
+      const entryTimestamp = DEFAULT_PROPERTY_TREE_NODE_FACTORY.makeCalculatedProperty(
+        'TransactionsTraceEntry',
+        'timestamp',
+        entry.getTimestamp()
+      );
+      entryTimestamp.setFormatter(TIMESTAMP_FORMATTER);
 
       for (const transactionState of assertDefined(
         entryNode.getChildByName('transactions')
diff --git a/tools/winscope/src/viewers/viewer_transactions/presenter_test.ts b/tools/winscope/src/viewers/viewer_transactions/presenter_test.ts
index ac06b3a..253ccae 100644
--- a/tools/winscope/src/viewers/viewer_transactions/presenter_test.ts
+++ b/tools/winscope/src/viewers/viewer_transactions/presenter_test.ts
@@ -320,12 +320,14 @@
 
   it('formats real time', async () => {
     await setUpTestEnvironment(TimestampType.REAL);
-    expect(assertDefined(outputUiData).entries[0].time).toEqual('2022-08-03T06:19:01.051480997');
+    expect(assertDefined(outputUiData).entries[0].time.formattedValue()).toEqual(
+      '2022-08-03T06:19:01.051480997'
+    );
   });
 
   it('formats elapsed time', async () => {
     await setUpTestEnvironment(TimestampType.ELAPSED);
-    expect(assertDefined(outputUiData).entries[0].time).toEqual('2s450ms981445ns');
+    expect(assertDefined(outputUiData).entries[0].time.formattedValue()).toEqual('2s450ms981445ns');
   });
 
   const setUpTestEnvironment = async (timestampType: TimestampType) => {
diff --git a/tools/winscope/src/viewers/viewer_transactions/scroll_strategy/transactions_scroll_strategy.ts b/tools/winscope/src/viewers/viewer_transactions/scroll_strategy/transactions_scroll_strategy.ts
index 20b1447..2535661 100644
--- a/tools/winscope/src/viewers/viewer_transactions/scroll_strategy/transactions_scroll_strategy.ts
+++ b/tools/winscope/src/viewers/viewer_transactions/scroll_strategy/transactions_scroll_strategy.ts
@@ -24,7 +24,10 @@
 
   protected override predictScrollItemHeight(entry: UiDataEntry): number {
     const whatHeight = this.subItemHeight(entry.what, this.whatCharsPerRow);
-    const timestampHeight = this.subItemHeight(entry.time, this.timestampCharsPerRow);
+    const timestampHeight = this.subItemHeight(
+      entry.time.formattedValue(),
+      this.timestampCharsPerRow
+    );
     return Math.max(whatHeight, timestampHeight);
   }
 }
diff --git a/tools/winscope/src/viewers/viewer_transactions/ui_data.ts b/tools/winscope/src/viewers/viewer_transactions/ui_data.ts
index 9af0c9f..c494224 100644
--- a/tools/winscope/src/viewers/viewer_transactions/ui_data.ts
+++ b/tools/winscope/src/viewers/viewer_transactions/ui_data.ts
@@ -55,7 +55,7 @@
 class UiDataEntry {
   constructor(
     public originalIndexInTraceEntry: number,
-    public time: string,
+    public time: PropertyTreeNode,
     public vsyncId: number,
     public pid: string,
     public uid: string,
diff --git a/tools/winscope/src/viewers/viewer_transactions/viewer_transactions.ts b/tools/winscope/src/viewers/viewer_transactions/viewer_transactions.ts
index 2ff5415..cc52c8f 100644
--- a/tools/winscope/src/viewers/viewer_transactions/viewer_transactions.ts
+++ b/tools/winscope/src/viewers/viewer_transactions/viewer_transactions.ts
@@ -14,9 +14,13 @@
  * limitations under the License.
  */
 
-import {WinscopeEvent} from 'messaging/winscope_event';
+import {FunctionUtils} from 'common/function_utils';
+import {Timestamp} from 'common/time';
+import {TracePositionUpdate, WinscopeEvent} from 'messaging/winscope_event';
+import {EmitEvent} from 'messaging/winscope_event_emitter';
 import {Traces} from 'trace/traces';
 import {TraceType} from 'trace/trace_type';
+import {PropertyTreeNode} from 'trace/tree_node/property_tree_node';
 import {ViewerEvents} from 'viewers/common/viewer_events';
 import {View, Viewer, ViewType} from 'viewers/viewer';
 import {Events} from './events';
@@ -29,6 +33,7 @@
   private readonly htmlElement: HTMLElement;
   private readonly presenter: Presenter;
   private readonly view: View;
+  private emitAppEvent: EmitEvent = FunctionUtils.DO_NOTHING_ASYNC;
 
   constructor(traces: Traces, storage: Storage) {
     this.htmlElement = document.createElement('viewer-transactions');
@@ -68,13 +73,12 @@
     this.htmlElement.addEventListener(Events.EntryClicked, (event) => {
       this.presenter.onEntryClicked((event as CustomEvent).detail);
     });
+    this.htmlElement.addEventListener(Events.TimestampSelected, (event) => {
+      this.propagateTimestamp((event as CustomEvent).detail);
+    });
 
-    this.htmlElement.addEventListener(
-      ViewerEvents.PropertiesUserOptionsChange,
-      async (event) =>
-        await this.presenter.onPropertiesUserOptionsChange(
-          (event as CustomEvent).detail.userOptions
-        )
+    this.htmlElement.addEventListener(ViewerEvents.PropertiesUserOptionsChange, (event) =>
+      this.presenter.onPropertiesUserOptionsChange((event as CustomEvent).detail.userOptions)
     );
 
     this.view = new View(
@@ -90,8 +94,13 @@
     await this.presenter.onAppEvent(event);
   }
 
-  setEmitEvent() {
-    // do nothing
+  setEmitEvent(callback: EmitEvent) {
+    this.emitAppEvent = callback;
+  }
+
+  async propagateTimestamp(timestampNode: PropertyTreeNode) {
+    const timestamp: Timestamp = timestampNode.getValue();
+    await this.emitAppEvent(TracePositionUpdate.fromTimestamp(timestamp, true));
   }
 
   getViews(): View[] {
diff --git a/tools/winscope/src/viewers/viewer_transactions/viewer_transactions_component.ts b/tools/winscope/src/viewers/viewer_transactions/viewer_transactions_component.ts
index eeda175..fb72c90 100644
--- a/tools/winscope/src/viewers/viewer_transactions/viewer_transactions_component.ts
+++ b/tools/winscope/src/viewers/viewer_transactions/viewer_transactions_component.ts
@@ -16,7 +16,11 @@
 import {CdkVirtualScrollViewport} from '@angular/cdk/scrolling';
 import {Component, ElementRef, Inject, Input, ViewChild} from '@angular/core';
 import {MatSelectChange} from '@angular/material/select';
+import {PropertyTreeNode} from 'trace/tree_node/property_tree_node';
 import {ViewerEvents} from 'viewers/common/viewer_events';
+import {currentElementStyle} from 'viewers/components/styles/current_element.styles';
+import {selectedElementStyle} from 'viewers/components/styles/selected_element.styles';
+import {timeButtonStyle} from 'viewers/components/styles/timestamp_button.styles';
 import {Events} from './events';
 import {UiData} from './ui_data';
 
@@ -104,11 +108,16 @@
             *cdkVirtualFor="let entry of uiData.entries; let i = index"
             class="entry"
             [attr.item-id]="i"
-            [class.current-entry]="isCurrentEntry(i)"
-            [class.selected-entry]="isSelectedEntry(i)"
+            [class.current]="isCurrentEntry(i)"
+            [class.selected]="isSelectedEntry(i)"
             (click)="onEntryClicked(i)">
             <div class="time">
-              <span class="mat-body-1">{{ entry.time }}</span>
+              <button
+                mat-button
+                [color]="isCurrentEntry(i) ? 'secondary' : 'primary'"
+                (click)="onTimestampClicked(entry.time)">
+                {{ entry.time.formattedValue() }}
+              </button>
             </div>
             <div class="id">
               <span class="mat-body-1">{{ entry.transactionId }}</span>
@@ -227,16 +236,6 @@
         margin-right: 16px;
       }
 
-      .entry.current-entry {
-        color: white;
-        background-color: #365179;
-      }
-
-      .entry.selected-entry {
-        color: white;
-        background-color: #98aecd;
-      }
-
       .go-to-current-time {
         flex: none;
         margin-top: 4px;
@@ -251,11 +250,15 @@
         max-height: 75vh;
       }
     `,
+    selectedElementStyle,
+    currentElementStyle,
+    timeButtonStyle,
   ],
 })
 class ViewerTransactionsComponent {
   objectKeys = Object.keys;
   uiData: UiData = UiData.EMPTY;
+  private lastClicked = '';
 
   @ViewChild(CdkVirtualScrollViewport) scrollComponent?: CdkVirtualScrollViewport;
 
@@ -264,7 +267,11 @@
   @Input()
   set inputData(data: UiData) {
     this.uiData = data;
-    if (this.uiData.scrollToIndex !== undefined && this.scrollComponent) {
+    if (
+      this.uiData.scrollToIndex !== undefined &&
+      this.scrollComponent &&
+      this.lastClicked !== this.uiData.entries[this.uiData.scrollToIndex].time.formattedValue()
+    ) {
       this.scrollComponent.scrollToIndex(this.uiData.scrollToIndex);
     }
   }
@@ -315,6 +322,11 @@
     }
   }
 
+  onTimestampClicked(timestamp: PropertyTreeNode) {
+    this.lastClicked = timestamp.formattedValue();
+    this.emitEvent(Events.TimestampSelected, timestamp);
+  }
+
   isCurrentEntry(index: number): boolean {
     return index === this.uiData.currentEntryIndex;
   }
diff --git a/tools/winscope/src/viewers/viewer_transactions/viewer_transactions_component_test.ts b/tools/winscope/src/viewers/viewer_transactions/viewer_transactions_component_test.ts
index f8e3cd3..258d294 100644
--- a/tools/winscope/src/viewers/viewer_transactions/viewer_transactions_component_test.ts
+++ b/tools/winscope/src/viewers/viewer_transactions/viewer_transactions_component_test.ts
@@ -18,9 +18,12 @@
 import {ComponentFixture, ComponentFixtureAutoDetect, TestBed} from '@angular/core/testing';
 import {MatDividerModule} from '@angular/material/divider';
 import {assertDefined} from 'common/assert_utils';
+import {NO_TIMEZONE_OFFSET_FACTORY} from 'common/timestamp_factory';
 import {PropertyTreeBuilder} from 'test/unit/property_tree_builder';
+import {TIMESTAMP_FORMATTER} from 'trace/tree_node/formatters';
 import {executeScrollComponentTests} from 'viewers/common/scroll_component_test_utils';
 import {UiPropertyTreeNode} from 'viewers/common/ui_property_tree_node';
+import {Events} from './events';
 import {TransactionsScrollDirective} from './scroll_strategy/transactions_scroll_directive';
 import {UiData, UiDataEntry} from './ui_data';
 import {ViewerTransactionsComponent} from './viewer_transactions_component';
@@ -62,7 +65,7 @@
       expect(htmlElement.querySelector('.scroll')).toBeTruthy();
 
       const entry = assertDefined(htmlElement.querySelector('.scroll .entry'));
-      expect(entry.innerHTML).toContain('TIME_VALUE');
+      expect(entry.innerHTML).toContain('1ns');
       expect(entry.innerHTML).toContain('-111');
       expect(entry.innerHTML).toContain('PID_VALUE');
       expect(entry.innerHTML).toContain('UID_VALUE');
@@ -84,6 +87,21 @@
       expect(spy).toHaveBeenCalledWith(1);
     });
 
+    it('propagates timestamp on click', () => {
+      component.inputData = makeUiData();
+      fixture.detectChanges();
+      let timestamp = '';
+      htmlElement.addEventListener(Events.TimestampSelected, (event) => {
+        timestamp = (event as CustomEvent).detail.formattedValue();
+      });
+      const logTimestampButton = assertDefined(
+        htmlElement.querySelector('.time button')
+      ) as HTMLButtonElement;
+      logTimestampButton.click();
+
+      expect(timestamp).toEqual('1ns');
+    });
+
     function makeUiData(): UiData {
       const propertiesTree = new PropertyTreeBuilder()
         .setRootId('Transactions')
@@ -91,9 +109,16 @@
         .setValue(null)
         .build();
 
+      const time = new PropertyTreeBuilder()
+        .setRootId(propertiesTree.id)
+        .setName('timestamp')
+        .setValue(NO_TIMEZONE_OFFSET_FACTORY.makeElapsedTimestamp(1n))
+        .setFormatter(TIMESTAMP_FORMATTER)
+        .build();
+
       const entry = new UiDataEntry(
         0,
-        'TIME_VALUE',
+        time,
         -111,
         'PID_VALUE',
         'UID_VALUE',
@@ -106,7 +131,7 @@
 
       const entry2 = new UiDataEntry(
         1,
-        'TIME_VALUE',
+        time,
         -222,
         'PID_VALUE_2',
         'UID_VALUE_2',
@@ -144,6 +169,14 @@
         .setName('tree')
         .setValue(null)
         .build();
+
+      const time = new PropertyTreeBuilder()
+        .setRootId(propertiesTree.id)
+        .setName('timestamp')
+        .setValue(NO_TIMEZONE_OFFSET_FACTORY.makeElapsedTimestamp(1n))
+        .setFormatter(TIMESTAMP_FORMATTER)
+        .build();
+
       const uiData = new UiData(
         [],
         [],
@@ -164,7 +197,7 @@
       for (let i = 0; i < 200; i++) {
         const entry = new UiDataEntry(
           0,
-          'TIME_VALUE',
+          time,
           -111,
           'PID_VALUE',
           'UID_VALUE',
diff --git a/tools/winscope/src/viewers/viewer_transitions/events.ts b/tools/winscope/src/viewers/viewer_transitions/events.ts
index 95f9665..aa19964 100644
--- a/tools/winscope/src/viewers/viewer_transitions/events.ts
+++ b/tools/winscope/src/viewers/viewer_transitions/events.ts
@@ -16,6 +16,7 @@
 
 class Events {
   static TransitionSelected = 'ViewerTransitionsEvent_TransitionSelected';
+  static TimestampSelected = 'ViewerTransitionsEvent_TimestampSelected';
 }
 
 export {Events};
diff --git a/tools/winscope/src/viewers/viewer_transitions/presenter.ts b/tools/winscope/src/viewers/viewer_transitions/presenter.ts
index b8a165b..b9a08ca 100644
--- a/tools/winscope/src/viewers/viewer_transitions/presenter.ts
+++ b/tools/winscope/src/viewers/viewer_transitions/presenter.ts
@@ -112,12 +112,13 @@
   private makeTransitions(entries: PropertyTreeNode[]): Transition[] {
     return entries.map((transitionNode) => {
       const wmDataNode = assertDefined(transitionNode.getChildByName('wmData'));
+      const shellDataNode = assertDefined(transitionNode.getChildByName('shellData'));
 
       const transition: Transition = {
         id: assertDefined(transitionNode.getChildByName('id')).getValue(),
         type: wmDataNode.getChildByName('type')?.formattedValue() ?? 'NONE',
-        sendTime: wmDataNode.getChildByName('sendTimeNs')?.formattedValue(),
-        finishTime: wmDataNode.getChildByName('finishTimeNs')?.formattedValue(),
+        sendTime: wmDataNode.getChildByName('sendTimeNs'),
+        dispatchTime: shellDataNode.getChildByName('dispatchTimeNs'),
         duration: transitionNode.getChildByName('duration')?.formattedValue(),
         merged: assertDefined(transitionNode.getChildByName('merged')).getValue(),
         aborted: assertDefined(transitionNode.getChildByName('aborted')).getValue(),
diff --git a/tools/winscope/src/viewers/viewer_transitions/viewer_transitions.ts b/tools/winscope/src/viewers/viewer_transitions/viewer_transitions.ts
index 495e825..f5e53a1 100644
--- a/tools/winscope/src/viewers/viewer_transitions/viewer_transitions.ts
+++ b/tools/winscope/src/viewers/viewer_transitions/viewer_transitions.ts
@@ -13,9 +13,13 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import {WinscopeEvent} from 'messaging/winscope_event';
+import {FunctionUtils} from 'common/function_utils';
+import {Timestamp} from 'common/time';
+import {TracePositionUpdate, WinscopeEvent} from 'messaging/winscope_event';
+import {EmitEvent} from 'messaging/winscope_event_emitter';
 import {Traces} from 'trace/traces';
 import {TraceType} from 'trace/trace_type';
+import {PropertyTreeNode} from 'trace/tree_node/property_tree_node';
 import {View, Viewer, ViewType} from 'viewers/viewer';
 import {Events} from './events';
 import {Presenter} from './presenter';
@@ -27,6 +31,7 @@
   private readonly htmlElement: HTMLElement;
   private readonly presenter: Presenter;
   private readonly view: View;
+  private emitAppEvent: EmitEvent = FunctionUtils.DO_NOTHING_ASYNC;
 
   constructor(traces: Traces) {
     this.htmlElement = document.createElement('viewer-transitions');
@@ -39,6 +44,10 @@
       this.presenter.onTransitionSelected((event as CustomEvent).detail);
     });
 
+    this.htmlElement.addEventListener(Events.TimestampSelected, (event) => {
+      this.propagateTimestamp((event as CustomEvent).detail);
+    });
+
     this.view = new View(
       ViewType.TAB,
       this.getDependencies(),
@@ -52,8 +61,13 @@
     await this.presenter.onAppEvent(event);
   }
 
-  setEmitEvent() {
-    // do nothing
+  setEmitEvent(callback: EmitEvent) {
+    this.emitAppEvent = callback;
+  }
+
+  async propagateTimestamp(timestampNode: PropertyTreeNode) {
+    const timestamp: Timestamp = timestampNode.getValue();
+    await this.emitAppEvent(TracePositionUpdate.fromTimestamp(timestamp, true));
   }
 
   getViews(): View[] {
diff --git a/tools/winscope/src/viewers/viewer_transitions/viewer_transitions_component.ts b/tools/winscope/src/viewers/viewer_transitions/viewer_transitions_component.ts
index 8e8a066..ca78276 100644
--- a/tools/winscope/src/viewers/viewer_transitions/viewer_transitions_component.ts
+++ b/tools/winscope/src/viewers/viewer_transitions/viewer_transitions_component.ts
@@ -17,6 +17,8 @@
 import {Component, ElementRef, Inject, Input} from '@angular/core';
 import {Transition} from 'trace/transition';
 import {PropertyTreeNode} from 'trace/tree_node/property_tree_node';
+import {selectedElementStyle} from 'viewers/components/styles/selected_element.styles';
+import {timeButtonStyle} from 'viewers/components/styles/timestamp_button.styles';
 import {Events} from './events';
 import {UiData} from './ui_data';
 
@@ -29,6 +31,7 @@
           <div class="id mat-body-2">Id</div>
           <div class="type mat-body-2">Type</div>
           <div class="send-time mat-body-2">Send Time</div>
+          <div class="dispatch-time mat-body-2">Dispatch Time</div>
           <div class="duration mat-body-2">Duration</div>
           <div class="status mat-body-2">Status</div>
         </div>
@@ -36,7 +39,7 @@
           <div
             *cdkVirtualFor="let transition of uiData.entries; let i = index"
             class="entry table-row"
-            [class.current]="isCurrentTransition(transition)"
+            [class.selected]="isSelectedTransition(transition)"
             (click)="onTransitionClicked(transition)">
             <div class="id">
               <span class="mat-body-1">{{ transition.id }}</span>
@@ -44,10 +47,26 @@
             <div class="type">
               <span class="mat-body-1">{{ transition.type }}</span>
             </div>
-            <div class="send-time">
-              <span *ngIf="transition.sendTime" class="mat-body-1">{{ transition.sendTime }}</span>
+            <div class="send-time time">
+              <button
+                mat-button
+                color="primary"
+                *ngIf="transition.sendTime"
+                (click)="onTimestampClicked(transition.sendTime)">
+                {{ transition.sendTime.formattedValue() }}
+              </button>
               <span *ngIf="!transition.sendTime" class="mat-body-1"> n/a </span>
             </div>
+            <div class="dispatch-time time">
+              <button
+                mat-button
+                color="primary"
+                *ngIf="transition.dispatchTime"
+                (click)="onTimestampClicked(transition.dispatchTime)">
+                {{ transition.dispatchTime.formattedValue() }}
+              </button>
+              <span *ngIf="!transition.dispatchTime" class="mat-body-1"> n/a </span>
+            </div>
             <div class="duration">
               <span *ngIf="transition.duration" class="mat-body-1">{{ transition.duration }}</span>
               <span *ngIf="!transition.duration" class="mat-body-1"> n/a </span>
@@ -133,11 +152,6 @@
         border-bottom: solid 1px rgba(0, 0, 0, 0.5);
       }
 
-      .scroll .entry.current {
-        color: white;
-        background-color: #365179;
-      }
-
       .table-row > div {
         padding: 16px;
       }
@@ -150,6 +164,10 @@
         flex: 2;
       }
 
+      .table-row .dispatch-time {
+        flex: 4;
+      }
+
       .table-row .send-time {
         flex: 4;
       }
@@ -169,7 +187,7 @@
         gap: 5px;
       }
 
-      .current .status mat-icon {
+      .selected .status mat-icon {
         color: white !important;
       }
 
@@ -186,13 +204,9 @@
         flex-grow: 1;
         padding: 0.5rem;
       }
-
-      .selected-transition {
-        padding: 1rem;
-        border-bottom: solid 1px rgba(0, 0, 0, 0.12);
-        flex-grow: 1;
-      }
     `,
+    selectedElementStyle,
+    timeButtonStyle,
   ],
 })
 export class ViewerTransitionsComponent {
@@ -207,7 +221,7 @@
     this.emitEvent(Events.TransitionSelected, transition.propertiesTree);
   }
 
-  isCurrentTransition(transition: Transition): boolean {
+  isSelectedTransition(transition: Transition): boolean {
     return (
       transition.id ===
         this.uiData.selectedTransition
@@ -222,6 +236,10 @@
     );
   }
 
+  onTimestampClicked(timestamp: PropertyTreeNode) {
+    this.emitEvent(Events.TimestampSelected, timestamp);
+  }
+
   emitEvent(event: string, propertiesTree: PropertyTreeNode) {
     const customEvent = new CustomEvent(event, {
       bubbles: true,
diff --git a/tools/winscope/src/viewers/viewer_transitions/viewer_transitions_component_test.ts b/tools/winscope/src/viewers/viewer_transitions/viewer_transitions_component_test.ts
index 835ba06..f8cc62f 100644
--- a/tools/winscope/src/viewers/viewer_transitions/viewer_transitions_component_test.ts
+++ b/tools/winscope/src/viewers/viewer_transitions/viewer_transitions_component_test.ts
@@ -20,6 +20,7 @@
 import {MatDividerModule} from '@angular/material/divider';
 import {assertDefined} from 'common/assert_utils';
 import {TimestampType} from 'common/time';
+import {NO_TIMEZONE_OFFSET_FACTORY} from 'common/timestamp_factory';
 import {TracePositionUpdate} from 'messaging/winscope_event';
 import {PropertyTreeBuilder} from 'test/unit/property_tree_builder';
 import {UnitTestUtils} from 'test/unit/utils';
@@ -29,6 +30,7 @@
 import {TracePosition} from 'trace/trace_position';
 import {TraceType} from 'trace/trace_type';
 import {Transition} from 'trace/transition';
+import {TIMESTAMP_FORMATTER} from 'trace/tree_node/formatters';
 import {PropertyTreeNode} from 'trace/tree_node/property_tree_node';
 import {TreeComponent} from 'viewers/components/tree_component';
 import {TreeNodeComponent} from 'viewers/components/tree_node_component';
@@ -154,6 +156,19 @@
     const textContentWithoutWhitespaces = treeView.textContent?.replace(/(\s|\t|\n)*/g, '');
     expect(textContentWithoutWhitespaces).toContain(`id:${selectedTransitionId}`);
   });
+
+  it('propagates timestamp on click', () => {
+    let timestamp = '';
+    htmlElement.addEventListener(Events.TimestampSelected, (event) => {
+      timestamp = (event as CustomEvent).detail.formattedValue();
+    });
+    const logTimestampButton = assertDefined(
+      htmlElement.querySelector('.time button')
+    ) as HTMLButtonElement;
+    logTimestampButton.click();
+
+    expect(timestamp).toEqual('20ns');
+  });
 });
 
 function makeUiData(): UiData {
@@ -181,11 +196,18 @@
     .setChildren([{name: 'id', value: id}])
     .build();
 
+  const sendTimeNode = new PropertyTreeBuilder()
+    .setRootId(transitionTree.id)
+    .setName('sendTimeNs')
+    .setValue(NO_TIMEZONE_OFFSET_FACTORY.makeElapsedTimestamp(BigInt(sendTimeNanos)))
+    .setFormatter(TIMESTAMP_FORMATTER)
+    .build();
+
   return {
     id,
     type: 'TO_FRONT',
-    sendTime: sendTimeNanos.toString() + 'ns',
-    finishTime: finishTimeNanos.toString() + 'ns',
+    sendTime: sendTimeNode,
+    dispatchTime: undefined,
     duration: (finishTimeNanos - sendTimeNanos).toString() + 'ns',
     merged: false,
     aborted: false,