[ui] ThreadStateTab

Refactor selection details panel for thread state to use BottomTab API.

Add Value API to support rendering JSON-like values in a panel.

Change-Id: Icb5b0725b0733fd1dc608b26cf0f06cbfbacb465
diff --git a/ui/src/controller/selection_controller.ts b/ui/src/controller/selection_controller.ts
index 4b4ee82..653bad6 100644
--- a/ui/src/controller/selection_controller.ts
+++ b/ui/src/controller/selection_controller.ts
@@ -22,7 +22,6 @@
   STR_NULL,
 } from '../common/query_result';
 import {ChromeSliceSelection} from '../common/state';
-import {translateState} from '../common/thread_state';
 import {fromNs, toNs} from '../common/time';
 import {SliceDetails, ThreadStateDetails} from '../frontend/globals';
 import {
@@ -311,19 +310,16 @@
     return trackId;
   }
 
+  // TODO(altimin): We currently rely on the ThreadStateDetails for supporting
+  // marking the area (the rest goes is handled by ThreadStateTab
+  // directly. Refactor it to be plugin-friendly and remove this.
   async threadStateDetails(id: number) {
     const query = `
       SELECT
         ts,
-        thread_state.dur as dur,
-        state,
-        io_wait as ioWait,
-        thread_state.utid as utid,
-        thread_state.cpu as cpu,
-        sched.id as id,
-        thread_state.blocked_function as blockedFunction
+        thread_state.dur as dur
       from thread_state
-      left join sched using(ts) where thread_state.id = ${id}
+      where thread_state.id = ${id}
     `;
     const result = await this.args.engine.query(query);
 
@@ -332,25 +328,11 @@
       const row = result.firstRow({
         ts: NUM,
         dur: NUM,
-        state: STR_NULL,
-        ioWait: NUM_NULL,
-        utid: NUM,
-        cpu: NUM_NULL,
-        id: NUM_NULL,
-        blockedFunction: STR_NULL,
       });
       const ts = row.ts;
       const timeFromStart = fromNs(ts) - globals.state.traceTime.startSec;
       const dur = fromNs(row.dur);
-      const ioWait = row.ioWait === null ? undefined : row.ioWait > 0;
-      const state = translateState(row.state || undefined, ioWait);
-      const utid = row.utid;
-      const cpu = row.cpu === null ? undefined : row.cpu;
-      const sliceId = row.id === null ? undefined : row.id;
-      const blockedFunction =
-          row.blockedFunction === null ? undefined : row.blockedFunction;
-      const selected: ThreadStateDetails =
-          {ts: timeFromStart, dur, state, utid, cpu, sliceId, blockedFunction};
+      const selected: ThreadStateDetails = {ts: timeFromStart, dur};
       publishThreadStateDetails(selected);
     }
   }
diff --git a/ui/src/frontend/details_panel.ts b/ui/src/frontend/details_panel.ts
index 9b7e617..d195b7e 100644
--- a/ui/src/frontend/details_panel.ts
+++ b/ui/src/frontend/details_panel.ts
@@ -39,7 +39,7 @@
 import {PivotTableRedux} from './pivot_table_redux';
 import {QueryTable} from './query_table';
 import {SliceDetailsPanel} from './slice_details_panel';
-import {ThreadStatePanel} from './thread_state_panel';
+import {ThreadStateTab} from './thread_state_tab';
 
 const UP_ICON = 'keyboard_arrow_up';
 const DOWN_ICON = 'keyboard_arrow_down';
@@ -230,6 +230,15 @@
         });
       }
       break;
+    case 'THREAD_STATE':
+      bottomTabList.addTab({
+        kind: ThreadStateTab.kind,
+        tag: currentSelectionTag,
+        config: {
+          id: newSelection.id,
+        },
+      });
+      break;
     default:
       bottomTabList.closeTabByTag(currentSelectionTag);
   }
@@ -315,13 +324,6 @@
             vnode: m(ChromeSliceDetailsPanel, {key: 'chrome_slice'}),
           });
           break;
-        case 'THREAD_STATE':
-          detailsPanels.push({
-            key: 'current_selection',
-            name: 'Current Selection',
-            vnode: m(ThreadStatePanel, {key: 'thread_state'}),
-          });
-          break;
         default:
           break;
       }
diff --git a/ui/src/frontend/globals.ts b/ui/src/frontend/globals.ts
index 0e5de55..b49e6cd 100644
--- a/ui/src/frontend/globals.ts
+++ b/ui/src/frontend/globals.ts
@@ -114,11 +114,6 @@
 export interface ThreadStateDetails {
   ts?: number;
   dur?: number;
-  state?: string;
-  utid?: number;
-  cpu?: number;
-  sliceId?: number;
-  blockedFunction?: string;
 }
 
 export interface FlamegraphDetails {
diff --git a/ui/src/frontend/popup_menu.ts b/ui/src/frontend/popup_menu.ts
index 863ddd1..e4c9190 100644
--- a/ui/src/frontend/popup_menu.ts
+++ b/ui/src/frontend/popup_menu.ts
@@ -23,6 +23,16 @@
   callback: () => void;
 }
 
+// Helper function for simplifying defining menus.
+export function menuItem(
+    text: string, action: () => void): RegularPopupMenuItem {
+  return {
+    itemType: 'regular',
+    text,
+    callback: action,
+  };
+}
+
 export interface GroupPopupMenuItem {
   itemType: 'group';
   text: string;
diff --git a/ui/src/frontend/sql_types.ts b/ui/src/frontend/sql_types.ts
new file mode 100644
index 0000000..a2159e3
--- /dev/null
+++ b/ui/src/frontend/sql_types.ts
@@ -0,0 +1,67 @@
+// Copyright (C) 2023 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.
+
+import {fromNs} from '../common/time';
+import {globals} from './globals';
+
+// Type-safe aliases for various flavours of ints Trace Processor exposes
+// (e.g. timestamp or ids into a given SQL table) and functions to work with
+// them.
+//
+// These rely on TypeScript's type branding: extending a number with additional
+// compile-time-only type information, which prevents "implicit" conversions
+// between different ids.
+
+// Timestamp (in nanoseconds) in the same time domain as Trace Processor is
+// exposing.
+export type TPTimestamp = number&{
+  __type: 'TPTimestamp'
+}
+
+// TODO: unify this with common/time.ts.
+export function toTraceTime(ts: TPTimestamp): number {
+  return fromNs(ts) - globals.state.traceTime.startSec;
+}
+
+// Unique id for a process, id into |process| table.
+export type Upid = number&{
+  __type: 'Upid'
+}
+
+export function asUpid(v: number): Upid;
+export function asUpid(v?: number): Upid|undefined;
+export function asUpid(v?: number): Upid|undefined {
+  return v as (Upid | undefined);
+}
+
+// Unique id for a thread, id into |thread| table.
+export type Utid = number&{
+  __type: 'Utid'
+}
+
+export function asUtid(v: number): Utid;
+export function asUtid(v?: number): Utid|undefined;
+export function asUtid(v?: number): Utid|undefined {
+  return v as (Utid | undefined);
+}
+
+// Id into |sched| SQL table.
+export type SchedSqlId = number&{
+  __type: 'SchedSqlId'
+}
+
+// Id into |thread_state| SQL table.
+export type ThreadStateSqlId = number&{
+  __type: 'ThreadStateSqlId'
+}
diff --git a/ui/src/frontend/sql_utils.ts b/ui/src/frontend/sql_utils.ts
new file mode 100644
index 0000000..40b5d72
--- /dev/null
+++ b/ui/src/frontend/sql_utils.ts
@@ -0,0 +1,47 @@
+// Copyright (C) 2023 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.
+
+import {SortDirection} from '../common/state';
+
+interface OrderClause {
+  fieldName: string;
+  direction?: SortDirection;
+}
+
+// Interface for defining constraints which can be passed to a SQL query.
+export interface SQLConstraints {
+      where?: string[];
+  orderBy?: OrderClause[];
+  limit?: number;
+}
+
+// Formatting given constraints into a string which can be injected into
+// SQL query.
+export function constraintsToQueryFragment(c: SQLConstraints): string {
+  const result: string[] = [];
+  if (c.where && c.where.length > 0) {
+    result.push(`WHERE ${c.where.join(' and ')}`);
+  }
+  if (c.orderBy && c.orderBy.length > 0) {
+    const orderBys = c.orderBy.map((clause) => {
+      const direction = clause.direction ? ` ${clause.direction}` : '';
+      return `${clause.fieldName}${direction}`;
+    });
+    result.push(`ORDER BY ${orderBys.join(', ')}`);
+  }
+  if (c.limit) {
+    result.push(`LIMIT ${c.limit}`);
+  }
+  return result.join('\n');
+}
diff --git a/ui/src/frontend/sql_utils_unittest.ts b/ui/src/frontend/sql_utils_unittest.ts
new file mode 100644
index 0000000..d8e35ba
--- /dev/null
+++ b/ui/src/frontend/sql_utils_unittest.ts
@@ -0,0 +1,44 @@
+// Copyright (C) 2023 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.
+
+import {constraintsToQueryFragment} from './sql_utils';
+
+// Clean up repeated whitespaces to allow for easier testing.
+function normalize(s: string): string {
+  return s.replace(/\s+/g, ' ');
+}
+
+test('constraintsToQueryFragment: where', () => {
+  expect(normalize(constraintsToQueryFragment({
+    where: ['ts > 1000', 'dur != 0'],
+  }))).toEqual('WHERE ts > 1000 and dur != 0');
+});
+
+test('constraintsToQueryFragment: order by', () => {
+  expect(normalize(constraintsToQueryFragment({
+    orderBy: [{fieldName: 'name'}, {fieldName: 'count', direction: 'DESC'}],
+  }))).toEqual('ORDER BY name, count DESC');
+});
+
+test('constraintsToQueryFragment: limit', () => {
+  expect(normalize(constraintsToQueryFragment({limit: 3}))).toEqual('LIMIT 3');
+});
+
+test('constraintsToQueryFragment: all', () => {
+  expect(normalize(constraintsToQueryFragment({
+    where: ['id != 1'],
+    orderBy: [{fieldName: 'ts'}],
+    limit: 1,
+  }))).toEqual('WHERE id != 1 ORDER BY ts LIMIT 1');
+});
diff --git a/ui/src/frontend/thread_and_process_info.ts b/ui/src/frontend/thread_and_process_info.ts
new file mode 100644
index 0000000..3d531d3
--- /dev/null
+++ b/ui/src/frontend/thread_and_process_info.ts
@@ -0,0 +1,120 @@
+// Copyright (C) 2023 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.
+
+import {EngineProxy} from '../common/engine';
+import {NUM, NUM_NULL, STR, STR_NULL} from '../common/query_result';
+import {Upid, Utid} from './sql_types';
+
+// Interface definitions for process and thread-related information
+// and functions to extract them from SQL.
+
+// TODO(altimin): Current implementation ends up querying process and thread
+// information separately for each thread. Given that there is a limited
+// numer of threads and processes, it might be easier to fetch this information
+// once when loading the trace and then just look it up synchronously.
+
+export interface ProcessInfo {
+  upid: Upid;
+  pid?: number;
+  name?: string;
+  uid?: number;
+  packageName?: string;
+  versionCode?: number;
+}
+
+async function getProcessInfo(
+    engine: EngineProxy, upid: Upid): Promise<ProcessInfo> {
+  const it = (await engine.query(`
+              SELECT pid, name, uid FROM process WHERE upid = ${upid};
+            `)).iter({pid: NUM, name: STR_NULL, uid: NUM_NULL});
+  if (!it.valid()) {
+    return {upid};
+  }
+  const result: ProcessInfo = {
+    upid,
+    pid: it.pid || undefined,
+    name: it.name || undefined,
+  };
+
+  if (it.pid === null) {
+    return result;
+  }
+  result.pid = it.pid || undefined;
+
+  if (it.uid === undefined) {
+    return result;
+  }
+
+  const packageResult = await engine.query(`
+                SELECT
+                  package_name as packageName,
+                  version_code as versionCode
+                FROM package_list WHERE uid = ${it.uid};
+              `);
+  // The package_list table is not populated in some traces so we need to
+  // check if the result has returned any rows.
+  if (packageResult.numRows() > 0) {
+    const packageDetails = packageResult.firstRow({
+      packageName: STR,
+      versionCode: NUM,
+    });
+    result.packageName = packageDetails.packageName;
+    result.versionCode = packageDetails.versionCode || undefined;
+  }
+  return result;
+}
+
+function getDisplayName(name: string|undefined, id: number|undefined): string|
+    undefined {
+  if (name === undefined) {
+    return id === undefined ? undefined : `${id}`;
+  }
+  return id === undefined ? name : `${name} [${id}]`;
+}
+
+export function getProcessName(info?: ProcessInfo): string|undefined {
+  return getDisplayName(info?.name, info?.pid);
+}
+
+export interface ThreadInfo {
+  utid: Utid;
+  tid?: number;
+  name?: string;
+  process?: ProcessInfo;
+}
+
+export async function getThreadInfo(
+    engine: EngineProxy, utid: Utid): Promise<ThreadInfo> {
+  const it = (await engine.query(`
+        SELECT tid, name, upid
+        FROM thread
+        WHERE utid = ${utid};
+    `)).iter({tid: NUM, name: STR_NULL, upid: NUM_NULL});
+  if (!it.valid()) {
+    return {
+      utid,
+    };
+  }
+  const upid = it.upid as (Upid | null);
+  return {
+    utid,
+    tid: it.tid,
+    name: it.name || undefined,
+    process: upid ? await getProcessInfo(engine, upid) : undefined,
+  };
+}
+
+export function getThreadName(info?: ThreadInfo): string|undefined {
+  return getDisplayName(info?.name, info?.tid);
+}
diff --git a/ui/src/frontend/thread_state.ts b/ui/src/frontend/thread_state.ts
new file mode 100644
index 0000000..56c9260
--- /dev/null
+++ b/ui/src/frontend/thread_state.ts
@@ -0,0 +1,200 @@
+// Copyright (C) 2023 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use size 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.
+
+import {Actions} from '../common/actions';
+import {EngineProxy} from '../common/engine';
+import {NUM, NUM_NULL, STR_NULL} from '../common/query_result';
+import {translateState} from '../common/thread_state';
+import {fromNs, timeToCode} from '../common/time';
+
+import {copyToClipboard} from './clipboard';
+import {globals} from './globals';
+import {menuItem} from './popup_menu';
+import {scrollToTrackAndTs} from './scroll_helper';
+import {
+  asUtid,
+  SchedSqlId,
+  ThreadStateSqlId,
+  toTraceTime,
+  TPTimestamp,
+} from './sql_types';
+import {constraintsToQueryFragment, SQLConstraints} from './sql_utils';
+import {
+  getProcessName,
+  getThreadInfo,
+  getThreadName,
+  ThreadInfo,
+} from './thread_and_process_info';
+import {dict, Dict, maybeValue, Value, value} from './value';
+
+// Representation of a single thread state object, corresponding to
+// a row for the |thread_slice| table.
+export interface ThreadState {
+  // Id into |thread_state| table.
+  threadStateSqlId: ThreadStateSqlId;
+  // Id of the corresponding entry in the |sched| table.
+  schedSqlId?: SchedSqlId;
+  // Timestamp of the the beginning of this thread state in nanoseconds.
+  ts: TPTimestamp;
+  // Duration of this thread state in nanoseconds.
+  dur: number;
+  // CPU id if this thread state corresponds to a thread running on the CPU.
+  cpu?: number;
+  // Human-readable name of this thread state.
+  state: string;
+  blockedFunction?: string;
+
+  thread?: ThreadInfo;
+  wakerThread?: ThreadInfo;
+}
+
+// Gets a list of thread state objects from Trace Processor with given
+// constraints.
+export async function getThreadStateFromConstraints(
+    engine: EngineProxy, constraints: SQLConstraints): Promise<ThreadState[]> {
+  const query = await engine.query(`
+    SELECT
+      thread_state.id as threadStateSqlId,
+      (select sched.id
+        from sched
+        where sched.ts=thread_state.ts and sched.utid=thread_state.utid
+        limit 1
+       ) as schedSqlId,
+      ts,
+      thread_state.dur as dur,
+      thread_state.cpu as cpu,
+      state,
+      thread_state.blocked_function as blockedFunction,
+      io_wait as ioWait,
+      thread_state.utid as utid,
+      waker_utid as wakerUtid
+    FROM thread_state
+    ${constraintsToQueryFragment(constraints)}`);
+  const it = query.iter({
+    threadStateSqlId: NUM,
+    schedSqlId: NUM_NULL,
+    ts: NUM,
+    dur: NUM,
+    cpu: NUM_NULL,
+    state: STR_NULL,
+    blockedFunction: STR_NULL,
+    ioWait: NUM_NULL,
+    utid: NUM,
+    wakerUtid: NUM_NULL,
+  });
+
+  const result: ThreadState[] = [];
+
+  for (; it.valid(); it.next()) {
+    const ioWait = it.ioWait === null ? undefined : it.ioWait > 0;
+    const wakerUtid = asUtid(it.wakerUtid || undefined);
+
+    // TODO(altimin): Consider fetcing thread / process info using a single
+    // query instead of one per row.
+    result.push({
+      threadStateSqlId: it.threadStateSqlId as ThreadStateSqlId,
+      schedSqlId: (it.schedSqlId as SchedSqlId | null) || undefined,
+      ts: it.ts as TPTimestamp,
+      dur: it.dur,
+      cpu: it.cpu || undefined,
+      state: translateState(it.state || undefined, ioWait),
+      blockedFunction: it.blockedFunction || undefined,
+      thread: await getThreadInfo(engine, asUtid(it.utid)),
+      wakerThread: wakerUtid ? await getThreadInfo(engine, wakerUtid) :
+                               undefined,
+    });
+  }
+  return result;
+}
+
+export async function getThreadState(
+    engine: EngineProxy, id: number): Promise<ThreadState|undefined> {
+  const result = await getThreadStateFromConstraints(engine, {
+    where: [`id=${id}`],
+  });
+  if (result.length > 1) {
+    throw new Error(`thread_state table has more than one row with id ${id}`);
+  }
+  if (result.length === 0) {
+    return undefined;
+  }
+  return result[0];
+}
+
+export function goToSchedSlice(cpu: number, id: SchedSqlId, ts: TPTimestamp) {
+  let trackId;
+  for (const track of Object.values(globals.state.tracks)) {
+    if (track.kind === 'CpuSliceTrack' &&
+        (track.config as {cpu: number}).cpu === cpu) {
+      trackId = track.id;
+    }
+  }
+  if (!trackId) return;
+  globals.makeSelection(Actions.selectSlice({id, trackId}));
+  scrollToTrackAndTs(trackId, ts);
+}
+
+function stateToValue(
+    state: string,
+    cpu: number|undefined,
+    id: SchedSqlId|undefined,
+    ts: TPTimestamp): Value|null {
+  if (!state) {
+    return null;
+  }
+  if (id === undefined || cpu === undefined) {
+    return value(state);
+  }
+  return value(`${state} on CPU ${cpu}`, {
+    rightButton: {
+      action: () => {
+        goToSchedSlice(cpu, id, ts);
+      },
+      hoverText: 'Go to CPU slice',
+    },
+  });
+}
+
+export function threadStateToDict(state: ThreadState): Dict {
+  const result: {[name: string]: Value|null} = {};
+
+  result['Start time'] = value(timeToCode(toTraceTime(state.ts)));
+  result['Duration'] = value(timeToCode(fromNs(state.dur)));
+  result['State'] =
+      stateToValue(state.state, state.cpu, state.schedSqlId, state.ts);
+  result['Blocked function'] = maybeValue(state.blockedFunction);
+  const process = state?.thread?.process;
+  result['Process'] = maybeValue(process ? getProcessName(process) : undefined);
+  const thread = state?.thread;
+  result['Thread'] = maybeValue(thread ? getThreadName(thread) : undefined);
+  if (state.wakerThread) {
+    const process = state.wakerThread.process;
+    result['Waker'] = dict({
+      'Process': maybeValue(process ? getProcessName(process) : undefined),
+      'Thread': maybeValue(getThreadName(state.wakerThread)),
+    });
+  }
+  result['SQL id'] = value(`thread_state[${state.threadStateSqlId}]`, {
+    contextMenu: [
+      menuItem(
+          'Copy SQL query',
+          () => {
+            copyToClipboard(`select * from thread_state where id=${
+                state.threadStateSqlId}`);
+          }),
+    ],
+  });
+
+  return dict(result);
+}
diff --git a/ui/src/frontend/thread_state_panel.ts b/ui/src/frontend/thread_state_panel.ts
deleted file mode 100644
index 337832a..0000000
--- a/ui/src/frontend/thread_state_panel.ts
+++ /dev/null
@@ -1,109 +0,0 @@
-// Copyright (C) 2019 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use size 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.
-
-import * as m from 'mithril';
-
-import {Actions} from '../common/actions';
-import {timeToCode, toNs} from '../common/time';
-
-import {globals} from './globals';
-import {Panel, PanelSize} from './panel';
-import {scrollToTrackAndTs} from './scroll_helper';
-
-
-export class ThreadStatePanel extends Panel {
-  view() {
-    const threadState = globals.threadStateDetails;
-    if (threadState === undefined || threadState.utid === undefined ||
-        threadState.ts === undefined || threadState.dur === undefined ||
-        threadState.state === undefined) {
-      return m('.details-panel');
-    }
-    const threadInfo = globals.threads.get(threadState.utid);
-    if (threadInfo) {
-      return m(
-          '.details-panel',
-          m('.details-panel-heading', m('h2', 'Thread State')),
-          m('.details-table', [m('table', [
-              m('tr',
-                m('th', `Start time`),
-                m('td', `${timeToCode(threadState.ts)}`)),
-              m('tr',
-                m('th', `Duration`),
-                m(
-                    'td',
-                    `${timeToCode(threadState.dur)} `,
-                    )),
-              m('tr',
-                m('th', `State`),
-                m('td',
-                  this.getStateContent(
-                      threadState.state,
-                      threadState.cpu,
-                      threadState.sliceId,
-                      threadState.ts))),
-              m('tr',
-                m('th', `Process`),
-                m('td', `${threadInfo.procName} [${threadInfo.pid}]`)),
-              this.getBlockedFunctionContent(threadState.blockedFunction),
-            ])]));
-    }
-    return m('.details-panel');
-  }
-
-  renderCanvas(_ctx: CanvasRenderingContext2D, _size: PanelSize) {}
-
-  // If it is the running state, we want to show which CPU and a button to
-  // go to the sched slice. Otherwise, just show the state.
-  getStateContent(
-      state: string, cpu: number|undefined, sliceId: number|undefined,
-      ts: number) {
-    if (sliceId === undefined || cpu === undefined) {
-      return [state];
-    }
-
-    return [
-      `${state} on CPU ${cpu}`,
-      m(
-          'i.material-icons.grey',
-          {
-            onclick: () => {
-              // TODO(hjd): Use trackId from TP.
-              let trackId;
-              for (const track of Object.values(globals.state.tracks)) {
-                if (track.kind === 'CpuSliceTrack' &&
-                    (track.config as {cpu: number}).cpu === cpu) {
-                  trackId = track.id;
-                }
-              }
-              if (trackId) {
-                globals.makeSelection(
-                    Actions.selectSlice({id: sliceId, trackId}));
-                scrollToTrackAndTs(
-                    trackId, toNs(ts + globals.state.traceTime.startSec));
-              }
-            },
-            title: 'Go to CPU slice',
-          },
-          'call_made'),
-    ];
-  }
-
-  getBlockedFunctionContent(blockedFunction: string|undefined) {
-    if (blockedFunction === undefined) {
-      return null;
-    }
-    return m('tr', m('th', `Blocked Function`), m('td', blockedFunction));
-  }
-}
diff --git a/ui/src/frontend/thread_state_tab.ts b/ui/src/frontend/thread_state_tab.ts
new file mode 100644
index 0000000..df8a775
--- /dev/null
+++ b/ui/src/frontend/thread_state_tab.ts
@@ -0,0 +1,75 @@
+// Copyright (C) 2023 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use size 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.
+
+import * as m from 'mithril';
+
+import {BottomTab, bottomTabRegistry, NewBottomTabArgs} from './bottom_tab';
+import {globals} from './globals';
+import {ThreadStateSqlId} from './sql_types';
+import {getThreadState, ThreadState, threadStateToDict} from './thread_state';
+import {renderDict} from './value';
+
+interface ThreadStateTabConfig {
+  // Id into |thread_state| sql table.
+  readonly id: ThreadStateSqlId;
+}
+
+export class ThreadStateTab extends BottomTab<ThreadStateTabConfig> {
+  static readonly kind = 'org.perfetto.ThreadStateTab';
+
+  state?: ThreadState;
+  loaded: boolean = false;
+
+  static create(args: NewBottomTabArgs): ThreadStateTab {
+    return new ThreadStateTab(args);
+  }
+
+  constructor(args: NewBottomTabArgs) {
+    super(args);
+
+    getThreadState(this.engine, this.config.id).then((state?: ThreadState) => {
+      this.loaded = true;
+      this.state = state;
+      globals.rafScheduler.scheduleFullRedraw();
+    });
+  }
+
+  getTitle() {
+    // TODO(altimin): Support dynamic titles here.
+    return 'Current Selection';
+  }
+
+  renderTabContents(): m.Child {
+    if (!this.loaded) {
+      return m('h2', 'Loading');
+    }
+    if (!this.state) {
+      return m('h2', `Thread state ${this.config.id} does not exist`);
+    }
+    return renderDict(threadStateToDict(this.state));
+  }
+
+  viewTab() {
+    // TODO(altimin): Create a reusable component for showing the header and
+    // differentiate between "Current Selection" and "Pinned" views.
+    return m(
+        'div.details-panel',
+        m('header.overview', m('span', 'Thread State')),
+        this.renderTabContents());
+  }
+
+  renderTabCanvas(): void {}
+}
+
+bottomTabRegistry.register(ThreadStateTab);
diff --git a/ui/src/frontend/value.ts b/ui/src/frontend/value.ts
new file mode 100644
index 0000000..2f14c07
--- /dev/null
+++ b/ui/src/frontend/value.ts
@@ -0,0 +1,189 @@
+// Copyright (C) 2023 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use size 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.
+
+import * as m from 'mithril';
+
+import {PopupMenuButton, PopupMenuItem} from './popup_menu';
+
+// This file implements a component for rendering JSON-like values (with
+// customisation options like context menu and action buttons).
+//
+// It defines the common Value, StringValue, DictValue, ArrayValue types,
+// to be used as an interchangeable format between different components
+// and `renderValue` function to convert DictValue into vdom nodes.
+
+// Leaf (non-dict and non-array) value which can be displayed to the user
+// together with the rendering customisation parameters.
+type StringValue = {
+  kind: 'STRING',
+  value: string,
+}&StringValueParams;
+
+// Helper function to create a StringValue from string together with optional
+// parameters.
+export function value(value: string, params?: StringValueParams): StringValue {
+  return {
+    kind: 'STRING',
+    value,
+    ...params,
+  };
+}
+
+// Helper function to convert a potentially undefined value to StringValue or
+// null.
+export function maybeValue(v?: string, params?: StringValueParams): StringValue|
+    null {
+  if (!v) {
+    return null;
+  }
+  return value(v, params);
+}
+
+// A basic type for the JSON-like value, comprising a primitive type (string)
+// and composite types (arrays and dicts).
+export type Value = StringValue|Array|Dict;
+
+// Dictionary type.
+export type Dict = {
+  kind: 'DICT',
+  items: {[name: string]: Value},
+}&ValueParams;
+
+// Helper function to simplify creation of an dictionary.
+// This function accepts and filters out nulls as values in the passed
+// dictionary (useful for simplifying the code to render optional values).
+export function dict(
+    items: {[name: string]: Value|null}, params?: ValueParams): Dict {
+  const result: {[name: string]: Value} = {};
+  for (const [name, value] of Object.entries(items)) {
+    if (value !== null) {
+      result[name] = value;
+    }
+  }
+  return {
+    kind: 'DICT',
+    items: result,
+    ...params,
+  };
+}
+
+// Array type.
+export type Array = {
+  kind: 'ARRAY', items: Value[];
+}&ValueParams;
+
+// Helper function to simplify creation of an array.
+// This function accepts and filters out nulls in the passed array (useful for
+// simplifying the code to render optional values).
+export function array(items: (Value|null)[], params?: ValueParams): Array {
+  return {
+    kind: 'ARRAY',
+    items: items.filter((item: Value|null) => item !== null) as Value[],
+    ...params,
+  };
+}
+
+// Parameters for displaying a button next to a value to perform
+// the context-dependent action (i.e. go to the corresponding slice).
+type ButtonParams = {
+  action: () => void;
+  hoverText?: string;
+  icon?: string;
+}
+
+// Customisation parameters which apply to any Value (e.g. context menu).
+interface ValueParams {
+  contextMenu?: PopupMenuItem[];
+}
+
+// Customisation parameters which apply for a primitive value (e.g. showing
+// button next to a string, or making it clickable, or adding onhover effect).
+interface StringValueParams extends ValueParams {
+  leftButton?: ButtonParams;
+  rightButton?: ButtonParams;
+}
+
+export function isArray(value: Value): value is Array {
+  return value.kind === 'ARRAY';
+};
+
+export function isDict(value: Value): value is Dict {
+  return value.kind === 'DICT';
+};
+
+export function isStringValue(value: Value): value is StringValue {
+  return !isArray(value) && !isDict(value);
+};
+
+// Recursively render the given value and its children, returning a list of
+// vnodes corresponding to the nodes of the table.
+function*
+    renderValue(name: string, value: Value, depth: number): Generator<m.Child> {
+  const row = [
+    m('th',
+      {
+        style: `padding-left: ${15 * depth}px`,
+      },
+      name,
+      value.contextMenu ? m(PopupMenuButton, {
+        icon: 'arrow_drop_down',
+        items: value.contextMenu,
+      }) :
+                          null),
+  ];
+  if (isArray(value)) {
+    yield m('tr', row);
+    for (let i = 0; i < value.items.length; ++i) {
+      yield* renderValue(`[${i}]`, value.items[i], depth + 1);
+    }
+    return;
+  } else if (isDict(value)) {
+    yield m('tr', row);
+    for (const key of Object.keys(value.items)) {
+      yield* renderValue(key, value.items[key], depth + 1);
+    }
+    return;
+  }
+  const renderButton = (button?: ButtonParams) => {
+    if (!button) {
+      return null;
+    }
+    return m(
+        'i.material-icons.grey',
+        {
+          onclick: button.action,
+          title: button.hoverText,
+        },
+        button.icon ? button.icon : 'call_made');
+  };
+  if (value.kind === 'STRING') {
+    row.push(
+        m('td',
+          renderButton(value.leftButton),
+          m('span', value.value),
+          renderButton(value.rightButton)));
+  }
+  yield m('tr', row);
+}
+
+// Render a given dictionary into a vnode.
+export function renderDict(dict: Dict): m.Child {
+  const rows: m.Child[] = [];
+  for (const key of Object.keys(dict.items)) {
+    for (const vnode of renderValue(key, dict.items[key], 0)) {
+      rows.push(vnode);
+    }
+  }
+  return m('table.auto-layout', rows);
+}