Merge "tp: Add Distinct to CEngine API" into main
diff --git a/Android.bp b/Android.bp
index 94b84ff..f8e8b03 100644
--- a/Android.bp
+++ b/Android.bp
@@ -12906,6 +12906,7 @@
     name: "perfetto_src_trace_redaction_trace_redaction",
     srcs: [
         "src/trace_redaction/collect_frame_cookies.cc",
+        "src/trace_redaction/collect_system_info.cc",
         "src/trace_redaction/collect_timeline_events.cc",
         "src/trace_redaction/filter_ftrace_using_allowlist.cc",
         "src/trace_redaction/filter_packet_using_allowlist.cc",
@@ -12937,6 +12938,7 @@
     name: "perfetto_src_trace_redaction_unittests",
     srcs: [
         "src/trace_redaction/collect_frame_cookies_unittest.cc",
+        "src/trace_redaction/collect_system_info_unittest.cc",
         "src/trace_redaction/collect_timeline_events_unittest.cc",
         "src/trace_redaction/filter_ftrace_using_allowlist_unittest.cc",
         "src/trace_redaction/filter_packet_using_allowlist_unittest.cc",
diff --git a/CHANGELOG b/CHANGELOG
index 99ff035..eb1dab5 100644
--- a/CHANGELOG
+++ b/CHANGELOG
@@ -3,6 +3,14 @@
     *
   Trace Processor:
     *
+  SQL Standard library:
+    * Improved support for querying startups on Android 9 (API level 28) and
+      below. Available in `android.startup.startups` module.
+    * Added tables for querying "Time To Initial Display" and "Time To Full
+      Display" metrics for app startups. Available in new module
+      `android.startup.time_to_display`.
+    * Added table for querying hardware power rail counters in new
+      `android.power_rails` module.
   UI:
     * Add tracks to the list of searchable items.
     * Use mipmaps to improve track query performance on large traces.
@@ -460,7 +468,7 @@
     * Changed sorting of process groups to take slice count and presence of
       perf profiles into account.
   SDK:
-    * 
+    *
 
 
 v26.1 - 2022-06-13:
@@ -506,7 +514,7 @@
       endoint or even newer /websocket.
   UI:
     * Changed detail panel to separate slice args from generic slice properties.
-    * Automatically enabled sched_compact when generating trace configs for 
+    * Automatically enabled sched_compact when generating trace configs for
       Android S+ targets.
   SDK:
     * Added option for recording thread CPU times at the beginning and end of
diff --git a/protos/third_party/chromium/chrome_track_event.proto b/protos/third_party/chromium/chrome_track_event.proto
index 0a8842f..8b766cb 100644
--- a/protos/third_party/chromium/chrome_track_event.proto
+++ b/protos/third_party/chromium/chrome_track_event.proto
@@ -39,6 +39,7 @@
     TASK_SCOPE_SCHEDULER_POST_TASK = 6;
     TASK_SCOPE_REQUEST_IDLE_CALLBACK = 7;
     TASK_SCOPE_XML_HTTP_REQUEST = 8;
+    TASK_SCOPE_SOFT_NAVIGATION = 9;
   }
   optional TaskScopeType type = 1;
   optional int64 scope_task_id = 2;
diff --git a/src/trace_processor/perfetto_sql/intrinsics/operators/slice_mipmap_operator.cc b/src/trace_processor/perfetto_sql/intrinsics/operators/slice_mipmap_operator.cc
index 6fe6956..f1c6b08 100644
--- a/src/trace_processor/perfetto_sql/intrinsics/operators/slice_mipmap_operator.cc
+++ b/src/trace_processor/perfetto_sql/intrinsics/operators/slice_mipmap_operator.cc
@@ -77,7 +77,7 @@
                                 sqlite3_vtab** vtab,
                                 char** zErr) {
   if (argc != 4) {
-    *zErr = sqlite3_mprintf("zoom_index_operator: wrong number of arguments");
+    *zErr = sqlite3_mprintf("slice_mipmap: wrong number of arguments");
     return SQLITE_ERROR;
   }
 
@@ -93,8 +93,7 @@
   auto res = ctx->engine->ExecuteUntilLastStatement(
       SqlSource::FromTraceProcessorImplementation(std::move(sql)));
   if (!res.ok()) {
-    *zErr =
-        sqlite3_mprintf("zoom_index_operator: %s", res.status().c_message());
+    *zErr = sqlite3_mprintf("%s", res.status().c_message());
     return SQLITE_ERROR;
   }
   do {
@@ -113,8 +112,7 @@
     by_depth.timestamps.push_back(ts);
   } while (res->stmt.Step());
   if (!res->stmt.status().ok()) {
-    *zErr = sqlite3_mprintf("zoom_index_operator: %s",
-                            res->stmt.status().c_message());
+    *zErr = sqlite3_mprintf("%s", res->stmt.status().c_message());
     return SQLITE_ERROR;
   }
 
@@ -208,8 +206,8 @@
         tses.begin(), std::lower_bound(tses.begin(), tses.end(), start)));
     if (start_idx != 0 &&
         (static_cast<size_t>(start_idx) == tses.size() ||
-         tses[start_idx] != start) &&
-        (tses[start_idx] + by_depth.forest[start_idx].dur > start)) {
+         (tses[start_idx] != start &&
+          tses[start_idx] + by_depth.forest[start_idx].dur > start))) {
       --start_idx;
     }
 
diff --git a/src/trace_processor/perfetto_sql/stdlib/chrome/scroll_jank/scroll_jank_v3.sql b/src/trace_processor/perfetto_sql/stdlib/chrome/scroll_jank/scroll_jank_v3.sql
index 4bc9513..589adec 100644
--- a/src/trace_processor/perfetto_sql/stdlib/chrome/scroll_jank/scroll_jank_v3.sql
+++ b/src/trace_processor/perfetto_sql/stdlib/chrome/scroll_jank/scroll_jank_v3.sql
@@ -8,22 +8,7 @@
 -- in BTP.
 INCLUDE PERFETTO MODULE chrome.metadata;
 INCLUDE PERFETTO MODULE chrome.scroll_jank.scroll_jank_v3_cause;
-
--- Checks if slice has a descendant with provided name.
-CREATE PERFETTO FUNCTION _has_descendant_slice_with_name(
-  -- Id of the slice to check descendants of.
-  id INT,
-  -- Name of potential descendant slice.
-  descendant_name STRING
-)
--- Whether `descendant_name` is a name of an descendant slice.
-RETURNS BOOL AS
-SELECT EXISTS(
-  SELECT 1
-  FROM descendant_slice($id)
-  WHERE name = $descendant_name
-  LIMIT 1
-);
+INCLUDE PERFETTO MODULE chrome.scroll_jank.utils;
 
 -- Finds the end timestamp for a given slice's descendant with a given name.
 -- If there are multiple descendants with a given name, the function will return the
@@ -58,9 +43,8 @@
 FROM slice
 WHERE $id = id;
 
-
 -- Grabs all gesture updates with respective scroll ids and start/end
--- timestamps, regardless of being coalesced.
+-- timestamps, regardless of being presented.
 CREATE PERFETTO TABLE chrome_gesture_scroll_updates(
   -- The start timestamp of the scroll.
   ts INT,
@@ -72,82 +56,100 @@
   scroll_update_id INT,
   -- The id of the scroll.
   scroll_id INT,
-  -- Whether this input event was coalesced.
-  is_coalesced BOOL
+  -- Whether this input event was presented.
+  is_presented BOOL,
+  -- Frame presentation timestamp aka the timestamp of the
+  -- SwapEndToPresentationCompositorFrame substage.
+  presentation_timestamp INT,
+  -- EventLatency event type.
+  event_type INT
 ) AS
 SELECT
-  ts,
-  dur,
-  id,
-  -- TODO(b/250089570) Add trace_id to EventLatency and update this script to use it.
-  EXTRACT_ARG(arg_set_id, 'chrome_latency_info.trace_id') AS scroll_update_id,
-  EXTRACT_ARG(arg_set_id, 'chrome_latency_info.gesture_scroll_id') AS scroll_id,
-  EXTRACT_ARG(arg_set_id, 'chrome_latency_info.is_coalesced') AS is_coalesced
-FROM slice
-WHERE name = "InputLatency::GestureScrollUpdate" AND dur != -1;
+  slice.ts,
+  slice.dur,
+  slice.id,
+  EXTRACT_arg(arg_set_id, "event_latency.event_latency_id") AS scroll_update_id,
+  chrome_get_most_recent_scroll_begin_id(slice.ts) AS scroll_id,
+  has_descendant_slice_with_name(slice.id, "SubmitCompositorFrameToPresentationCompositorFrame")
+  AS is_presented,
+  _descendant_slice_end(slice.id, "SwapEndToPresentationCompositorFrame") AS presentation_timestamp,
+  EXTRACT_ARG(arg_set_id, 'event_latency.event_type') AS event_type
+FROM slice JOIN args USING(arg_set_id)
+WHERE name = "EventLatency"
+AND args.string_value GLOB "*GESTURE_SCROLL_UPDATE";
 
-CREATE PERFETTO TABLE _non_coalesced_gesture_scrolls AS
+CREATE PERFETTO TABLE _presented_gesture_scrolls AS
 SELECT
   id,
   ts,
   dur,
   scroll_update_id,
-  scroll_id
-FROM  chrome_gesture_scroll_updates
-WHERE is_coalesced = false
+  scroll_id,
+  presentation_timestamp,
+  event_type
+FROM chrome_gesture_scroll_updates
+WHERE is_presented = true
 ORDER BY ts ASC;
 
 -- Scroll updates, corresponding to all input events that were converted to a
 -- presented scroll update.
 CREATE PERFETTO TABLE chrome_presented_gesture_scrolls(
-  -- Minimum slice id for input presented in this frame, the non coalesced input.
+  -- Minimum slice id for input presented in this frame, the non-presented input.
   id INT,
   -- The start timestamp for producing the frame.
   ts INT,
   -- The duration between producing and presenting the frame.
   dur INT,
-  -- The timestamp of the last input that arrived and got coalesced into the frame.
-  last_coalesced_input_ts INT,
+  -- The timestamp of the last input that arrived and got presented in the frame.
+  last_presented_input_ts INT,
   -- The id of the scroll update event, a unique identifier to the gesture.
   scroll_update_id INT,
   -- The id of the ongoing scroll.
-  scroll_id INT
+  scroll_id INT,
+  -- Frame presentation timestamp.
+  presentation_timestamp INT,
+  -- EventLatency event type.
+  event_type INT
 ) AS
 WITH
-scroll_updates_with_coalesce_info as MATERIALIZED (
+scroll_updates_with_presentation_info as MATERIALIZED (
   SELECT
     id,
     ts,
-    -- For each scroll update, find the latest non-coalesced update which
-    -- happened before it. For coalesced scroll updates, this will be the
-    -- presented scroll update they have been coalesced into.
+    -- For each scroll update, find the latest presented update which
+    -- started before it.
     (
       SELECT id
-      FROM _non_coalesced_gesture_scrolls non_coalesced
-      WHERE non_coalesced.ts <= scroll_update.ts
+      FROM _presented_gesture_scrolls _presented
+      WHERE _presented.ts <= scroll_update.ts
       ORDER BY ts DESC
       LIMIT 1
-     ) as coalesced_to_scroll_update_slice_id
+     ) as presented_to_scroll_update_slice_id
   FROM chrome_gesture_scroll_updates scroll_update
-  ORDER BY coalesced_to_scroll_update_slice_id, ts
+  ORDER BY presented_to_scroll_update_slice_id, ts
 )
 SELECT
   id,
   ts,
   dur,
-  -- Find the latest input that was coalesced into this scroll update.
+  -- Find the latest input that was presented in this scroll update.
   (
-    SELECT coalesce_info.ts
-    FROM scroll_updates_with_coalesce_info coalesce_info
+    SELECT presentation_info.ts
+    FROM scroll_updates_with_presentation_info presentation_info
     WHERE
-      coalesce_info.coalesced_to_scroll_update_slice_id =
-        _non_coalesced_gesture_scrolls.id
+      presentation_info.presented_to_scroll_update_slice_id =
+        _presented_gesture_scrolls.id
     ORDER BY ts DESC
     LIMIT 1
-  ) as last_coalesced_input_ts,
+  ) as last_presented_input_ts,
   scroll_update_id,
-  scroll_id
-FROM _non_coalesced_gesture_scrolls;
+  scroll_id,
+  presentation_timestamp,
+  event_type
+FROM _presented_gesture_scrolls
+-- TODO(b/247542163): remove this condition when all stages
+-- of EventLatency are recorded correctly.
+WHERE presentation_timestamp IS NOT NULL;
 
 -- Associate every trace_id with it's perceived delta_y on the screen after
 -- prediction.
@@ -163,50 +165,15 @@
 FROM slice
 WHERE name = "InputHandlerProxy::HandleGestureScrollUpdate_Result";
 
--- Extract event latency timestamps, to later use it for joining
--- with gesture scroll updates, as event latencies don't have trace
--- ids associated with it.
-CREATE PERFETTO TABLE chrome_gesture_scroll_event_latencies(
-  -- Start timestamp for the EventLatency.
-  ts INT,
-  -- Slice id of the EventLatency.
-  event_latency_id INT,
-  -- Duration of the EventLatency.
-  dur INT,
-  -- End timestamp for input aka the timestamp of the LatchToSwapEnd substage.
-  input_latency_end_ts INT,
-  -- Frame presentation timestamp aka the timestamp of the
-  -- SwapEndToPresentationCompositorFrame substage.
-  presentation_timestamp INT,
-  -- EventLatency event type.
-  event_type INT
-) AS
-SELECT
-  slice.ts,
-  slice.id AS event_latency_id,
-  slice.dur AS dur,
-  _descendant_slice_end(slice.id, "LatchToSwapEnd") AS input_latency_end_ts,
-  _descendant_slice_end(slice.id, "SwapEndToPresentationCompositorFrame") AS presentation_timestamp,
-  EXTRACT_ARG(arg_set_id, 'event_latency.event_type') AS event_type
-FROM slice
-WHERE name = "EventLatency"
-      AND event_type in (
-          "GESTURE_SCROLL_UPDATE",
-          "FIRST_GESTURE_SCROLL_UPDATE",
-          "INERTIAL_GESTURE_SCROLL_UPDATE")
-      AND _has_descendant_slice_with_name(slice.id, "SwapEndToPresentationCompositorFrame");
-
--- Join presented gesture scrolls with their respective event
--- latencies based on |LatchToSwapEnd| timestamp, as it's the
--- end timestamp for both the gesture scroll update slice and
--- the LatchToSwapEnd slice.
+-- Obtain the subset of input events that were fully presented, as indicated
+-- by the presence of SwapEndToPresentationCompositorFrame.
 CREATE PERFETTO TABLE chrome_full_frame_view(
   -- ID of the frame.
   id INT,
   -- Start timestamp of the frame.
   ts INT,
-  -- The timestamp of the last coalesced input.
-  last_coalesced_input_ts INT,
+  -- The timestamp of the last presented input.
+  last_presented_input_ts INT,
   -- ID of the associated scroll.
   scroll_id INT,
   -- ID of the associated scroll update.
@@ -221,16 +188,18 @@
 SELECT
   frames.id,
   frames.ts,
-  frames.last_coalesced_input_ts,
+  frames.last_presented_input_ts,
   frames.scroll_id,
   frames.scroll_update_id,
-  events.event_latency_id,
-  events.dur,
-  events.presentation_timestamp
+  frames.id AS event_latency_id,
+  frames.dur,
+  frames.presentation_timestamp
 FROM chrome_presented_gesture_scrolls frames
-JOIN chrome_gesture_scroll_event_latencies events
-  ON frames.ts = events.ts
-  AND events.input_latency_end_ts = (frames.ts + frames.dur);
+WHERE frames.event_type in (
+          "GESTURE_SCROLL_UPDATE",
+          "FIRST_GESTURE_SCROLL_UPDATE",
+          "INERTIAL_GESTURE_SCROLL_UPDATE")
+    AND has_descendant_slice_with_name(frames.id, "SwapEndToPresentationCompositorFrame");
 
 -- Join deltas with EventLatency data.
 CREATE PERFETTO TABLE chrome_full_frame_delta_view(
@@ -242,8 +211,8 @@
   scroll_id INT,
   -- ID of the associated scroll update.
   scroll_update_id INT,
-  -- The timestamp of the last coalesced input.
-  last_coalesced_input_ts INT,
+  -- The timestamp of the last presented input.
+  last_presented_input_ts INT,
   -- The perceived delta_y on the screen post prediction.
   delta_y INT,
   -- ID of the associated EventLatency.
@@ -258,7 +227,7 @@
   frames.ts,
   frames.scroll_id,
   frames.scroll_update_id,
-  frames.last_coalesced_input_ts,
+  frames.last_presented_input_ts,
   deltas.delta_y,
   frames.event_latency_id,
   frames.dur,
@@ -272,7 +241,7 @@
 CREATE PERFETTO VIEW chrome_merged_frame_view(
   -- ID of the frame.
   id INT,
-  -- The timestamp of the last coalesced input.
+  -- The timestamp of the last presented input.
   max_start_ts INT,
   -- The earliest frame start timestamp.
   min_start_ts INT,
@@ -295,7 +264,7 @@
 ) AS
 SELECT
   id,
-  MAX(last_coalesced_input_ts) AS max_start_ts,
+  MAX(last_presented_input_ts) AS max_start_ts,
   MIN(ts) AS min_start_ts,
   scroll_id,
   scroll_update_id,
@@ -461,7 +430,7 @@
 ) AS
 SELECT DISTINCT
 presentation_timestamp
-FROM chrome_gesture_scroll_event_latencies;
+FROM chrome_presented_gesture_scrolls;
 
 -- Dividing missed frames over total frames to get janky frame percentage.
 -- This represents the v3 scroll jank metrics.
diff --git a/src/trace_redaction/BUILD.gn b/src/trace_redaction/BUILD.gn
index 4f2fb5f..037545d 100644
--- a/src/trace_redaction/BUILD.gn
+++ b/src/trace_redaction/BUILD.gn
@@ -30,6 +30,8 @@
   sources = [
     "collect_frame_cookies.cc",
     "collect_frame_cookies.h",
+    "collect_system_info.cc",
+    "collect_system_info.h",
     "collect_timeline_events.cc",
     "collect_timeline_events.h",
     "filter_ftrace_using_allowlist.cc",
@@ -126,6 +128,7 @@
   testonly = true
   sources = [
     "collect_frame_cookies_unittest.cc",
+    "collect_system_info_unittest.cc",
     "collect_timeline_events_unittest.cc",
     "filter_ftrace_using_allowlist_unittest.cc",
     "filter_packet_using_allowlist_unittest.cc",
@@ -157,5 +160,6 @@
     "../../protos/perfetto/trace/ps:cpp",
     "../../protos/perfetto/trace/ps:zero",
     "../base:test_support",
+    "../trace_processor/util:util",
   ]
 }
diff --git a/src/trace_redaction/collect_system_info.cc b/src/trace_redaction/collect_system_info.cc
new file mode 100644
index 0000000..374f510
--- /dev/null
+++ b/src/trace_redaction/collect_system_info.cc
@@ -0,0 +1,81 @@
+/*
+ * 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.
+ */
+
+#include "src/trace_redaction/collect_system_info.h"
+
+#include "perfetto/protozero/field.h"
+
+#include "protos/perfetto/trace/ftrace/ftrace_event_bundle.pbzero.h"
+
+namespace perfetto::trace_redaction {
+
+base::Status CollectSystemInfo::Begin(Context* context) const {
+  // Other primitives are allows to push more data into the system info (e.g.
+  // another source of pids).
+  if (!context->system_info.has_value()) {
+    context->system_info.emplace();
+  }
+
+  return base::OkStatus();
+}
+
+base::Status CollectSystemInfo::Collect(
+    const protos::pbzero::TracePacket::Decoder& packet,
+    Context* context) const {
+  auto* system_info = &context->system_info.value();
+
+  if (!packet.has_ftrace_events()) {
+    return base::OkStatus();
+  }
+
+  protozero::ProtoDecoder decoder(packet.ftrace_events());
+
+  auto field =
+      decoder.FindField(protos::pbzero::FtraceEventBundle::kCpuFieldNumber);
+
+  if (field.valid()) {
+    system_info->ReserveCpu(field.as_uint32());
+  }
+
+  return base::OkStatus();
+}
+
+base::Status BuildSyntheticThreads::Build(Context* context) const {
+  if (!context->system_info.has_value()) {
+    return base::ErrStatus("BuildThreadMap: missing system info.");
+  }
+
+  if (context->synthetic_threads.has_value()) {
+    return base::ErrStatus(
+        "BuildThreadMap: synthetic threads were already initialized.");
+  }
+
+  auto& system_info = context->system_info.value();
+  auto& synthetic_threads = context->synthetic_threads.emplace();
+
+  auto cpu_count = system_info.last_cpu() + 1;
+
+  synthetic_threads.tgid = system_info.AllocateSynthThread();
+  synthetic_threads.tids.resize(cpu_count);
+
+  for (uint32_t cpu = 0; cpu < cpu_count; ++cpu) {
+    synthetic_threads.tids[cpu] = system_info.AllocateSynthThread();
+  }
+
+  return base::OkStatus();
+}
+
+}  // namespace perfetto::trace_redaction
diff --git a/src/trace_redaction/collect_system_info.h b/src/trace_redaction/collect_system_info.h
new file mode 100644
index 0000000..1dec21c
--- /dev/null
+++ b/src/trace_redaction/collect_system_info.h
@@ -0,0 +1,46 @@
+/*
+ * 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.
+ */
+
+#ifndef SRC_TRACE_REDACTION_COLLECT_SYSTEM_INFO_H_
+#define SRC_TRACE_REDACTION_COLLECT_SYSTEM_INFO_H_
+
+#include "src/trace_redaction/trace_redaction_framework.h"
+
+namespace perfetto::trace_redaction {
+
+// Collects system info (e.g. tids and cpu info). These will provide the raw
+// material needed by BuildThreadMap.
+class CollectSystemInfo : public CollectPrimitive {
+ public:
+  base::Status Begin(Context*) const override;
+
+  base::Status Collect(const protos::pbzero::TracePacket::Decoder&,
+                       Context*) const override;
+};
+
+// Condenses system info into a query-focuesed structure, making it possible to
+// replace a thread with a synthetic thread.
+//
+// This is done here, and not in CollectSystemInfo::End, so that other collect
+// primitives can report additional system information.
+class BuildSyntheticThreads : public BuildPrimitive {
+ public:
+  base::Status Build(Context* context) const override;
+};
+
+}  // namespace perfetto::trace_redaction
+
+#endif  // SRC_TRACE_REDACTION_COLLECT_SYSTEM_INFO_H_
diff --git a/src/trace_redaction/collect_system_info_unittest.cc b/src/trace_redaction/collect_system_info_unittest.cc
new file mode 100644
index 0000000..bf0ced2
--- /dev/null
+++ b/src/trace_redaction/collect_system_info_unittest.cc
@@ -0,0 +1,97 @@
+/*
+ * 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.
+ */
+
+#include "src/trace_redaction/collect_system_info.h"
+#include "src/base/test/status_matchers.h"
+#include "src/trace_processor/util/status_macros.h"
+#include "test/gtest_and_gmock.h"
+
+#include "protos/perfetto/trace/ftrace/ftrace_event.gen.h"
+#include "protos/perfetto/trace/ftrace/ftrace_event_bundle.gen.h"
+#include "protos/perfetto/trace/ftrace/sched.gen.h"
+#include "protos/perfetto/trace/trace_packet.gen.h"
+
+namespace perfetto::trace_redaction {
+
+class CollectSystemInfoTest : public testing::Test {
+ protected:
+  base::Status Collect() {
+    auto buffer = packet_.SerializeAsString();
+    protos::pbzero::TracePacket::Decoder decoder(buffer);
+
+    RETURN_IF_ERROR(collect_.Begin(&context_));
+    RETURN_IF_ERROR(collect_.Collect(decoder, &context_));
+    return collect_.End(&context_);
+  }
+
+  void AppendFtraceEvent(uint32_t event_cpu, uint32_t pid) {
+    auto* events = packet_.mutable_ftrace_events();
+    events->set_cpu(event_cpu);
+
+    auto* event = events->add_event();
+    event->set_pid(pid);
+  }
+
+  void AppendSchedSwitch(int32_t next_pid) {
+    auto& event = packet_.mutable_ftrace_events()->mutable_event()->back();
+
+    auto* sched_switch = event.mutable_sched_switch();
+    sched_switch->set_prev_pid(static_cast<int32_t>(event.pid()));
+    sched_switch->set_next_pid(next_pid);
+  }
+
+  protos::gen::TracePacket packet_;
+  Context context_;
+  CollectSystemInfo collect_;
+};
+
+TEST_F(CollectSystemInfoTest, UpdatesCpuCountUsingFtraceEvents) {
+  AppendFtraceEvent(7, 8);
+  AppendSchedSwitch(9);
+
+  ASSERT_OK(Collect());
+  ASSERT_EQ(context_.system_info->last_cpu(), 7u);
+
+  AppendFtraceEvent(11, 8);
+  AppendSchedSwitch(9);
+
+  ASSERT_OK(Collect());
+  ASSERT_EQ(context_.system_info->last_cpu(), 11u);
+}
+
+// The first synth thread pid should be beyond the range of valid pids.
+TEST(SystemInfoTest, FirstSynthThreadPidIsNotAValidPid) {
+  SystemInfo info;
+
+  auto pid = info.AllocateSynthThread();
+  ASSERT_GT(pid, 1 << 22);
+}
+
+TEST(BuildSyntheticThreadsTest, CreatesThreadsPerCpu) {
+  Context context;
+  context.system_info.emplace();
+
+  // The first CPU is always 0, so CPU 7 means there are 8 CPUs.
+  context.system_info->ReserveCpu(7);
+
+  BuildSyntheticThreads build;
+  ASSERT_OK(build.Build(&context));
+
+  ASSERT_NE(context.synthetic_threads->tgid, 0);
+  ASSERT_EQ(context.synthetic_threads->tids.size(), 8u);
+}
+
+}  // namespace perfetto::trace_redaction
diff --git a/src/trace_redaction/main.cc b/src/trace_redaction/main.cc
index 2e433c0..04cfa1e 100644
--- a/src/trace_redaction/main.cc
+++ b/src/trace_redaction/main.cc
@@ -17,6 +17,7 @@
 #include "perfetto/base/logging.h"
 #include "perfetto/base/status.h"
 #include "src/trace_redaction/collect_frame_cookies.h"
+#include "src/trace_redaction/collect_system_info.h"
 #include "src/trace_redaction/collect_timeline_events.h"
 #include "src/trace_redaction/filter_ftrace_using_allowlist.h"
 #include "src/trace_redaction/filter_packet_using_allowlist.h"
@@ -51,12 +52,14 @@
   redactor.emplace_collect<FindPackageUid>();
   redactor.emplace_collect<CollectTimelineEvents>();
   redactor.emplace_collect<CollectFrameCookies>();
+  redactor.emplace_collect<CollectSystemInfo>();
 
   // Add all builders.
   redactor.emplace_build<PopulateAllowlists>();
   redactor.emplace_build<AllowSuspendResume>();
   redactor.emplace_build<OptimizeTimeline>();
   redactor.emplace_build<ReduceFrameCookies>();
+  redactor.emplace_build<BuildSyntheticThreads>();
 
   // Add all transforms.
   auto* scrub_packet = redactor.emplace_transform<ScrubTracePacket>();
@@ -78,9 +81,12 @@
   redactor.emplace_transform<ScrubProcessStats>();
 
   auto* redact_ftrace_events = redactor.emplace_transform<RedactFtraceEvent>();
-  redact_ftrace_events->emplace_back<RedactSchedSwitch>();
-  redact_ftrace_events->emplace_back<RedactTaskNewTask>();
-  redact_ftrace_events->emplace_back<RedactProcessFree>();
+  redact_ftrace_events
+      ->emplace_back<RedactSchedSwitch::kFieldId, RedactSchedSwitch>();
+  redact_ftrace_events
+      ->emplace_back<RedactTaskNewTask::kFieldId, RedactTaskNewTask>();
+  redact_ftrace_events
+      ->emplace_back<RedactProcessFree::kFieldId, RedactProcessFree>();
 
   Context context;
   context.package_name = package_name;
diff --git a/src/trace_redaction/redact_ftrace_event.cc b/src/trace_redaction/redact_ftrace_event.cc
index 32cacda..6c09236 100644
--- a/src/trace_redaction/redact_ftrace_event.cc
+++ b/src/trace_redaction/redact_ftrace_event.cc
@@ -85,26 +85,14 @@
 
   for (auto field = event.ReadField(); field.valid();
        field = event.ReadField()) {
-    auto mod = FindRedactionFor(field.id());
+    auto* mod = redactions_.Find(field.id());
 
-    if (mod) {
+    if (mod && mod->get()) {
       protos::pbzero::FtraceEvent::Decoder event_decoder(bytes);
-      mod->Redact(context, event_decoder, field.as_bytes(), message);
+      mod->get()->Redact(context, event_decoder, field.as_bytes(), message);
     } else {
       proto_util::AppendField(field, message);
     }
   }
 }
-
-const FtraceEventRedaction* RedactFtraceEvent::FindRedactionFor(
-    uint32_t i) const {
-  for (const auto& modification : redactions_) {
-    if (modification->field_id() == i) {
-      return modification.get();
-    }
-  }
-
-  return nullptr;
-}
-
 }  // namespace perfetto::trace_redaction
diff --git a/src/trace_redaction/redact_ftrace_event.h b/src/trace_redaction/redact_ftrace_event.h
index 730ed9d..fe282cf 100644
--- a/src/trace_redaction/redact_ftrace_event.h
+++ b/src/trace_redaction/redact_ftrace_event.h
@@ -19,6 +19,7 @@
 
 #include <cstdint>
 
+#include "perfetto/ext/base/flat_hash_map.h"
 #include "src/trace_redaction/trace_redaction_framework.h"
 
 #include "protos/perfetto/trace/ftrace/ftrace_event.pbzero.h"
@@ -30,21 +31,14 @@
 // event in the trace.
 class FtraceEventRedaction {
  public:
-  explicit FtraceEventRedaction(uint32_t field_id) : field_id_(field_id) {}
-
   virtual ~FtraceEventRedaction();
 
-  uint32_t field_id() const { return field_id_; }
-
   // Write a new version of the event to the message.
   virtual base::Status Redact(
       const Context& context,
       const protos::pbzero::FtraceEvent::Decoder& event,
       protozero::ConstBytes bytes,
       protos::pbzero::FtraceEvent* event_message) const = 0;
-
- private:
-  uint32_t field_id_;
 };
 
 class RedactFtraceEvent : public TransformPrimitive {
@@ -53,9 +47,9 @@
                          std::string* packet) const override;
 
   // Add a new redaction. T must extend FtraceEventRedaction.
-  template <typename T>
+  template <uint32_t field_id, typename T>
   void emplace_back() {
-    redactions_.push_back(std::make_unique<T>());
+    redactions_.Insert(field_id, std::make_unique<T>());
   }
 
  private:
@@ -71,12 +65,8 @@
                    protozero::ConstBytes bytes,
                    protos::pbzero::FtraceEvent* message) const;
 
-  const FtraceEventRedaction* FindRedactionFor(uint32_t i) const;
-
-  // Each redactions supplies its own id. This list will be small. So
-  // iterating over the list checking the reported ids should be good enough.
-  // Ids must not collide.
-  std::vector<std::unique_ptr<FtraceEventRedaction>> redactions_;
+  base::FlatHashMap<uint32_t, std::unique_ptr<FtraceEventRedaction>>
+      redactions_;
 };
 
 }  // namespace perfetto::trace_redaction
diff --git a/src/trace_redaction/redact_process_free.cc b/src/trace_redaction/redact_process_free.cc
index 654b7b2..7294d68 100644
--- a/src/trace_redaction/redact_process_free.cc
+++ b/src/trace_redaction/redact_process_free.cc
@@ -42,33 +42,32 @@
 // The timeline treats "start" as inclusive and "end" as exclusive. This means
 // no pid will connect to the target package at a process free event. Because
 // of this, the timeline is not needed.
-RedactProcessFree::RedactProcessFree()
-    : FtraceEventRedaction(
-          protos::pbzero::FtraceEvent::kSchedProcessFreeFieldNumber) {}
-
 base::Status RedactProcessFree::Redact(
     const Context&,
     const protos::pbzero::FtraceEvent::Decoder&,
     protozero::ConstBytes bytes,
     protos::pbzero::FtraceEvent* event_message) const {
-  protos::pbzero::SchedProcessFreeFtraceEvent::Decoder process_free(bytes);
+  // SchedProcessFreeFtraceEvent
+  protozero::ProtoDecoder process_free_decoder(bytes);
 
-  // There must be pid. If there's no pid, dropping the event is the safest
-  // option.
-  if (!process_free.has_pid()) {
+  // There must be pid. If there's no pid, the safest option is to drop it.
+  auto pid = process_free_decoder.FindField(
+      protos::pbzero::SchedProcessFreeFtraceEvent::kPidFieldNumber);
+
+  if (!pid.valid()) {
     return base::OkStatus();
   }
 
-  // Avoid making the message until we know that we have prev and next pids.
   auto* process_free_message = event_message->set_sched_process_free();
 
-  // To read the fields, move the read head back to the start.
-  process_free.Reset();
-
-  for (auto field = process_free.ReadField(); field.valid();
-       field = process_free.ReadField()) {
-    if (field.id() !=
+  // Replace the comm with an empty string instead of dropping the comm field.
+  // The perfetto UI doesn't render things correctly if comm values are missing.
+  for (auto field = process_free_decoder.ReadField(); field.valid();
+       field = process_free_decoder.ReadField()) {
+    if (field.id() ==
         protos::pbzero::SchedProcessFreeFtraceEvent::kCommFieldNumber) {
+      process_free_message->set_comm("");
+    } else {
       proto_util::AppendField(field, process_free_message);
     }
   }
diff --git a/src/trace_redaction/redact_process_free.h b/src/trace_redaction/redact_process_free.h
index f14fee2..8d24153 100644
--- a/src/trace_redaction/redact_process_free.h
+++ b/src/trace_redaction/redact_process_free.h
@@ -26,7 +26,8 @@
 // process free events.
 class RedactProcessFree : public FtraceEventRedaction {
  public:
-  RedactProcessFree();
+  static constexpr auto kFieldId =
+      protos::pbzero::FtraceEvent::kSchedProcessFreeFieldNumber;
 
   base::Status Redact(
       const Context& context,
diff --git a/src/trace_redaction/redact_sched_switch.cc b/src/trace_redaction/redact_sched_switch.cc
index 55777f1..361993d 100644
--- a/src/trace_redaction/redact_sched_switch.cc
+++ b/src/trace_redaction/redact_sched_switch.cc
@@ -47,10 +47,6 @@
 // collection of ftrace event messages) because data in a sched_switch message
 // is needed in order to know if the event should be added to the bundle.
 
-RedactSchedSwitch::RedactSchedSwitch()
-    : FtraceEventRedaction(
-          protos::pbzero::FtraceEvent::kSchedSwitchFieldNumber) {}
-
 base::Status RedactSchedSwitch::Redact(
     const Context& context,
     const protos::pbzero::FtraceEvent::Decoder& event,
diff --git a/src/trace_redaction/redact_sched_switch.h b/src/trace_redaction/redact_sched_switch.h
index 7c154e3..e9d70cb 100644
--- a/src/trace_redaction/redact_sched_switch.h
+++ b/src/trace_redaction/redact_sched_switch.h
@@ -26,7 +26,8 @@
 // sched switch events.
 class RedactSchedSwitch : public FtraceEventRedaction {
  public:
-  RedactSchedSwitch();
+  static constexpr auto kFieldId =
+      protos::pbzero::FtraceEvent::kSchedSwitchFieldNumber;
 
   base::Status Redact(
       const Context& context,
diff --git a/src/trace_redaction/redact_sched_switch_integrationtest.cc b/src/trace_redaction/redact_sched_switch_integrationtest.cc
index 67948f2..85d7416 100644
--- a/src/trace_redaction/redact_sched_switch_integrationtest.cc
+++ b/src/trace_redaction/redact_sched_switch_integrationtest.cc
@@ -48,7 +48,8 @@
 
     auto* ftrace_event_redactions =
         trace_redactor()->emplace_transform<RedactFtraceEvent>();
-    ftrace_event_redactions->emplace_back<RedactSchedSwitch>();
+    ftrace_event_redactions
+        ->emplace_back<RedactSchedSwitch::kFieldId, RedactSchedSwitch>();
 
     context()->package_name = "com.Unity.com.unity.multiplayer.samples.coop";
   }
diff --git a/src/trace_redaction/redact_task_newtask.cc b/src/trace_redaction/redact_task_newtask.cc
index cdd34d7..cb6a41c 100644
--- a/src/trace_redaction/redact_task_newtask.cc
+++ b/src/trace_redaction/redact_task_newtask.cc
@@ -41,15 +41,6 @@
 //
 // In the above message, it should be noted that "event.pid" will never be
 // equal to "event.task_newtask.pid" (a thread cannot start itself).
-
-// TODO(vaage): How does this primitive (and others like it) work when we're
-// merging threads? Remame events are already dropped. New task and proces free
-// events won't matter the timeline is created. Can these events be dropped?
-
-RedactTaskNewTask::RedactTaskNewTask()
-    : FtraceEventRedaction(
-          protos::pbzero::FtraceEvent::kTaskNewtaskFieldNumber) {}
-
 base::Status RedactTaskNewTask::Redact(
     const Context& context,
     const protos::pbzero::FtraceEvent::Decoder& event,
diff --git a/src/trace_redaction/redact_task_newtask.h b/src/trace_redaction/redact_task_newtask.h
index b4fd830..51d384c 100644
--- a/src/trace_redaction/redact_task_newtask.h
+++ b/src/trace_redaction/redact_task_newtask.h
@@ -26,7 +26,8 @@
 // task_newtask events.
 class RedactTaskNewTask : public FtraceEventRedaction {
  public:
-  RedactTaskNewTask();
+  static constexpr auto kFieldId =
+      protos::pbzero::FtraceEvent::kTaskNewtaskFieldNumber;
 
   base::Status Redact(
       const Context& context,
diff --git a/src/trace_redaction/trace_redaction_framework.h b/src/trace_redaction/trace_redaction_framework.h
index cc135a9..12f6eb2 100644
--- a/src/trace_redaction/trace_redaction_framework.h
+++ b/src/trace_redaction/trace_redaction_framework.h
@@ -40,6 +40,59 @@
   return uid % 1000000;
 }
 
+class SystemInfo {
+ public:
+  int32_t AllocateSynthThread() {
+    return (1 << kSynthShift) | (++next_synth_thread_);
+  }
+
+  uint32_t ReserveCpu(uint32_t cpu) {
+    last_cpu_ = std::max(last_cpu_, cpu);
+    return last_cpu_;
+  }
+
+  uint32_t last_cpu() const { return last_cpu_; }
+
+ private:
+  // This is the last allocated tid. Using a tid equal to or less than this tid
+  // risks a collision with another tid. If a tid is ever created (by a
+  // primitive) this should be advanced to the max between this value and the
+  // new tid.
+  //
+  // On a 64 bit machine, the max pid limit is 2^22 (approximately 4 million).
+  // Perfetto uses a 32 (signed) int for the pid. Even in this case, there is
+  // room for 2^9 synthetic threads (2 ^ (31 - 22) = 2 ^ 9).
+  //
+  // Futhermore, ther Android source code return 4194304 (2 ^ 22) on 64 bit
+  // devices.
+  //
+  //  /proc/sys/kernel/pid_max (since Linux 2.5.34)
+  //      This file specifies the value at which PIDs wrap around
+  //      (i.e., the value in this file is one greater than the
+  //      maximum PID).  PIDs greater than this value are not
+  //      allocated; thus, the value in this file also acts as a
+  //      system-wide limit on the total number of processes and
+  //      threads.  The default value for this file, 32768, results
+  //      in the same range of PIDs as on earlier kernels.  On
+  //      32-bit platforms, 32768 is the maximum value for pid_max.
+  //      On 64-bit systems, pid_max can be set to any value up to
+  //      2^22 (PID_MAX_LIMIT, approximately 4 million).
+  //
+  // SOURCE: https://man7.org/linux/man-pages/man5/proc.5.html
+  static constexpr auto kSynthShift = 22;
+  int32_t next_synth_thread_ = 0;
+
+  // The last CPU index seen. If this value is 7, it means there are at least
+  // 8 CPUs.
+  uint32_t last_cpu_ = 0;
+};
+
+class SyntheticThreadGroup {
+ public:
+  int32_t tgid;
+  std::vector<int32_t> tids;
+};
+
 // Primitives should be stateless. All state should be stored in the context.
 // Primitives should depend on data in the context, not the origin of the data.
 // This allows primitives to be swapped out or work together to populate data
@@ -217,6 +270,10 @@
   // values are unique within the scope of the trace, pid and time are no longer
   // needed and a set can be used for faster queries.
   std::unordered_set<int64_t> package_frame_cookies;
+
+  std::optional<SystemInfo> system_info;
+
+  std::optional<SyntheticThreadGroup> synthetic_threads;
 };
 
 // Extracts low-level data from the trace and writes it into the context. The
diff --git a/test/data/api32_startup_warm.perfetto-trace.sha256 b/test/data/api32_startup_warm.perfetto-trace.sha256
new file mode 100644
index 0000000..f83dd34
--- /dev/null
+++ b/test/data/api32_startup_warm.perfetto-trace.sha256
@@ -0,0 +1 @@
+776122b5660c5d6e738950031fdb4992a64e3224e9e82bbaed474a0a281ed7e3
\ No newline at end of file
diff --git a/test/data/api34_startup_cold.perfetto-trace.sha256 b/test/data/api34_startup_cold.perfetto-trace.sha256
new file mode 100644
index 0000000..2a7a044
--- /dev/null
+++ b/test/data/api34_startup_cold.perfetto-trace.sha256
@@ -0,0 +1 @@
+1958521dc5128cd4eadd1df281e19987aded718750e6883f82ffd3b5eb529bd6
\ No newline at end of file
diff --git a/test/data/ui-screenshots/ui-android_trace_30s_expand_camera.png.sha256 b/test/data/ui-screenshots/ui-android_trace_30s_expand_camera.png.sha256
index 6c9da12..ae1eb86 100644
--- a/test/data/ui-screenshots/ui-android_trace_30s_expand_camera.png.sha256
+++ b/test/data/ui-screenshots/ui-android_trace_30s_expand_camera.png.sha256
@@ -1 +1 @@
-3b1182b4e24fa80004c5bcec56c2cc9b4814ed9da7266fe54fafde4191ab3b4f
\ No newline at end of file
+27883a40c19205b1de4f9b87dfc22f4039986330074a7955e4432a045b657314
\ No newline at end of file
diff --git a/test/data/ui-screenshots/ui-android_trace_30s_load.png.sha256 b/test/data/ui-screenshots/ui-android_trace_30s_load.png.sha256
index 4a77c81..aa136cd 100644
--- a/test/data/ui-screenshots/ui-android_trace_30s_load.png.sha256
+++ b/test/data/ui-screenshots/ui-android_trace_30s_load.png.sha256
@@ -1 +1 @@
-07bcd60e3622fd39e0270962294696c7280c3cc45997837ae136ea1da0f8d094
\ No newline at end of file
+b2c625bbdeb611de9a4bfbc3497353ee91322410bb867eee603bf2635353a21f
\ No newline at end of file
diff --git a/test/data/ui-screenshots/ui-chrome_missing_track_names_load.png.sha256 b/test/data/ui-screenshots/ui-chrome_missing_track_names_load.png.sha256
index ce2d330..81c36d8 100644
--- a/test/data/ui-screenshots/ui-chrome_missing_track_names_load.png.sha256
+++ b/test/data/ui-screenshots/ui-chrome_missing_track_names_load.png.sha256
@@ -1 +1 @@
-ce747a5d7a1547aaa238bef8aa7b241d3e16cdc38dd34ef165e57aecb6659a92
\ No newline at end of file
+ff45b08442a6f59a4768f3ad697cffb43d19889f1f6523d5ed4ef83aff944864
\ No newline at end of file
diff --git a/test/data/ui-screenshots/ui-chrome_rendering_desktop_expand_browser_proc.png.sha256 b/test/data/ui-screenshots/ui-chrome_rendering_desktop_expand_browser_proc.png.sha256
index c8cc93d..4d7b25a 100644
--- a/test/data/ui-screenshots/ui-chrome_rendering_desktop_expand_browser_proc.png.sha256
+++ b/test/data/ui-screenshots/ui-chrome_rendering_desktop_expand_browser_proc.png.sha256
@@ -1 +1 @@
-193a54d8a03494937a21ecd2210b926a73e43f3d9617d622328e9a45cd32b017
\ No newline at end of file
+161c05f6bae9b0e2142e73e83f3bcf7a9c689c58411db9e345a63ba882605557
\ No newline at end of file
diff --git a/test/data/ui-screenshots/ui-chrome_rendering_desktop_load.png.sha256 b/test/data/ui-screenshots/ui-chrome_rendering_desktop_load.png.sha256
index de5d8b4..2d48761 100644
--- a/test/data/ui-screenshots/ui-chrome_rendering_desktop_load.png.sha256
+++ b/test/data/ui-screenshots/ui-chrome_rendering_desktop_load.png.sha256
@@ -1 +1 @@
-b1cbf99aeee1bab2eabe0d1495697970535ef350f3d6e6d177ed21d469f3b11e
\ No newline at end of file
+c0bf63de1c6c738fb994d64e5f2f7c5c0e4315f8627fc5fd68f5b906acbb814d
\ No newline at end of file
diff --git a/test/data/ui-screenshots/ui-chrome_rendering_desktop_select_slice_with_flows.png.sha256 b/test/data/ui-screenshots/ui-chrome_rendering_desktop_select_slice_with_flows.png.sha256
index 0843823..2ced23b 100644
--- a/test/data/ui-screenshots/ui-chrome_rendering_desktop_select_slice_with_flows.png.sha256
+++ b/test/data/ui-screenshots/ui-chrome_rendering_desktop_select_slice_with_flows.png.sha256
@@ -1 +1 @@
-562482bc25d7a2d34b30d6003d100207e7f1eb0a48ea4549445a340e34a2e3b4
\ No newline at end of file
+0b5b8abecbbef18f25cbe0ddbfc7a8ff5e4aefb0c1ad07871cd72dc69d2647c9
\ No newline at end of file
diff --git a/test/data/ui-screenshots/ui-modal_dialog_dismiss_1.png.sha256 b/test/data/ui-screenshots/ui-modal_dialog_dismiss_1.png.sha256
index 9c06aaa..e9fa1e3 100644
--- a/test/data/ui-screenshots/ui-modal_dialog_dismiss_1.png.sha256
+++ b/test/data/ui-screenshots/ui-modal_dialog_dismiss_1.png.sha256
@@ -1 +1 @@
-554b36f8fd3cf77c4e8e08fb53664dd1c5b54e2009045b513363c5d9ba861da9
\ No newline at end of file
+14056ade87692d8ec27ad4eb6c0c4fe55a14f87682b26af5acd829388326ed1b
\ No newline at end of file
diff --git a/test/data/ui-screenshots/ui-modal_dialog_dismiss_2.png.sha256 b/test/data/ui-screenshots/ui-modal_dialog_dismiss_2.png.sha256
index 0f29bad..0470eab 100644
--- a/test/data/ui-screenshots/ui-modal_dialog_dismiss_2.png.sha256
+++ b/test/data/ui-screenshots/ui-modal_dialog_dismiss_2.png.sha256
@@ -1 +1 @@
-c7bb5ef50e96b3bdd96d684cbc65025e60ff9dd3281b220e4b99e07c2a896afe
\ No newline at end of file
+ee1bbe38698683159bc2d51940ebda03e22f766302c26f71d9792fbd68f71fd7
\ No newline at end of file
diff --git a/test/data/ui-screenshots/ui-modal_dialog_show_dialog_1.png.sha256 b/test/data/ui-screenshots/ui-modal_dialog_show_dialog_1.png.sha256
index ac18e72..ace70f0 100644
--- a/test/data/ui-screenshots/ui-modal_dialog_show_dialog_1.png.sha256
+++ b/test/data/ui-screenshots/ui-modal_dialog_show_dialog_1.png.sha256
@@ -1 +1 @@
-99c00e663442fd3ca7913b5ce7bea350af82f5ec8422d16f49175c91d484b52a
\ No newline at end of file
+84f915ea5ab8d247a451de6844c66af348889f8ae789f8378b52660745f78ad2
\ No newline at end of file
diff --git a/test/data/ui-screenshots/ui-modal_dialog_show_dialog_2.png.sha256 b/test/data/ui-screenshots/ui-modal_dialog_show_dialog_2.png.sha256
index ff1df49..8bf2a53 100644
--- a/test/data/ui-screenshots/ui-modal_dialog_show_dialog_2.png.sha256
+++ b/test/data/ui-screenshots/ui-modal_dialog_show_dialog_2.png.sha256
@@ -1 +1 @@
-f345cfd5f9f2c5922d15c691dbe76d5d50ccfecdf35f2fd0033c1462299b0d17
\ No newline at end of file
+6906847e636f09c0d8476074392e6d79de745bed39adc79624a1a305477ad09b
\ No newline at end of file
diff --git a/test/data/ui-screenshots/ui-modal_dialog_switch_page_no_dialog.png.sha256 b/test/data/ui-screenshots/ui-modal_dialog_switch_page_no_dialog.png.sha256
index 58840ee..4b545db 100644
--- a/test/data/ui-screenshots/ui-modal_dialog_switch_page_no_dialog.png.sha256
+++ b/test/data/ui-screenshots/ui-modal_dialog_switch_page_no_dialog.png.sha256
@@ -1 +1 @@
-b9146d41d92c3e164adb9ada4ed0b384f225220ef458a70c152a14c6bd29aef3
\ No newline at end of file
+d0a10b3fa63f100b16b8a595e805485cbb7b2ac7a7b25cc68024a726f4e69e6a
\ No newline at end of file
diff --git a/test/data/ui-screenshots/ui-routing_navigate_navigate_back_and_forward.png.sha256 b/test/data/ui-screenshots/ui-routing_navigate_navigate_back_and_forward.png.sha256
index be09ae5..423413f 100644
--- a/test/data/ui-screenshots/ui-routing_navigate_navigate_back_and_forward.png.sha256
+++ b/test/data/ui-screenshots/ui-routing_navigate_navigate_back_and_forward.png.sha256
@@ -1 +1 @@
-b9f04ac7c1d9bc25023a22bd2e6d79c48cf44ac62da30db9454f3e367dc9a824
\ No newline at end of file
+a38e589b73daa4c8b1eb6fe5a592bc85fdd00139e7278e7289aed0d0c2a0b048
\ No newline at end of file
diff --git a/test/data/ui-screenshots/ui-routing_navigate_open_trace_from_url.png.sha256 b/test/data/ui-screenshots/ui-routing_navigate_open_trace_from_url.png.sha256
index 24ab368..1c8ee20 100644
--- a/test/data/ui-screenshots/ui-routing_navigate_open_trace_from_url.png.sha256
+++ b/test/data/ui-screenshots/ui-routing_navigate_open_trace_from_url.png.sha256
@@ -1 +1 @@
-f9b1725cd859b6cccf34d171e8b2e9d87b44a4fc241e9f577ccfde2ae3cc5216
\ No newline at end of file
+f7350a1ea142f50c7f8847cffa2ca1ec89faac07a57660b8b20f2d0d068f5747
\ No newline at end of file
diff --git a/test/data/ui-screenshots/ui-routing_open_invalid_trace_from_blank_page.png.sha256 b/test/data/ui-screenshots/ui-routing_open_invalid_trace_from_blank_page.png.sha256
index fa3e082..bf40698 100644
--- a/test/data/ui-screenshots/ui-routing_open_invalid_trace_from_blank_page.png.sha256
+++ b/test/data/ui-screenshots/ui-routing_open_invalid_trace_from_blank_page.png.sha256
@@ -1 +1 @@
-82b386830bbfc6287b893331ecffcb1058634868a1bd43cce240b09297f060c7
\ No newline at end of file
+7031a1c1d49c9b953bd177ce268460d3702574b6d8b57542f9896c3cbcb91cac
\ No newline at end of file
diff --git a/test/data/ui-screenshots/ui-routing_open_trace_and_go_back_to_landing_page.png.sha256 b/test/data/ui-screenshots/ui-routing_open_trace_and_go_back_to_landing_page.png.sha256
index 737aa22..5b4b6e8 100644
--- a/test/data/ui-screenshots/ui-routing_open_trace_and_go_back_to_landing_page.png.sha256
+++ b/test/data/ui-screenshots/ui-routing_open_trace_and_go_back_to_landing_page.png.sha256
@@ -1 +1 @@
-148a4d82304842f610caa722e4cfc124eaa9e1da7b0ec1a1c0aeb790e03700c9
\ No newline at end of file
+bde6b98b6bf32e479277e34bbb410e08bf05840f6e34959c545ced0592f1f4c5
\ No newline at end of file
diff --git a/test/data/ui-screenshots/ui-routing_open_two_traces_then_go_back_access_subpage_then_go_back.png.sha256 b/test/data/ui-screenshots/ui-routing_open_two_traces_then_go_back_access_subpage_then_go_back.png.sha256
index 7b76fd1..c703153 100644
--- a/test/data/ui-screenshots/ui-routing_open_two_traces_then_go_back_access_subpage_then_go_back.png.sha256
+++ b/test/data/ui-screenshots/ui-routing_open_two_traces_then_go_back_access_subpage_then_go_back.png.sha256
@@ -1 +1 @@
-431b27cc6333c21aac4bf681ab9d7f06e096f340f7913f1c1e3a943d4c4f0111
\ No newline at end of file
+4a24fb909c6346d5419905fc0a7ff69b9dda00fe77db6b953497d47f4dbe4149
\ No newline at end of file
diff --git a/test/data/ui-screenshots/ui-routing_open_two_traces_then_go_back_open_first_trace_from_url.png.sha256 b/test/data/ui-screenshots/ui-routing_open_two_traces_then_go_back_open_first_trace_from_url.png.sha256
index 24ab368..1c8ee20 100644
--- a/test/data/ui-screenshots/ui-routing_open_two_traces_then_go_back_open_first_trace_from_url.png.sha256
+++ b/test/data/ui-screenshots/ui-routing_open_two_traces_then_go_back_open_first_trace_from_url.png.sha256
@@ -1 +1 @@
-f9b1725cd859b6cccf34d171e8b2e9d87b44a4fc241e9f577ccfde2ae3cc5216
\ No newline at end of file
+f7350a1ea142f50c7f8847cffa2ca1ec89faac07a57660b8b20f2d0d068f5747
\ No newline at end of file
diff --git a/test/data/ui-screenshots/ui-routing_open_two_traces_then_go_back_open_second_trace_from_url.png.sha256 b/test/data/ui-screenshots/ui-routing_open_two_traces_then_go_back_open_second_trace_from_url.png.sha256
index 7b76fd1..c703153 100644
--- a/test/data/ui-screenshots/ui-routing_open_two_traces_then_go_back_open_second_trace_from_url.png.sha256
+++ b/test/data/ui-screenshots/ui-routing_open_two_traces_then_go_back_open_second_trace_from_url.png.sha256
@@ -1 +1 @@
-431b27cc6333c21aac4bf681ab9d7f06e096f340f7913f1c1e3a943d4c4f0111
\ No newline at end of file
+4a24fb909c6346d5419905fc0a7ff69b9dda00fe77db6b953497d47f4dbe4149
\ No newline at end of file
diff --git a/test/data/ui-screenshots/ui-routing_start_from_no_trace_go_back_to_first_trace.png.sha256 b/test/data/ui-screenshots/ui-routing_start_from_no_trace_go_back_to_first_trace.png.sha256
index 7b76fd1..c703153 100644
--- a/test/data/ui-screenshots/ui-routing_start_from_no_trace_go_back_to_first_trace.png.sha256
+++ b/test/data/ui-screenshots/ui-routing_start_from_no_trace_go_back_to_first_trace.png.sha256
@@ -1 +1 @@
-431b27cc6333c21aac4bf681ab9d7f06e096f340f7913f1c1e3a943d4c4f0111
\ No newline at end of file
+4a24fb909c6346d5419905fc0a7ff69b9dda00fe77db6b953497d47f4dbe4149
\ No newline at end of file
diff --git a/test/data/ui-screenshots/ui-routing_start_from_no_trace_go_to_page_with_no_trace.png.sha256 b/test/data/ui-screenshots/ui-routing_start_from_no_trace_go_to_page_with_no_trace.png.sha256
index 0aacaa4..517d3eb 100644
--- a/test/data/ui-screenshots/ui-routing_start_from_no_trace_go_to_page_with_no_trace.png.sha256
+++ b/test/data/ui-screenshots/ui-routing_start_from_no_trace_go_to_page_with_no_trace.png.sha256
@@ -1 +1 @@
-539226bd5412f6f573473f91ae2fc6edda026744c143bdc4b027a069094de96f
\ No newline at end of file
+2ce30cc545efe24f68a1b6efe7dd27971f6b40c777f08098f4d68c8e861f843f
\ No newline at end of file
diff --git a/test/data/ui-screenshots/ui-routing_start_from_no_trace_open_invalid_trace.png.sha256 b/test/data/ui-screenshots/ui-routing_start_from_no_trace_open_invalid_trace.png.sha256
index a902763..98854c1 100644
--- a/test/data/ui-screenshots/ui-routing_start_from_no_trace_open_invalid_trace.png.sha256
+++ b/test/data/ui-screenshots/ui-routing_start_from_no_trace_open_invalid_trace.png.sha256
@@ -1 +1 @@
-eeb0625eb5286e0ca40b5d308608714ae7da0ca67d6bf07fac314a6639b5ddb5
\ No newline at end of file
+c662b150f085a83819bb6250c63095ace7c2d8fe0c44b9a7b16a0ed633177955
\ No newline at end of file
diff --git a/test/data/ui-screenshots/ui-routing_start_from_no_trace_open_second_trace.png.sha256 b/test/data/ui-screenshots/ui-routing_start_from_no_trace_open_second_trace.png.sha256
index 24ab368..1c8ee20 100644
--- a/test/data/ui-screenshots/ui-routing_start_from_no_trace_open_second_trace.png.sha256
+++ b/test/data/ui-screenshots/ui-routing_start_from_no_trace_open_second_trace.png.sha256
@@ -1 +1 @@
-f9b1725cd859b6cccf34d171e8b2e9d87b44a4fc241e9f577ccfde2ae3cc5216
\ No newline at end of file
+f7350a1ea142f50c7f8847cffa2ca1ec89faac07a57660b8b20f2d0d068f5747
\ No newline at end of file
diff --git a/test/data/ui-screenshots/ui-routing_start_from_no_trace_open_trace_.png.sha256 b/test/data/ui-screenshots/ui-routing_start_from_no_trace_open_trace_.png.sha256
index 7b76fd1..c703153 100644
--- a/test/data/ui-screenshots/ui-routing_start_from_no_trace_open_trace_.png.sha256
+++ b/test/data/ui-screenshots/ui-routing_start_from_no_trace_open_trace_.png.sha256
@@ -1 +1 @@
-431b27cc6333c21aac4bf681ab9d7f06e096f340f7913f1c1e3a943d4c4f0111
\ No newline at end of file
+4a24fb909c6346d5419905fc0a7ff69b9dda00fe77db6b953497d47f4dbe4149
\ No newline at end of file
diff --git a/test/data/ui-screenshots/ui-routing_start_from_no_trace_refresh.png.sha256 b/test/data/ui-screenshots/ui-routing_start_from_no_trace_refresh.png.sha256
index 7b76fd1..c703153 100644
--- a/test/data/ui-screenshots/ui-routing_start_from_no_trace_refresh.png.sha256
+++ b/test/data/ui-screenshots/ui-routing_start_from_no_trace_refresh.png.sha256
@@ -1 +1 @@
-431b27cc6333c21aac4bf681ab9d7f06e096f340f7913f1c1e3a943d4c4f0111
\ No newline at end of file
+4a24fb909c6346d5419905fc0a7ff69b9dda00fe77db6b953497d47f4dbe4149
\ No newline at end of file
diff --git a/ui/package.json b/ui/package.json
index 9dea540..8f1e15d 100644
--- a/ui/package.json
+++ b/ui/package.json
@@ -61,7 +61,7 @@
     "pixelmatch": "^5.3.0",
     "pngjs": "^6.0.0",
     "prettier": "^2.8.8",
-    "puppeteer": "^20.7.3",
+    "puppeteer": "^22.6.0",
     "rollup": "^2.79.1",
     "rollup-plugin-re": "^1.0.7",
     "rollup-plugin-sourcemaps": "^0.6.3",
diff --git a/ui/pnpm-lock.yaml b/ui/pnpm-lock.yaml
index d1e5756..e2beb57 100644
--- a/ui/pnpm-lock.yaml
+++ b/ui/pnpm-lock.yaml
@@ -164,8 +164,8 @@
     specifier: ^2.8.8
     version: 2.8.8
   puppeteer:
-    specifier: ^20.7.3
-    version: 20.7.3(typescript@5.0.4)
+    specifier: ^22.6.0
+    version: 22.6.0(typescript@5.0.4)
   rollup:
     specifier: ^2.79.1
     version: 2.79.1
@@ -1019,24 +1019,19 @@
     resolution: {integrity: sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==}
     dev: false
 
-  /@puppeteer/browsers@1.4.2(typescript@5.0.4):
-    resolution: {integrity: sha512-5MLU1RFaJh1Beb9FH6raowtZErcsZ0ojYJvdG3OWXfnc3wZiDAa0PgXU2QOKtbW2S+Z731K/2n3YczGA3KbLbQ==}
-    engines: {node: '>=16.3.0'}
+  /@puppeteer/browsers@2.2.0:
+    resolution: {integrity: sha512-MC7LxpcBtdfTbzwARXIkqGZ1Osn3nnZJlm+i0+VqHl72t//Xwl9wICrXT8BwtgC6s1xJNHsxOpvzISUqe92+sw==}
+    engines: {node: '>=18'}
     hasBin: true
-    peerDependencies:
-      typescript: '>= 4.7.4'
-    peerDependenciesMeta:
-      typescript:
-        optional: true
     dependencies:
       debug: 4.3.4
       extract-zip: 2.0.1
       progress: 2.0.3
-      proxy-agent: 6.2.1
-      tar-fs: 3.0.2
-      typescript: 5.0.4
+      proxy-agent: 6.4.0
+      semver: 7.6.0
+      tar-fs: 3.0.5
       unbzip2-stream: 1.4.3
-      yargs: 17.7.1
+      yargs: 17.7.2
     transitivePeerDependencies:
       - supports-color
     dev: true
@@ -1121,6 +1116,10 @@
     engines: {node: '>= 6'}
     dev: true
 
+  /@tootallnate/quickjs-emscripten@0.23.0:
+    resolution: {integrity: sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==}
+    dev: true
+
   /@types/babel__core@7.20.1:
     resolution: {integrity: sha512-aACu/U/omhdk15O4Nfb+fHgH/z3QsfQzpnvRZhYhThms83ZnAOZz7zZAWO7mn2yyNQaA4xTO8GLK3uqFU4bYYw==}
     dependencies:
@@ -1466,11 +1465,6 @@
     engines: {node: '>=0.4.0'}
     dev: true
 
-  /acorn-walk@8.2.0:
-    resolution: {integrity: sha512-k+iyHEuPgSw6SbuDpGQM+06HQUa04DZ3o+F6CSzXMvvI5KMvnaEqXe+YVe555R9nn6GPt404fos4wcgpw12SDA==}
-    engines: {node: '>=0.4.0'}
-    dev: true
-
   /acorn@7.4.1:
     resolution: {integrity: sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==}
     engines: {node: '>=0.4.0'}
@@ -1690,6 +1684,37 @@
   /balanced-match@1.0.2:
     resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
 
+  /bare-events@2.2.2:
+    resolution: {integrity: sha512-h7z00dWdG0PYOQEvChhOSWvOfkIKsdZGkWr083FgN/HyoQuebSew/cgirYqh9SCuy/hRvxc5Vy6Fw8xAmYHLkQ==}
+    requiresBuild: true
+    dev: true
+    optional: true
+
+  /bare-fs@2.2.2:
+    resolution: {integrity: sha512-X9IqgvyB0/VA5OZJyb5ZstoN62AzD7YxVGog13kkfYWYqJYcK0kcqLZ6TrmH5qr4/8//ejVcX4x/a0UvaogXmA==}
+    requiresBuild: true
+    dependencies:
+      bare-events: 2.2.2
+      bare-os: 2.2.1
+      bare-path: 2.1.0
+      streamx: 2.15.0
+    dev: true
+    optional: true
+
+  /bare-os@2.2.1:
+    resolution: {integrity: sha512-OwPyHgBBMkhC29Hl3O4/YfxW9n7mdTr2+SsO29XBWKKJsbgj3mnorDB80r5TiCQgQstgE5ga1qNYrpes6NvX2w==}
+    requiresBuild: true
+    dev: true
+    optional: true
+
+  /bare-path@2.1.0:
+    resolution: {integrity: sha512-DIIg7ts8bdRKwJRJrUMy/PICEaQZaPGZ26lsSx9MJSwIhSrcdHn7/C8W+XmnG/rKi6BaRcz+JO00CjZteybDtw==}
+    requiresBuild: true
+    dependencies:
+      bare-os: 2.2.1
+    dev: true
+    optional: true
+
   /base64-js@1.5.1:
     resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
     dev: true
@@ -1888,13 +1913,15 @@
       fsevents: 2.3.3
     dev: false
 
-  /chromium-bidi@0.4.12(devtools-protocol@0.0.1135028):
-    resolution: {integrity: sha512-yl0ngMHtYUGJa2G0lkcbPvbnUZ9WMQyMNSfYmlrGD1nHRNyI9KOGw3dOaofFugXHHToneUaSmF9iUdgCBamCjA==}
+  /chromium-bidi@0.5.13(devtools-protocol@0.0.1262051):
+    resolution: {integrity: sha512-OHbYCetDxdW/xmlrafgOiLsIrw4Sp1BEeolbZ1UGJO5v/nekQOJBj/Kzyw6sqKcAVabUTo0GS3cTYgr6zIf00g==}
     peerDependencies:
       devtools-protocol: '*'
     dependencies:
-      devtools-protocol: 0.0.1135028
-      mitt: 3.0.0
+      devtools-protocol: 0.0.1262051
+      mitt: 3.0.1
+      urlpattern-polyfill: 10.0.0
+      zod: 3.22.4
     dev: true
 
   /ci-info@2.0.0:
@@ -2022,28 +2049,26 @@
     engines: {node: '>=0.10.0'}
     dev: true
 
-  /cosmiconfig@8.2.0:
-    resolution: {integrity: sha512-3rTMnFJA1tCOPwRxtgF4wd7Ab2qvDbL8jX+3smjIbS4HlZBagTlpERbdN7iAbWlrfxE3M8c27kTwTawQ7st+OQ==}
+  /cosmiconfig@9.0.0(typescript@5.0.4):
+    resolution: {integrity: sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg==}
     engines: {node: '>=14'}
+    peerDependencies:
+      typescript: '>=4.9.5'
+    peerDependenciesMeta:
+      typescript:
+        optional: true
     dependencies:
+      env-paths: 2.2.1
       import-fresh: 3.3.0
       js-yaml: 4.1.0
       parse-json: 5.2.0
-      path-type: 4.0.0
+      typescript: 5.0.4
     dev: true
 
   /crelt@1.0.6:
     resolution: {integrity: sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==}
     dev: false
 
-  /cross-fetch@3.1.6:
-    resolution: {integrity: sha512-riRvo06crlE8HiqOwIpQhxwdOk4fOeR7FVM/wXoxchFEqMNUjvbs3bfo4OTgMEMHzppd4DxFBDbyySj8Cv781g==}
-    dependencies:
-      node-fetch: 2.6.11
-    transitivePeerDependencies:
-      - encoding
-    dev: true
-
   /cross-spawn@6.0.5:
     resolution: {integrity: sha512-eTVLrBSt7fjbDygz805pMnstIs2VTBNkRm0qxZd+M7A5XDdxVRWO5MxGBXZhjY4cqLYLdtrGqRf8mBPmzwSpWQ==}
     engines: {node: '>=4.8'}
@@ -2291,14 +2316,13 @@
       isobject: 3.0.1
     dev: true
 
-  /degenerator@4.0.3:
-    resolution: {integrity: sha512-2wY8vmCfxrQpe2PKGYdiWRre5HQRwsAXbAAWRbC+z2b80MEpnWc8A3a9k4TwqwN3Z/Fm3uhNm5vYUZIbMhyRxQ==}
+  /degenerator@5.0.1:
+    resolution: {integrity: sha512-TllpMR/t0M5sqCXfj85i4XaAzxmS5tVA16dqvdkMwGmzI+dXLXnw3J+3Vdv7VKw+ThlTMboK6i9rnZ6Nntj5CQ==}
     engines: {node: '>= 14'}
     dependencies:
       ast-types: 0.13.4
-      escodegen: 1.14.3
+      escodegen: 2.1.0
       esprima: 4.0.1
-      vm2: 3.9.19
     dev: true
 
   /delaunator@5.0.0:
@@ -2317,14 +2341,14 @@
     engines: {node: '>=8'}
     dev: true
 
-  /devtools-protocol@0.0.1135028:
-    resolution: {integrity: sha512-jEcNGrh6lOXNRJvZb9RjeevtZGrgugPKSMJZxfyxWQnhlKawMPhMtk/dfC+Z/6xNXExlzTKlY5LzIAK/fRpQIw==}
-    dev: true
-
   /devtools-protocol@0.0.1159816:
     resolution: {integrity: sha512-2cZlHxC5IlgkIWe2pSDmCrDiTzbSJWywjbDDnupOImEBcG31CQgBLV8wWE+5t+C4rimcjHsbzy7CBzf9oFjboA==}
     dev: false
 
+  /devtools-protocol@0.0.1262051:
+    resolution: {integrity: sha512-YJe4CT5SA8on3Spa+UDtNhEqtuV6Epwz3OZ4HQVLhlRccpZ9/PAYk0/cy/oKxFKRrZPBUPyxympQci4yWNWZ9g==}
+    dev: true
+
   /diff-sequences@26.6.2:
     resolution: {integrity: sha512-Mv/TDa3nZ9sbc5soK+OoA74BsS3mL37yixCvUAQkiuA4Wz6YtwP/K47n2rv2ovzHZvoiQeA5FTQOschKkEwB0Q==}
     engines: {node: '>= 10.14.2'}
@@ -2377,6 +2401,11 @@
     resolution: {integrity: sha512-hCx1oky9PFrJ611mf0ifBLBRW8lUUVRlFolb5gWRfIELabBlbp9xZvrqZLZAs+NxFnbfQoeGd8wDkygjg7U85w==}
     dev: false
 
+  /env-paths@2.2.1:
+    resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==}
+    engines: {node: '>=6'}
+    dev: true
+
   /error-ex@1.3.2:
     resolution: {integrity: sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==}
     dependencies:
@@ -2622,6 +2651,7 @@
       optionator: 0.8.3
     optionalDependencies:
       source-map: 0.6.1
+    dev: false
 
   /escodegen@2.0.0:
     resolution: {integrity: sha512-mmHKys/C8BFUGI+MAWNcSYoORYLMdPzjrknd2Vc+bUsjN5bXcr8EhrNB+UTqfL1y3I9c4fw2ihgtMPQLBRiQxw==}
@@ -2636,6 +2666,18 @@
       source-map: 0.6.1
     dev: true
 
+  /escodegen@2.1.0:
+    resolution: {integrity: sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==}
+    engines: {node: '>=6.0'}
+    hasBin: true
+    dependencies:
+      esprima: 4.0.1
+      estraverse: 5.3.0
+      esutils: 2.0.3
+    optionalDependencies:
+      source-map: 0.6.1
+    dev: true
+
   /eslint-config-google@0.14.0(eslint@8.43.0):
     resolution: {integrity: sha512-WsbX4WbjuMvTdeVL6+J3rK1RGhCTqjsFjX7UMSMgZiyxxaNLkoJENbrGExzERFeoTpGw3F3FypTiWAP9ZXzkEw==}
     engines: {node: '>=0.10.0'}
@@ -3150,6 +3192,7 @@
 
   /growly@1.3.0:
     resolution: {integrity: sha512-+xGQY0YyAWCnqy7Cd++hc2JqMYzlm0dG30Jd0beaA64sROr8C4nt8Yc9V5Ro3avlSUDTN0ulqP/VBKi1/lLygw==}
+    requiresBuild: true
     dev: true
     optional: true
 
@@ -3246,8 +3289,8 @@
       - supports-color
     dev: true
 
-  /http-proxy-agent@7.0.0:
-    resolution: {integrity: sha512-+ZT+iBxVUQ1asugqnD6oWoRiS25AkjNfG085dKJGtGxkdwLQrMKU5wJr2bOOFAXzKcTuqq+7fZlTMgG3SRfIYQ==}
+  /http-proxy-agent@7.0.2:
+    resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==}
     engines: {node: '>= 14'}
     dependencies:
       agent-base: 7.1.0
@@ -3266,8 +3309,8 @@
       - supports-color
     dev: true
 
-  /https-proxy-agent@7.0.0:
-    resolution: {integrity: sha512-0euwPCRyAPSgGdzD1IVN9nJYHtBhJwb6XPfbpQcYbPCwrBidX6GzxmchnaF4sfF/jPb74Ojx5g4yTg3sixlyPw==}
+  /https-proxy-agent@7.0.4:
+    resolution: {integrity: sha512-wlwpilI7YdjSkWaQ/7omYBMTliDcmCN8OLihO6I9B86g06lMyAoqgoDpV0XqoaPOKj+0DIdAvnsWfyAAhmimcg==}
     engines: {node: '>= 14'}
     dependencies:
       agent-base: 7.1.0
@@ -3348,10 +3391,6 @@
     engines: {node: '>=12'}
     dev: false
 
-  /ip@1.1.8:
-    resolution: {integrity: sha512-PuExPYUiu6qMBQb4l06ecm6T6ujzhmh+MeJcW9wa89PoAz5pvd4zPgN5WJV104mb6S2T1AwNIAaB70JNrLQWhg==}
-    dev: true
-
   /ip@2.0.0:
     resolution: {integrity: sha512-WKa+XuLG1A1R0UWhl2+1XQSi+fZWMsYKffMZTTYsiZaUD8k2yDAj5atimTUD2TZkyCkNEeYE5NhFZmupOGtjYQ==}
     dev: true
@@ -3454,6 +3493,7 @@
     resolution: {integrity: sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==}
     engines: {node: '>=8'}
     hasBin: true
+    requiresBuild: true
     dev: true
     optional: true
 
@@ -3565,6 +3605,7 @@
   /is-wsl@2.2.0:
     resolution: {integrity: sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==}
     engines: {node: '>=8'}
+    requiresBuild: true
     dependencies:
       is-docker: 2.2.1
     dev: true
@@ -4461,8 +4502,8 @@
       ospec: 4.0.0
     dev: false
 
-  /mitt@3.0.0:
-    resolution: {integrity: sha512-7dX2/10ITVyqh4aOSVI9gdape+t9l2/8QxHrFmUXu4EEUpdlxl6RudZUPZoc+zuY2hk1j7XxVroIVIan/pD/SQ==}
+  /mitt@3.0.1:
+    resolution: {integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==}
     dev: true
 
   /mixin-deep@1.3.2:
@@ -4473,10 +4514,6 @@
       is-extendable: 1.0.1
     dev: true
 
-  /mkdirp-classic@0.5.3:
-    resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==}
-    dev: true
-
   /mkdirp@1.0.4:
     resolution: {integrity: sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==}
     engines: {node: '>=10'}
@@ -4537,6 +4574,7 @@
         optional: true
     dependencies:
       whatwg-url: 5.0.0
+    dev: false
 
   /node-int64@0.4.0:
     resolution: {integrity: sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==}
@@ -4714,27 +4752,27 @@
     engines: {node: '>=6'}
     dev: true
 
-  /pac-proxy-agent@6.0.3:
-    resolution: {integrity: sha512-5Hr1KgPDoc21Vn3rsXBirwwDnF/iac1jN/zkpsOYruyT+ZgsUhUOgVwq3v9+ukjZd/yGm/0nzO1fDfl7rkGoHQ==}
+  /pac-proxy-agent@7.0.1:
+    resolution: {integrity: sha512-ASV8yU4LLKBAjqIPMbrgtaKIvxQri/yh2OpI+S6hVa9JRkUI3Y3NPFbfngDtY7oFtSMD3w31Xns89mDa3Feo5A==}
     engines: {node: '>= 14'}
     dependencies:
+      '@tootallnate/quickjs-emscripten': 0.23.0
       agent-base: 7.1.0
       debug: 4.3.4
       get-uri: 6.0.1
-      http-proxy-agent: 7.0.0
-      https-proxy-agent: 7.0.0
-      pac-resolver: 6.0.1
-      socks-proxy-agent: 8.0.1
+      http-proxy-agent: 7.0.2
+      https-proxy-agent: 7.0.4
+      pac-resolver: 7.0.1
+      socks-proxy-agent: 8.0.2
     transitivePeerDependencies:
       - supports-color
     dev: true
 
-  /pac-resolver@6.0.1:
-    resolution: {integrity: sha512-dg497MhVT7jZegPRuOScQ/z0aV/5WR0gTdRu1md+Irs9J9o+ls5jIuxjo1WfaTG+eQQkxyn5HMGvWK+w7EIBkQ==}
+  /pac-resolver@7.0.1:
+    resolution: {integrity: sha512-5NPgf87AT2STgwa2ntRMr45jTKrYBGkVU36yT0ig/n/GMAa3oPqhZfIQ2kMEimReg0+t9kZViDVZ83qfVUlckg==}
     engines: {node: '>= 14'}
     dependencies:
-      degenerator: 4.0.3
-      ip: 1.1.8
+      degenerator: 5.0.1
       netmask: 2.0.2
     dev: true
 
@@ -4914,18 +4952,18 @@
       long: 5.2.3
     dev: false
 
-  /proxy-agent@6.2.1:
-    resolution: {integrity: sha512-OIbBKlRAT+ycCm6wAYIzMwPejzRtjy8F3QiDX0eKOA3e4pe3U9F/IvzcHP42bmgQxVv97juG+J8/gx+JIeCX/Q==}
+  /proxy-agent@6.4.0:
+    resolution: {integrity: sha512-u0piLU+nCOHMgGjRbimiXmA9kM/L9EHh3zL81xCdp7m+Y2pHIsnmbdDoEDoAz5geaonNR6q6+yOPQs6n4T6sBQ==}
     engines: {node: '>= 14'}
     dependencies:
       agent-base: 7.1.0
       debug: 4.3.4
-      http-proxy-agent: 7.0.0
-      https-proxy-agent: 7.0.0
+      http-proxy-agent: 7.0.2
+      https-proxy-agent: 7.0.4
       lru-cache: 7.18.3
-      pac-proxy-agent: 6.0.3
+      pac-proxy-agent: 7.0.1
       proxy-from-env: 1.1.0
-      socks-proxy-agent: 8.0.1
+      socks-proxy-agent: 8.0.2
     transitivePeerDependencies:
       - supports-color
     dev: true
@@ -4950,40 +4988,33 @@
     engines: {node: '>=6'}
     dev: true
 
-  /puppeteer-core@20.7.3(typescript@5.0.4):
-    resolution: {integrity: sha512-OraI71GPPfUMosLqaOsDGbp/ZLoxLTm0BAda0uE6G+H3onmljfoaJcIPm8X5y1LMq1K1HF1bipcCI7hWGkd3bQ==}
-    engines: {node: '>=16.3.0'}
-    peerDependencies:
-      typescript: '>= 4.7.4'
-    peerDependenciesMeta:
-      typescript:
-        optional: true
+  /puppeteer-core@22.6.0:
+    resolution: {integrity: sha512-xclyGFhxHfZ9l62uXFm+JpgtJHLIZ1qHc7iR4eaIqBNKA5Dg2sDr8yvylfCx5bMN89QWIaxpV6IHsy0qUynK/g==}
+    engines: {node: '>=18'}
     dependencies:
-      '@puppeteer/browsers': 1.4.2(typescript@5.0.4)
-      chromium-bidi: 0.4.12(devtools-protocol@0.0.1135028)
-      cross-fetch: 3.1.6
+      '@puppeteer/browsers': 2.2.0
+      chromium-bidi: 0.5.13(devtools-protocol@0.0.1262051)
       debug: 4.3.4
-      devtools-protocol: 0.0.1135028
-      typescript: 5.0.4
-      ws: 8.13.0
+      devtools-protocol: 0.0.1262051
+      ws: 8.16.0
     transitivePeerDependencies:
       - bufferutil
-      - encoding
       - supports-color
       - utf-8-validate
     dev: true
 
-  /puppeteer@20.7.3(typescript@5.0.4):
-    resolution: {integrity: sha512-3tw12ykFRLvzTRc9PyUOE5xeHQhhLEcKEOVjSfNtRmZqlAnvfhAP8ue+mjojy8NJ1LIfF6fps7OKzSc4JSJSlA==}
-    engines: {node: '>=16.3.0'}
+  /puppeteer@22.6.0(typescript@5.0.4):
+    resolution: {integrity: sha512-TYeza4rl1YXfxqUVw/0hWUWYX5cicnf6qu5kkDV+t7QrESCjMoSNnva4ZA/MRGQ03HnB9BOFw9nxs/SKek5KDA==}
+    engines: {node: '>=18'}
+    hasBin: true
     requiresBuild: true
     dependencies:
-      '@puppeteer/browsers': 1.4.2(typescript@5.0.4)
-      cosmiconfig: 8.2.0
-      puppeteer-core: 20.7.3(typescript@5.0.4)
+      '@puppeteer/browsers': 2.2.0
+      cosmiconfig: 9.0.0(typescript@5.0.4)
+      devtools-protocol: 0.0.1262051
+      puppeteer-core: 22.6.0
     transitivePeerDependencies:
       - bufferutil
-      - encoding
       - supports-color
       - typescript
       - utf-8-validate
@@ -4999,6 +5030,7 @@
 
   /queue-tick@1.0.1:
     resolution: {integrity: sha512-kJt5qhMxoszgU/62PLP1CJytzd2NKetjSRnyuj31fDd3Rlcz3fzlFdFLD1SItunPwyqEOkca6GbV612BWfaBag==}
+    requiresBuild: true
     dev: true
 
   /react-is@17.0.2:
@@ -5236,6 +5268,14 @@
     dependencies:
       lru-cache: 6.0.0
 
+  /semver@7.6.0:
+    resolution: {integrity: sha512-EnwXhrlwXMk9gKu5/flx5sv/an57AkRplG3hTK68W7FRDN+k+OWBj65M7719OkA82XLBxrcX0KSHj+X5COhOVg==}
+    engines: {node: '>=10'}
+    hasBin: true
+    dependencies:
+      lru-cache: 6.0.0
+    dev: true
+
   /set-blocking@2.0.0:
     resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==}
     dev: true
@@ -5276,6 +5316,7 @@
 
   /shellwords@0.1.1:
     resolution: {integrity: sha512-vFwSUfQvqybiICwZY5+DAWIPLKsWO31Q91JSKl3UYv+K5c2QRPzn0qzec6QPu1Qc9eHYItiP3NdJqNVqetYAww==}
+    requiresBuild: true
     dev: true
     optional: true
 
@@ -5329,8 +5370,8 @@
       - supports-color
     dev: true
 
-  /socks-proxy-agent@8.0.1:
-    resolution: {integrity: sha512-59EjPbbgg8U3x62hhKOFVAmySQUcfRQ4C7Q/D5sEHnZTQRrQlNKINks44DMR1gwXp0p4LaVIeccX2KHTTcHVqQ==}
+  /socks-proxy-agent@8.0.2:
+    resolution: {integrity: sha512-8zuqoLv1aP/66PHF5TqwJ7Czm3Yv32urJQHrVyhD7mmA6d61Zv8cIXQYPTWwmg6qlupnPvs/QKDmfa4P/qct2g==}
     engines: {node: '>= 14'}
     dependencies:
       agent-base: 7.1.0
@@ -5532,16 +5573,18 @@
     resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==}
     dev: true
 
-  /tar-fs@3.0.2:
-    resolution: {integrity: sha512-mLQ5iTTCv2tt3a4BwvD8QX1YFVBL/94/Nd+U2il38wt2+zaJSusp1VwJSNkBmB48FeTdOqptf1DAUIosXQBRrQ==}
+  /tar-fs@3.0.5:
+    resolution: {integrity: sha512-JOgGAmZyMgbqpLwct7ZV8VzkEB6pxXFBVErLtb+XCOqzc6w1xiWKI9GVd6bwk68EX7eJ4DWmfXVmq8K2ziZTGg==}
     dependencies:
-      mkdirp-classic: 0.5.3
       pump: 3.0.0
-      tar-stream: 3.1.4
+      tar-stream: 3.1.7
+    optionalDependencies:
+      bare-fs: 2.2.2
+      bare-path: 2.1.0
     dev: true
 
-  /tar-stream@3.1.4:
-    resolution: {integrity: sha512-IlHr7ZOW6XaVBCrSCokUJG4IqUuRcWW76B8XbrtCotbaDh6zVGE7WPCzaSz1CN+acFmWiwoa+cE4RZsom0RzXg==}
+  /tar-stream@3.1.7:
+    resolution: {integrity: sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==}
     dependencies:
       b4a: 1.6.4
       fast-fifo: 1.2.0
@@ -5642,6 +5685,7 @@
 
   /tr46@0.0.3:
     resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==}
+    dev: false
 
   /tr46@2.1.0:
     resolution: {integrity: sha512-15Ih7phfcdP5YxqiB+iDtLoaTz4Nd35+IiAv0kQ5FNKHzXgdWqPoTIqEDDJmXceQt4JZk6lVPT8lnDlPpGDppw==}
@@ -5795,6 +5839,10 @@
       requires-port: 1.0.0
     dev: true
 
+  /urlpattern-polyfill@10.0.0:
+    resolution: {integrity: sha512-H/A06tKD7sS1O1X2SshBVeA5FLycRpjqiBeqGKmBwBDBy28EnRjORxTNe269KSSr5un5qyWi1iL61wLxpd+ZOg==}
+    dev: true
+
   /use@3.1.1:
     resolution: {integrity: sha512-cwESVXlO3url9YWlFW/TA9cshCEhtu7IKJ/p5soJ/gGpj7vbvFrAY/eIioQ6Dw23KjZhYgiIo8HOs1nQ2vr/oQ==}
     engines: {node: '>=0.10.0'}
@@ -5813,6 +5861,7 @@
   /uuid@8.3.2:
     resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==}
     hasBin: true
+    requiresBuild: true
     dev: true
     optional: true
 
@@ -6185,15 +6234,6 @@
     resolution: {integrity: sha512-DRibZL6DsNhIgYQ+wNdWDL2SL3bKPlVrRiBqV5yuMm++op8W4kGFtaQfCs4KEJn0wBZcHVHJ3eoywX8983k1ow==}
     dev: true
 
-  /vm2@3.9.19:
-    resolution: {integrity: sha512-J637XF0DHDMV57R6JyVsTak7nIL8gy5KH4r1HiwWLf/4GBbb5MKL5y7LpmF4A8E2nR6XmzpmMFQ7V7ppPTmUQg==}
-    engines: {node: '>=6.0'}
-    hasBin: true
-    dependencies:
-      acorn: 8.9.0
-      acorn-walk: 8.2.0
-    dev: true
-
   /w3c-hr-time@1.0.2:
     resolution: {integrity: sha512-z8P5DvDNjKDoFIHK7q8r8lackT6l+jo/Ye3HOle7l9nICP9lf1Ci25fy9vHd0JOWewkIFzXIEig3TdKT7JQ5fQ==}
     deprecated: Use your platform's native performance.now() and performance.timeOrigin.
@@ -6220,6 +6260,7 @@
 
   /webidl-conversions@3.0.1:
     resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==}
+    dev: false
 
   /webidl-conversions@5.0.0:
     resolution: {integrity: sha512-VlZwKPCkYKxQgeSbH5EyngOmRp7Ww7I9rQLERETtf5ofd9pGeswWiOtogpEO850jziPRarreGxn5QIiTqpb2wA==}
@@ -6246,6 +6287,7 @@
     dependencies:
       tr46: 0.0.3
       webidl-conversions: 3.0.1
+    dev: false
 
   /whatwg-url@8.7.0:
     resolution: {integrity: sha512-gAojqb/m9Q8a5IV96E3fHJM70AzCkgt4uXYX2O7EmuyOnLrViCQlsEBmF9UQIu3/aeAIp2U17rtbpZWNntQqdg==}
@@ -6333,8 +6375,8 @@
         optional: true
     dev: true
 
-  /ws@8.13.0:
-    resolution: {integrity: sha512-x9vcZYTrFPC7aSIbj7sRCYo7L/Xb8Iy+pW0ng0wt2vCJv7M9HOMy0UoN3rr+IFC7hb7vXoqS+P9ktyLLLhO+LA==}
+  /ws@8.16.0:
+    resolution: {integrity: sha512-HS0c//TP7Ina87TfiPUz1rQzMhHrl/SG2guqRcTOIUYD2q8uhUdNHZYJUaQ8aTGPzCh+c6oawMKW35nFl1dxyQ==}
     engines: {node: '>=10.0.0'}
     peerDependencies:
       bufferutil: ^4.0.1
@@ -6402,19 +6444,6 @@
       yargs-parser: 18.1.3
     dev: true
 
-  /yargs@17.7.1:
-    resolution: {integrity: sha512-cwiTb08Xuv5fqF4AovYacTFNxk62th7LKJ6BL9IGUpTJrWoU7/7WdQGTP2SjKf1dUNBGzDd28p/Yfs/GI6JrLw==}
-    engines: {node: '>=12'}
-    dependencies:
-      cliui: 8.0.1
-      escalade: 3.1.1
-      get-caller-file: 2.0.5
-      require-directory: 2.1.1
-      string-width: 4.2.3
-      y18n: 5.0.8
-      yargs-parser: 21.1.1
-    dev: true
-
   /yargs@17.7.2:
     resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==}
     engines: {node: '>=12'}
@@ -6426,7 +6455,6 @@
       string-width: 4.2.3
       y18n: 5.0.8
       yargs-parser: 21.1.1
-    dev: false
 
   /yauzl@2.10.0:
     resolution: {integrity: sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==}
@@ -6440,8 +6468,11 @@
     engines: {node: '>=10'}
     dev: true
 
+  /zod@3.22.4:
+    resolution: {integrity: sha512-iC+8Io04lddc+mVqQ9AZ7OQ2MrUKGN+oIQyq1vemgt46jwCwLfhq7/pwnBnNXXXZb8VTVLKwp9EDkx+ryxIWmg==}
+    dev: true
+
   file:src/base/utils:
     resolution: {directory: src/base/utils, type: directory}
     name: custom_utils
-    version: 0.0.1
     dev: false
diff --git a/ui/src/assets/widgets/tree.scss b/ui/src/assets/widgets/tree.scss
index 83073c9..0f6604b 100644
--- a/ui/src/assets/widgets/tree.scss
+++ b/ui/src/assets/widgets/tree.scss
@@ -2,66 +2,60 @@
 
 $chevron-svg: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' height='8' width='8'%3E%3Cline x1='2' y1='0' x2='6' y2='4' stroke='black'/%3E%3Cline x1='6' y1='4' x2='2' y2='8' stroke='black'/%3E%3C/svg%3E");
 
-@mixin grid {
-  display: grid;
-  grid-template-columns: [gutter]auto [left]auto [right]1fr;
-  row-gap: 5px;
-}
-
 .pf-tree {
   font-family: $pf-font;
-  @include grid;
+  display: grid;
+  grid-template-columns: [left]auto [right]1fr;
+  row-gap: 5px;
+
   .pf-tree-node {
     display: contents;
-    .pf-tree-content {
-      display: contents;
-      &:hover {
-        background: $table-hover-color;
-      }
-      .pf-tree-left {
-        background: inherit;
-        min-width: max-content;
-        border-radius: $pf-border-radius 0 0 $pf-border-radius;
-        font-weight: bolder;
-      }
-      .pf-tree-right {
-        background: inherit;
-        padding: 0 0 0 15px;
-        border-radius: 0 $pf-border-radius $pf-border-radius 0;
-        word-break: break-all;
-        white-space: pre-wrap;
-      }
+
+    &:hover {
+      background: $table-hover-color;
     }
+
+    .pf-tree-left {
+      grid-column: left;
+      background: inherit;
+      min-width: max-content;
+      border-radius: $pf-border-radius 0 0 $pf-border-radius;
+      font-weight: bolder;
+    }
+
+    .pf-tree-right {
+      grid-column: right;
+      background: inherit;
+      padding: 0 0 0 15px;
+      border-radius: 0 $pf-border-radius $pf-border-radius 0;
+      word-break: break-all;
+      white-space: pre-wrap;
+    }
+
     .pf-tree-gutter {
-      display: block;
+      display: inline-flex;
       position: relative;
-    }
-    &.pf-collapsed > .pf-tree-gutter {
-      cursor: pointer;
       width: 16px;
-      display: flex;
       justify-content: center;
       align-items: center;
+    }
+
+    &.pf-collapsed > .pf-tree-left > .pf-tree-gutter {
+      cursor: pointer;
+
       &::after {
         content: $chevron-svg;
       }
     }
-    &.pf-expanded > .pf-tree-gutter {
+    &.pf-expanded > .pf-tree-left > .pf-tree-gutter {
       cursor: pointer;
-      width: 16px;
-      display: flex;
-      justify-content: center;
-      align-items: center;
       &::after {
         content: $chevron-svg;
         rotate: 90deg;
       }
     }
-    &.pf-loading > .pf-tree-gutter {
-      width: 16px;
-      display: flex;
-      justify-content: center;
-      align-items: center;
+
+    &.pf-loading > .pf-tree-left > .pf-tree-gutter {
       &::after {
         content: "";
         border: solid 1px lightgray;
@@ -76,19 +70,19 @@
       display: block;
       position: relative;
     }
-    .pf-tree-children {
-      grid-column: 2 / span 2;
-      @include grid;
-      .pf-tree-gutter {
-        // Nested gutters are always present, to provide indentation
-        width: 16px;
-      }
-    }
-    &.pf-collapsed > .pf-tree-children {
+
+    &.pf-collapsed + .pf-tree-children {
       display: none;
     }
-    &.pf-collapsed > .pf-tree-indent-gutter {
-      display: none;
-    }
+  }
+
+  .pf-tree-children {
+    display: grid;
+    grid-column: 1 / span 2;
+    grid-template-columns: subgrid;
+    row-gap: 5px;
+    border-left: solid rgba(0, 0, 0, 0.2) 1px;
+    margin-left: 6px;
+    padding-left: 6px;
   }
 }
diff --git a/ui/src/frontend/widgets_page.ts b/ui/src/frontend/widgets_page.ts
index 65fb04e..98fc400 100644
--- a/ui/src/frontend/widgets_page.ts
+++ b/ui/src/frontend/widgets_page.ts
@@ -371,6 +371,17 @@
   label: string;
 }
 
+function recursiveTreeNode(): m.Children {
+  return m(LazyTreeNode, {
+    left: 'Recursive',
+    right: '...',
+    fetchData: async () => {
+      // await new Promise((r) => setTimeout(r, 1000));
+      return () => recursiveTreeNode();
+    },
+  });
+}
+
 class WidgetTitle implements m.ClassComponent<WidgetTitleAttrs> {
   view({attrs}: m.CVnode<WidgetTitleAttrs>) {
     const {label} = attrs;
@@ -996,6 +1007,7 @@
                 return () => m(TreeNode, {left: 'foo'});
               },
             }),
+            recursiveTreeNode(),
           ),
         wide: true,
       }),
diff --git a/ui/src/widgets/tree.ts b/ui/src/widgets/tree.ts
index 68dcbe1..0df2f58 100644
--- a/ui/src/widgets/tree.ts
+++ b/ui/src/widgets/tree.ts
@@ -82,24 +82,27 @@
       attrs,
       attrs: {left, onCollapseChanged = () => {}},
     } = vnode;
-    return m(
-      '.pf-tree-node',
-      {
-        class: classNames(this.getClassNameForNode(vnode)),
-      },
-      m('span.pf-tree-gutter', {
-        onclick: () => {
-          this.collapsed = !this.isCollapsed(vnode);
-          onCollapseChanged(this.collapsed, attrs);
-          scheduleFullRedraw();
+    return [
+      m(
+        '.pf-tree-node',
+        {
+          class: classNames(this.getClassNameForNode(vnode)),
         },
-      }),
-      m('.pf-tree-content', m('.pf-tree-left', left), this.renderRight(vnode)),
-      hasChildren(vnode) && [
-        m('span.pf-tree-indent-gutter'),
-        m('.pf-tree-children', children),
-      ],
-    );
+        m(
+          '.pf-tree-left',
+          m('span.pf-tree-gutter', {
+            onclick: () => {
+              this.collapsed = !this.isCollapsed(vnode);
+              onCollapseChanged(this.collapsed, attrs);
+              scheduleFullRedraw();
+            },
+          }),
+          left,
+        ),
+        this.renderRight(vnode),
+      ),
+      hasChildren(vnode) && m('.pf-tree-children', children),
+    ];
   }
 
   private getClassNameForNode(vnode: m.CVnode<TreeNodeAttrs>) {