Repository: temporalio/ui
Pull request #162 (2021-12-08 22:30:12)

Expand events and add support for pending activities
Adds expanded detail view as well as updating the URL hash. Adds Font Awesome.


Patch:
diff --git a/package-lock.json b/package-lock.json
index d27da90e..7ecbed86 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -8,8 +8,10 @@
       "name": "temporal-ui",
       "version": "0.0.1",
       "dependencies": {
+        "@fortawesome/free-solid-svg-icons": "^5.15.4",
         "date-fns": "^2.23.0",
         "json-beautify": "^1.1.1",
+        "svelte-fa": "^2.4.0",
         "svelte-hero-icons": "^3.0.0",
         "url-pattern": "^1.0.3",
         "websocket-as-promised": "^2.0.1"
@@ -788,6 +790,27 @@
         "node": ">= 4"
       }
     },
+    "node_modules/@fortawesome/fontawesome-common-types": {
+      "version": "0.2.36",
+      "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-0.2.36.tgz",
+      "integrity": "sha512-a/7BiSgobHAgBWeN7N0w+lAhInrGxksn13uK7231n2m8EDPE3BMCl9NZLTGrj9ZXfCmC6LM0QLqXidIizVQ6yg==",
+      "hasInstallScript": true,
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/@fortawesome/free-solid-svg-icons": {
+      "version": "5.15.4",
+      "resolved": "https://registry.npmjs.org/@fortawesome/free-solid-svg-icons/-/free-solid-svg-icons-5.15.4.tgz",
+      "integrity": "sha512-JLmQfz6tdtwxoihXLg6lT78BorrFyCf59SAwBM6qV/0zXyVeDygJVb3fk+j5Qat+Yvcxp1buLTY5iDh1ZSAQ8w==",
+      "hasInstallScript": true,
+      "dependencies": {
+        "@fortawesome/fontawesome-common-types": "^0.2.36"
+      },
+      "engines": {
+        "node": ">=6"
+      }
+    },
     "node_modules/@grpc/grpc-js": {
       "version": "1.4.1",
       "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.4.1.tgz",
@@ -8545,6 +8568,11 @@
         "node": ">= 8"
       }
     },
+    "node_modules/svelte-fa": {
+      "version": "2.4.0",
+      "resolved": "https://registry.npmjs.org/svelte-fa/-/svelte-fa-2.4.0.tgz",
+      "integrity": "sha512-0bnbMGbsE1LUnlioDcf27tl2O8kjuXlTXMXzIxC7LoIOWmqn0D+zd539HfLiQbdLuOHGTaynwN9V+4ehhEu1Jw=="
+    },
     "node_modules/svelte-hero-icons": {
       "version": "3.0.0",
       "resolved": "https://registry.npmjs.org/svelte-hero-icons/-/svelte-hero-icons-3.0.0.tgz",
@@ -10214,6 +10242,19 @@
         }
       }
     },
+    "@fortawesome/fontawesome-common-types": {
+      "version": "0.2.36",
+      "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-0.2.36.tgz",
+      "integrity": "sha512-a/7BiSgobHAgBWeN7N0w+lAhInrGxksn13uK7231n2m8EDPE3BMCl9NZLTGrj9ZXfCmC6LM0QLqXidIizVQ6yg=="
+    },
+    "@fortawesome/free-solid-svg-icons": {
+      "version": "5.15.4",
+      "resolved": "https://registry.npmjs.org/@fortawesome/free-solid-svg-icons/-/free-solid-svg-icons-5.15.4.tgz",
+      "integrity": "sha512-JLmQfz6tdtwxoihXLg6lT78BorrFyCf59SAwBM6qV/0zXyVeDygJVb3fk+j5Qat+Yvcxp1buLTY5iDh1ZSAQ8w==",
+      "requires": {
+        "@fortawesome/fontawesome-common-types": "^0.2.36"
+      }
+    },
     "@grpc/grpc-js": {
       "version": "1.4.1",
       "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.4.1.tgz",
@@ -15984,6 +16025,11 @@
         }
       }
     },
+    "svelte-fa": {
+      "version": "2.4.0",
+      "resolved": "https://registry.npmjs.org/svelte-fa/-/svelte-fa-2.4.0.tgz",
+      "integrity": "sha512-0bnbMGbsE1LUnlioDcf27tl2O8kjuXlTXMXzIxC7LoIOWmqn0D+zd539HfLiQbdLuOHGTaynwN9V+4ehhEu1Jw=="
+    },
     "svelte-hero-icons": {
       "version": "3.0.0",
       "resolved": "https://registry.npmjs.org/svelte-hero-icons/-/svelte-hero-icons-3.0.0.tgz",
diff --git a/package.json b/package.json
index 8ec19302..cf0cfd8c 100644
--- a/package.json
+++ b/package.json
@@ -22,8 +22,10 @@
     "cypress:open": "TEMPORAL_API_HOST=http://localhost:3000 cypress open"
   },
   "dependencies": {
+    "@fortawesome/free-solid-svg-icons": "^5.15.4",
     "date-fns": "^2.23.0",
     "json-beautify": "^1.1.1",
+    "svelte-fa": "^2.4.0",
     "svelte-hero-icons": "^3.0.0",
     "url-pattern": "^1.0.3",
     "websocket-as-promised": "^2.0.1"
diff --git a/src/events.d.ts b/src/events.d.ts
index 045bf74f..424137e0 100644
--- a/src/events.d.ts
+++ b/src/events.d.ts
@@ -1,4 +1,5 @@
 type HistoryEvent = import('$types').HistoryEvent;
+
 type WorkflowExecutionStartedEventAttributes =
   import('$types').WorkflowExecutionStartedEventAttributes;
 type WorkflowExecutionCompletedEventAttributes =
@@ -302,7 +303,11 @@ type EventAttribute =
   | WorkflowTaskFailedAttrs
   | ChildWorkflowExecutionFailedAttrs;
 
-interface HistoryEventWithId extends HistoryEvent {
+interface HistoryEventWithId extends HistoryEvent, Record<string, unknown> {
   id: string;
   attributes: EventAttribute;
 }
+
+type PendingActivity = Omit<import('$types').PendingActivityInfo, 'state'> & {
+  state: 'Unspecified' | 'Scheduled' | 'Started' | 'CancelRequested';
+} & Record<string, unknown>;
diff --git a/src/lib/components/data-convertor-status.svelte b/src/lib/components/data-convertor-status.svelte
new file mode 100644
index 00000000..8ad596b6
--- /dev/null
+++ b/src/lib/components/data-convertor-status.svelte
@@ -0,0 +1,25 @@
+<script lang="ts">
+  import { StatusOnline, StatusOffline } from 'svelte-hero-icons';
+  import {
+    dataConverterPort,
+    lastDataConverterStatus,
+  } from '$lib/stores/data-converter-config';
+  import Icon from 'svelte-hero-icons/Icon.svelte';
+  import Tooltip from '$lib/components/tooltip.svelte';
+</script>
+
+{#if $dataConverterPort}
+  {#if $lastDataConverterStatus === 'notRequested'}
+    <Tooltip text={'Data converter is configured'}>
+      <Icon src={StatusOffline} class="mx-auto text-gray-400 w-8 h-8" />
+    </Tooltip>
+  {:else if $lastDataConverterStatus === 'error'}
+    <Tooltip text={`Data converter couldn't connect to the remote converter`}>
+      <Icon src={StatusOffline} class="mx-auto text-red-400 w-8 h-8" />
+    </Tooltip>
+  {:else if $lastDataConverterStatus === 'success'}
+    <Tooltip text={'Data converter succesfully converted content'}>
+      <Icon src={StatusOnline} class="mx-auto text-green-500 w-8 h-8" />
+    </Tooltip>
+  {/if}
+{/if}
diff --git a/src/lib/components/event-details.svelte b/src/lib/components/event-details.svelte
new file mode 100644
index 00000000..e3d3a3e2
--- /dev/null
+++ b/src/lib/components/event-details.svelte
@@ -0,0 +1,25 @@
+<script lang="ts">
+  import { format } from '$lib/utilities/format-camel-case';
+  import CodeBlock from './code-block.svelte';
+
+  export let attributes: EventAttribute | Record<string, unknown>;
+
+  console.log(attributes);
+</script>
+
+<section class="w-full px-8">
+  {#each Object.entries(attributes) as [key, value]}
+    <article class="w-full flex items-start py-4 border-t-2 border-gray-300">
+      <h4 class="w-1/3">{format(key)}</h4>
+      <div class="flex gap-2 w-full">
+        {#if value === null}
+          <CodeBlock content={value} inline={true} />
+        {:else if typeof value === 'object'}
+          <CodeBlock content={value} inline={true} />
+        {:else if value}
+          <p class="w-full label">{value}</p>
+        {/if}
+      </div>
+    </article>
+  {/each}
+</section>
diff --git a/src/lib/components/event-label.svelte b/src/lib/components/event-label.svelte
new file mode 100644
index 00000000..2da391c7
--- /dev/null
+++ b/src/lib/components/event-label.svelte
@@ -0,0 +1,43 @@
+<script lang="ts">
+  import type { EventClassification } from '$lib/utilities/get-event-classification';
+
+  export let color: EventClassification | PendingActivity['state'] = null;
+</script>
+
+<span class="label {color}">
+  <slot />
+</span>
+
+<style lang="postcss">
+  .label {
+    @apply px-2 text-gray-700 bg-gray-300;
+  }
+
+  .Open,
+  .New {
+    @apply text-indigo-700 bg-indigo-100;
+  }
+
+  .Started,
+  .Initiated {
+    @apply text-blue-700 bg-blue-100;
+  }
+
+  .Running,
+  .Completed,
+  .Fired {
+    @apply text-green-700 bg-green-100;
+  }
+
+  .CancelRequested,
+  .TimedOut,
+  .Signaled,
+  .Cancelled {
+    @apply text-yellow-700 bg-yellow-100;
+  }
+
+  .Failed,
+  .Terminated {
+    @apply text-red-700 bg-red-100;
+  }
+</style>
diff --git a/src/lib/components/event-summary-attributes.svelte b/src/lib/components/event-summary-attributes.svelte
new file mode 100644
index 00000000..8a0f9a5a
--- /dev/null
+++ b/src/lib/components/event-summary-attributes.svelte
@@ -0,0 +1,33 @@
+<script lang="ts">
+  import { format } from '$lib/utilities/format-camel-case';
+
+  import CodeBlock from './code-block.svelte';
+
+  export let attributes: Record<string, unknown>;
+</script>
+
+<div class="flex items-center event gap-4 w-full">
+  {#each Object.entries(attributes) as [attribute, value]}
+    {#if typeof value === 'object'}
+      <div class="flex gap-2 flex-nowrap">
+        <h4>{format(attribute)}</h4>
+        <CodeBlock content={value} inline={true} />
+      </div>
+    {:else if value}
+      <div class="flex gap-2 flex-nowrap">
+        <h4>{format(attribute)}</h4>
+        <p class="w-full label">{value}</p>
+      </div>
+    {/if}
+  {/each}
+</div>
+
+<style lang="postcss">
+  h4 {
+    @apply whitespace-nowrap;
+  }
+
+  .label {
+    @apply bg-gray-300 px-2 rounded-sm;
+  }
+</style>
diff --git a/src/lib/components/event-summary.svelte b/src/lib/components/event-summary.svelte
new file mode 100644
index 00000000..15e331a5
--- /dev/null
+++ b/src/lib/components/event-summary.svelte
@@ -0,0 +1,39 @@
+<script lang="ts">
+  import { onMount } from 'svelte';
+  import Icon from 'svelte-fa';
+  import { faAngleDown, faAngleUp } from '@fortawesome/free-solid-svg-icons';
+
+  import { goto } from '$app/navigation';
+
+  export let expanded = false;
+  export let hash: string;
+
+  onMount(() => {
+    expanded = window.location.hash === hash;
+  });
+
+  const expand = () => {
+    expanded = !expanded;
+    if (expanded) {
+      goto(hash);
+    }
+  };
+</script>
+
+<article
+  id={hash.slice(1)}
+  class="w-full py-2 my-4 border-2 rounded-lg relative"
+  on:click={expand}
+>
+  <div class="absolute right-6 top-7">
+    <Icon icon={expanded ? faAngleUp : faAngleDown} scale={1.2} />
+  </div>
+  <slot />
+  {#if expanded}<slot name="expanded" />{/if}
+</article>
+
+<style lang="postcss">
+  article:hover {
+    @apply cursor-pointer;
+  }
+</style>
diff --git a/src/lib/models/workflow-execution.ts b/src/lib/models/workflow-execution.ts
index 65b0fe18..22c52728 100644
--- a/src/lib/models/workflow-execution.ts
+++ b/src/lib/models/workflow-execution.ts
@@ -1,6 +1,5 @@
 import type {
   WorkflowExecutionStatus,
-  PendingActivityInfo,
   DescribeWorkflowExecutionResponse,
   ListWorkflowExecutionsResponse,
 } from '$types';
@@ -17,7 +16,7 @@ export type WorkflowExecution = {
   status: WorkflowExecutionStatus;
   taskQueue?: string;
   historyEvents: Long;
-  pendingActivities: PendingActivityInfo[];
+  pendingActivities: PendingActivity[];
   url: string;
 };
 
@@ -38,7 +37,8 @@ export const toWorkflowExecution = (
   const historyEvents = response.workflowExecutionInfo.historyLength;
   const url = `/workflows/${id}/${runId}`;
   const taskQueue = response?.executionConfig?.taskQueue?.name;
-  const pendingActivities = response.pendingActivities || [];
+  const pendingActivities: PendingActivity[] =
+    (response.pendingActivities as unknown as PendingActivity[]) || [];
 
   return {
     name,
diff --git a/src/lib/stores/data-converter-config.ts b/src/lib/stores/data-converter-config.ts
index e7f95b69..4dc80bd3 100644
--- a/src/lib/stores/data-converter-config.ts
+++ b/src/lib/stores/data-converter-config.ts
@@ -1,6 +1,8 @@
 import { browser } from '$app/env';
 import { writable } from 'svelte/store';
 
+import type { Writable } from 'svelte/store';
+
 export const dataConverterPort = persistStore('port', null);
 
 type DataConverterStatus = 'notRequested' | 'success' | 'error';
@@ -15,7 +17,10 @@ export function setLastDataConverterSuccess(): void {
   lastDataConverterStatus.set('success');
 }
 
-export function persistStore(name: string, initialValue: string | null = '') {
+export function persistStore(
+  name: string,
+  initialValue: string | null = '',
+): Pick<Writable<string>, 'subscribe' | 'set'> {
   let initialStoreValue = initialValue;
   if (browser) {
     try {
diff --git a/src/lib/utilities/format-camel-case.ts b/src/lib/utilities/format-camel-case.ts
index 8c90f7e0..87233ee5 100644
--- a/src/lib/utilities/format-camel-case.ts
+++ b/src/lib/utilities/format-camel-case.ts
@@ -1,9 +1,9 @@
-const isUpperCase = (label: string, index: number) => {
+const isUpperCase = (label: string, index: number): boolean => {
   const charCode = label.charCodeAt(index);
   return charCode >= 65 && charCode <= 90;
 };
 
-export const format = (label: string) => {
+export const format = (label: string): string => {
   let result = '';
   let index = 0;
 
diff --git a/src/lib/utilities/get-attributes-from-event.ts b/src/lib/utilities/get-attributes-from-event.ts
index 976898f3..7a2e0d50 100644
--- a/src/lib/utilities/get-attributes-from-event.ts
+++ b/src/lib/utilities/get-attributes-from-event.ts
@@ -1,12 +1,5 @@
 import * as eventTypes from './is-event-type';
-
-function pick<T, K extends keyof T>(source: T, ...keys: K[]): Pick<T, K> {
-  const result: any = {};
-  for (const key of keys) {
-    result[key] = source[key];
-  }
-  return result;
-}
+import { pick } from './pick';
 
 export const getAttributesFromEvent = (event: unknown) => {
   if (eventTypes.isActivityTaskCancelRequestedEvent(event)) {
diff --git a/src/lib/utilities/get-event-classification.ts b/src/lib/utilities/get-event-classification.ts
index c04602f3..a9b30064 100644
--- a/src/lib/utilities/get-event-classification.ts
+++ b/src/lib/utilities/get-event-classification.ts
@@ -1,4 +1,6 @@
-const eventClassifications = [
+export type EventClassification = typeof eventClassifications[number];
+
+export const eventClassifications = [
   'Scheduled',
   'Open',
   'New',
@@ -13,11 +15,11 @@ const eventClassifications = [
   'Canceled',
   'Failed',
   'Terminated',
-];
+] as const;
 
 export const getEventClassification = (
   event: HistoryEvent,
-): typeof eventClassifications[number] => {
+): EventClassification => {
   const eventType = event.eventType.toString();
 
   if (eventType.includes('RequestCancel')) return 'CancelRequested';
diff --git a/src/lib/utilities/pick.ts b/src/lib/utilities/pick.ts
new file mode 100644
index 00000000..2f71a450
--- /dev/null
+++ b/src/lib/utilities/pick.ts
@@ -0,0 +1,10 @@
+export const pick = <T, K extends keyof T>(
+  source: T,
+  ...keys: K[]
+): Pick<T, K> => {
+  const result: Partial<Pick<T, K>> = {};
+  for (const key of keys) {
+    result[key] = source[key];
+  }
+  return result as Pick<T, K>;
+};
diff --git a/src/routes/__layout.svelte b/src/routes/__layout.svelte
index 314df71f..bf94214c 100644
--- a/src/routes/__layout.svelte
+++ b/src/routes/__layout.svelte
@@ -6,6 +6,8 @@
     ListNamespacesResponse,
   } from '$types';
 
+  import '../app.postcss';
+
   export async function load({ fetch }: LoadInput) {
     const { namespaces }: ListNamespacesResponse = await requestFromAPI(
       '/namespaces',
@@ -20,10 +22,9 @@
 </script>
 
 <script lang="ts">
-  import '../app.postcss';
-  import Navigation from './_navigation.svelte';
-  import Header from './_header.svelte';
   import { setContext } from 'svelte';
+
+  import Header from './_header.svelte';
   import Notifications from '$lib/components/notifications.svelte';
 
   export let namespaces: DescribeNamespaceResponse[];
@@ -43,13 +44,10 @@
   <meta property="og:image" content="/banner.png" />
 </svelte:head>
 
-<main class="flex align-start h-screen">
+<main>
   <Notifications />
-  <Navigation />
-  <div class="w-full overflow-y-scroll">
-    <Header />
-    <div class="w-full relative">
-      <slot />
-    </div>
-  </div>
+  <Header />
+  <section id="content">
+    <slot />
+  </section>
 </main>
diff --git a/src/routes/_header.svelte b/src/routes/_header.svelte
index b2ae6e78..1b98f5ad 100644
--- a/src/routes/_header.svelte
+++ b/src/routes/_header.svelte
@@ -15,39 +15,17 @@
 
 <script lang="ts">
   import { namespace } from '$lib/stores/namespace';
-  import { StatusOnline, StatusOffline } from 'svelte-hero-icons';
-  import {
-    dataConverterPort,
-    lastDataConverterStatus,
-  } from '$lib/stores/data-converter-config';
-  import Icon from 'svelte-hero-icons/Icon.svelte';
-  import Tooltip from '$lib/components/tooltip.svelte';
+  import DataConvertorStatus from '$lib/components/data-convertor-status.svelte';
   export let user: { name?: string; email?: string; picture?: string } = {};
 </script>
 
 <div
   id="header"
-  class="h-16 w-full px-6 flex flex-row items-center justify-between static border-b-2"
+  class="h-16 w-full px-6 flex flex-row items-center justify-between border-b-2"
 >
   <NamespaceSelect />
   <div class="flex flex-row justify-center items-center">
-    {#if $dataConverterPort}
-      {#if $lastDataConverterStatus === 'notRequested'}
-        <Tooltip text={'Data converter is configured'}>
-          <Icon src={StatusOffline} class="mx-auto text-gray-400 w-8 h-8" />
-        </Tooltip>
-      {:else if $lastDataConverterStatus === 'error'}
-        <Tooltip
-          text={`Data converter couldn't connect to the remote converter`}
-        >
-          <Icon src={StatusOffline} class="mx-auto text-red-400 w-8 h-8" />
-        </Tooltip>
-      {:else if $lastDataConverterStatus === 'success'}
-        <Tooltip text={'Data converter succesfully converted content'}>
-          <Icon src={StatusOnline} class="mx-auto text-green-500 w-8 h-8" />
-        </Tooltip>
-      {/if}
-    {/if}
+    <DataConvertorStatus />
     <a href="https://github.com/temporalio/web/issues/new/choose">
       Report Bug/Give Feedback
     </a>
@@ -65,6 +43,7 @@
   a {
     @apply block px-4 py-2 text-sm;
   }
+
   a:hover {
     @apply bg-purple-100 rounded-md;
   }
diff --git a/src/routes/_navigation-link.svelte b/src/routes/_navigation-link.svelte
deleted file mode 100644
index 2e7b353d..00000000
--- a/src/routes/_navigation-link.svelte
+++ /dev/null
@@ -1,32 +0,0 @@
-<script lang="ts">
-  import { page } from '$app/stores';
-  import { pathMatches } from '$lib/utilities/path-matches';
-  import Icon from 'svelte-hero-icons/Icon.svelte';
-  import Tooltip from '$lib/components/tooltip.svelte';
-
-  export let href: string;
-  export let label: string;
-  export let icon: Heroicon;
-
-  $: isActive = pathMatches(href, $page.path);
-</script>
-
-<Tooltip text={label}>
-  <a {href} class:active={isActive} class="relative" data-tooltip={label}>
-    <Icon src={icon} class="mx-auto text-white w-8 h-8" />
-  </a>
-</Tooltip>
-
-<style lang="postcss">
-  a.active {
-    @apply bg-purple-900 text-white opacity-100;
-  }
-
-  a:hover {
-    @apply opacity-100;
-  }
-
-  a {
-    @apply no-underline opacity-70 rounded-lg text-center text-gray-400 text-xs;
-  }
-</style>
diff --git a/src/routes/_navigation.svelte b/src/routes/_navigation.svelte
deleted file mode 100644
index 10dbac43..00000000
--- a/src/routes/_navigation.svelte
+++ /dev/null
@@ -1,40 +0,0 @@
-<script lang="ts">
-  import { namespace } from '$lib/stores/namespace';
-
-  import {
-    Photograph,
-    Collection,
-    DocumentSearch,
-    Identification,
-    Home,
-  } from 'svelte-hero-icons';
-  import NavigationLink from './_navigation-link.svelte';
-</script>
-
-<nav
-  id="sidebar"
-  class="w-12 h-screen py-6 flex flex-col space-y-6 items-center bg-black"
->
-  <img src="/logo.svg" alt="Temporal Logo" class="rounded-full w-12" />
-  <NavigationLink
-    href={`/namespaces/${$namespace}/report`}
-    label="Home"
-    icon={Home}
-  />
-  <NavigationLink
-    href={`/namespaces/${$namespace}/workflows`}
-    label="Workflows"
-    icon={Photograph}
-  />
-  <NavigationLink href="/cluster" label="Cluster" icon={Collection} />
-  <NavigationLink
-    href="/search-attributes"
-    label="Search Attributes"
-    icon={DocumentSearch}
-  />
-  <NavigationLink
-    href={`/namespaces/${$namespace}/settings`}
-    label="Settings"
-    icon={Identification}
-  />
-</nav>
diff --git a/src/routes/namespaces/[namespace]/workflows/[workflow]/[run]/_event.svelte b/src/routes/namespaces/[namespace]/workflows/[workflow]/[run]/_event.svelte
index 8c7f8340..6699f97d 100644
--- a/src/routes/namespaces/[namespace]/workflows/[workflow]/[run]/_event.svelte
+++ b/src/routes/namespaces/[namespace]/workflows/[workflow]/[run]/_event.svelte
@@ -3,45 +3,32 @@
   import { getEventClassification } from '$lib/utilities/get-event-classification';
   import { format } from '$lib/utilities/format-camel-case';
 
-  import CodeBlock from '$lib/components/code-block.svelte';
+  import EventSummary from '$lib/components/event-summary.svelte';
+  import EventDetails from '$lib/components/event-details.svelte';
+  import EventSummaryAttributes from '$lib/components/event-summary-attributes.svelte';
+  import EventLabel from '$lib/components/event-label.svelte';
 
-  export let event: HistoryEvent;
+  export let event: HistoryEventWithId;
 
+  const { attributes } = event;
+
+  const hash = `#event-${event.id}`;
   const summaryEvent = getAttributesFromEvent(event);
 </script>
 
-<article class="flex items-start event-box border-2 p-4 rounded-lg">
-  <h2 class="w-1/3 flex-1 {event.eventType}">
-    <span class="label {getEventClassification(event)}">
-      {format(String(event.eventType))}
-    </span>
-  </h2>
-  <div class="flex items-center event gap-4 w-full">
-    {#each Object.entries(summaryEvent.attributes) as [attribute, value]}
-      {#if typeof value === 'object'}
-        <div class="flex gap-2 flex-nowrap">
-          <h4>{format(attribute)}</h4>
-          <CodeBlock content={value} inline={true} />
-        </div>
-      {:else if value}
-        <div class="flex gap-2 flex-nowrap">
-          <h4>{format(attribute)}</h4>
-          <p class="w-full label">{value}</p>
-        </div>
-      {/if}
-    {/each}
+<EventSummary {hash}>
+  <div class="flex items-start p-4 mx-4">
+    <h2 class="w-1/3 {event.eventType}">
+      <EventLabel color={getEventClassification(event)}>
+        {format(String(event.eventType))}
+      </EventLabel>
+    </h2>
+    <EventSummaryAttributes attributes={summaryEvent.attributes} />
   </div>
-</article>
+  <EventDetails {attributes} slot="expanded" />
+</EventSummary>
 
 <style lang="postcss">
-  h4 {
-    @apply whitespace-nowrap;
-  }
-
-  .event-box {
-    margin: 1rem 0;
-  }
-
   .label {
     @apply bg-gray-300 px-2 rounded-sm;
   }
diff --git a/src/routes/namespaces/[namespace]/workflows/[workflow]/[run]/_pending-activities.svelte b/src/routes/namespaces/[namespace]/workflows/[workflow]/[run]/_pending-activities.svelte
index 4652ae39..202a5e03 100644
--- a/src/routes/namespaces/[namespace]/workflows/[workflow]/[run]/_pending-activities.svelte
+++ b/src/routes/namespaces/[namespace]/workflows/[workflow]/[run]/_pending-activities.svelte
@@ -1,17 +1,15 @@
 <script lang="ts">
-  import type { PendingActivityInfo } from '$types';
+  import Activity from './_pending-activity.svelte';
 
-  import PendingActivity from './_pending-activity.svelte';
-
-  export let activities: PendingActivityInfo[];
+  export let activities: PendingActivity[];
   let showActivities = activities && activities.length;
 </script>
 
 {#if showActivities}
   <div class="mb-6 w-full">
-    <h3 class="text-lg mb-2">Pending Activities</h3>
+    <h3 class="text-lg mb-2 font-semibold">Pending Activities</h3>
     {#each activities as activity}
-      <PendingActivity {activity} />
+      <Activity {activity} />
     {/each}
   </div>
 {/if}
diff --git a/src/routes/namespaces/[namespace]/workflows/[workflow]/[run]/_pending-activity.svelte b/src/routes/namespaces/[namespace]/workflows/[workflow]/[run]/_pending-activity.svelte
index 791fd8c2..b2f7c83c 100644
--- a/src/routes/namespaces/[namespace]/workflows/[workflow]/[run]/_pending-activity.svelte
+++ b/src/routes/namespaces/[namespace]/workflows/[workflow]/[run]/_pending-activity.svelte
@@ -1,81 +1,35 @@
 <script lang="ts">
-  import beautify from 'json-beautify';
-  import type { PendingActivityInfo } from '$types';
-  import { formatDate } from '$lib/utilities/format-date';
+  import { pick } from '$lib/utilities/pick';
 
-  export let activity: PendingActivityInfo;
-</script>
-
-<article class="border-2 p-2">
-  <h4>
-    {activity.activityType.name}
-    <code>#{activity.activityId}</code>
-  </h4>
-  <section>
-    <div class="row">
-      <div class="key">State</div>
-      <div class="value">{activity.state}</div>
-    </div>
-    <div class="row">
-      <div class="key">Heartbeat Detaults</div>
-      <div class="value">{activity.heartbeatDetails}</div>
-    </div>
-    <div class="row">
-      <div class="key">Last Heartbeat Time</div>
-      <div class="value">{formatDate(activity.lastHeartbeatTime)}</div>
-    </div>
-    <div class="row">
-      <div class="key">Last Started Time</div>
-      <div class="value">{formatDate(activity.lastStartedTime)}</div>
-    </div>
-    <div class="row">
-      <div class="key">Attempt</div>
-      <div class="value">{activity.attempt}</div>
-    </div>
-    <div class="row">
-      <div class="key">Maximum Attempts</div>
-      <div class="value">{activity.maximumAttempts}</div>
-    </div>
-    <div class="row">
-      <div class="key">Scheduled Time</div>
-      <div class="value">{formatDate(activity.scheduledTime)}</div>
-    </div>
-    <div class="row">
-      <div class="key">Expiration Time</div>
-      <div class="value">{formatDate(activity.expirationTime)}</div>
-    </div>
-    <div class="row">
-      <div class="key">Last Failure</div>
-      <div class="value overflow-y-scroll whitespace-pre font-mono">
-        {beautify(activity.lastFailure, null, 2, 80)}
-      </div>
-    </div>
-    <div class="row">
-      <div class="key">Last Worker Identity</div>
-      <div class="value">{activity.lastWorkerIdentity}</div>
-    </div>
-  </section>
-</article>
+  import EventSummary from '$lib/components/event-summary.svelte';
+  import EventDetails from '$lib/components/event-details.svelte';
+  import EventSummaryAttributes from '$lib/components/event-summary-attributes.svelte';
+  import EventLabel from '$lib/components/event-label.svelte';
 
-<style lang="postcss">
-  h4 {
-    @apply mb-4 font-semibold;
-  }
+  export let activity: PendingActivity;
 
-  .row {
-    @apply flex p-4;
-  }
+  const summaryAttributes = pick<PendingActivity, keyof PendingActivity>(
+    activity,
+    'attempts',
+    'maximumAttempts',
+    'lastHeart',
+    'expiration',
+  );
 
-  .key,
-  .value {
-    @apply w-full;
-  }
-
-  .key {
-    @apply font-semibold max-w-sm;
-  }
+  const hash = `#pending-${activity.activityId}`;
+</script>
 
-  .row:nth-child(even) {
-    @apply bg-gray-100;
-  }
-</style>
+<EventSummary {hash}>
+  <div class="flex items-start p-4 mx-4">
+    <h2 class="w-1/3">
+      <EventLabel>
+        {activity?.activityType?.name}
+      </EventLabel>
+      <EventLabel color={activity.state}>
+        {activity.state}
+      </EventLabel>
+    </h2>
+    <EventSummaryAttributes attributes={summaryAttributes} />
+  </div>
+  <EventDetails attributes={activity} slot="expanded" />
+</EventSummary>
diff --git a/src/routes/namespaces/[namespace]/workflows/[workflow]/[run]/index.svelte b/src/routes/namespaces/[namespace]/workflows/[workflow]/[run]/index.svelte
index f40bd8e5..55461bea 100644
--- a/src/routes/namespaces/[namespace]/workflows/[workflow]/[run]/index.svelte
+++ b/src/routes/namespaces/[namespace]/workflows/[workflow]/[run]/index.svelte
@@ -5,7 +5,7 @@
   export async function load({ stuff, page }: LoadInput) {
     const { workflow, events } = stuff as {
       workflow: WorkflowExecution;
-      events: HistoryEvent[];
+      events: HistoryEventWithId[];
     };
     const { namespace } = page.params;
 
@@ -21,15 +21,12 @@
 
 <script lang="ts">
   import PendingActivities from './_pending-activities.svelte';
-  import CodeBlock from '$lib/components/code-block.svelte';
   import Event from './_event.svelte';
 
   export let workflow: WorkflowExecution;
-  export let events: HistoryEvent[];
+  export let events: HistoryEventWithId[];
 
-  let format: EventFormat = 'grid';
-
-  $: pendingActivities = workflow?.pendingActivities;
+  let pendingActivities = workflow?.pendingActivities;
 </script>
 
 <div class="execution-information px-6 py-6">
@@ -37,6 +34,7 @@
     <PendingActivities activities={pendingActivities} />
   </div>
   <section>
+    <h3 class="text-lg mb-2 font-semibold">Event History</h3>
     {#each events as event}
       <Event {event} />
     {/each}
diff --git a/src/routes/namespaces/[namespace]/workflows/index.svelte b/src/routes/namespaces/[namespace]/workflows/index.svelte
index 43b47a69..fae99041 100644
--- a/src/routes/namespaces/[namespace]/workflows/index.svelte
+++ b/src/routes/namespaces/[namespace]/workflows/index.svelte
@@ -39,25 +39,23 @@
   let timeFormat = 'relative';
 </script>
 
-<section class="flex items-start">
-  <div class="w-full h-screen overflow-scroll">
-    <header>
-      <WorkflowFilters bind:timeFormat />
-    </header>
-    {#await data}
-      <WorkflowsLoadingState />
-    {:then { workflows }}
-      {#if workflows.length}
-        <WorkflowsSummaryTable>
-          <tbody slot="rows">
-            {#each workflows as workflow}
-              <WorkflowsSummaryRow {workflow} {timeFormat} />
-            {/each}
-          </tbody>
-        </WorkflowsSummaryTable>
-      {:else}
-        <WorkflowsEmptyState />
-      {/if}
-    {/await}
-  </div>
+<section>
+  <header>
+    <WorkflowFilters bind:timeFormat />
+  </header>
+  {#await data}
+    <WorkflowsLoadingState />
+  {:then { workflows }}
+    {#if workflows.length}
+      <WorkflowsSummaryTable>
+        <tbody slot="rows">
+          {#each workflows as workflow}
+            <WorkflowsSummaryRow {workflow} {timeFormat} />
+          {/each}
+        </tbody>
+      </WorkflowsSummaryTable>
+    {:else}
+      <WorkflowsEmptyState />
+    {/if}
+  {/await}
 </section>


Test patch:
diff --git a/src/lib/utilities/pick.test.ts b/src/lib/utilities/pick.test.ts
new file mode 100644
index 00000000..5f1a8313
--- /dev/null
+++ b/src/lib/utilities/pick.test.ts
@@ -0,0 +1,8 @@
+import { pick } from './pick';
+
+describe(pick, () => {
+  it('should only keep the keys specified', () => {
+    const source = { a: 1, b: 2, c: 3 };
+    expect(pick(source, 'a', 'c')).toEqual({ a: 1, c: 3 });
+  });
+});