Repository: dxos/dxos
Pull request #9127: fix: contact table creation & automation navigation
Author: login: wittjosiah
Created: 2025-05-06T17:50:54Z | Merged: 2025-05-06T19:55:48Z



Diff:
diff --git a/.github/workflows/env/labs b/.github/workflows/env/labs
index eab0ff762c5..c9cadc22b92 100644
--- a/.github/workflows/env/labs
+++ b/.github/workflows/env/labs
@@ -22,5 +22,5 @@ DX_PWA=false
 DX_MINIFY=true
 DX_EDGE_ECHO_REPLICATOR=true
 DX_LABS=true
-DX_EDGE_BASE_URL="https://edge-labs.dxos.workers.dev/"
+DX_EDGE_BASE_URL="https://edge-production.dxos.workers.dev/"
 DX_EDGE_AI_SERVICE_URL="https://ai-service-labs.dxos.workers.dev" # MUST NOT END WITH /
diff --git a/packages/plugins/plugin-automation/src/capabilities/intent-resolver.ts b/packages/plugins/plugin-automation/src/capabilities/intent-resolver.ts
index aa4d2961af3..fdc24b143f6 100644
--- a/packages/plugins/plugin-automation/src/capabilities/intent-resolver.ts
+++ b/packages/plugins/plugin-automation/src/capabilities/intent-resolver.ts
@@ -2,10 +2,18 @@
 // Copyright 2025 DXOS.org
 //
 
-import { contributes, Capabilities, createResolver, type PluginsContext, createIntent } from '@dxos/app-framework';
+import {
+  contributes,
+  Capabilities,
+  createResolver,
+  type PluginsContext,
+  createIntent,
+  LayoutAction,
+} from '@dxos/app-framework';
 import { FunctionTrigger, FunctionType, ScriptType, TriggerKind } from '@dxos/functions';
 import { type DXN } from '@dxos/keys';
 import { live } from '@dxos/live-object';
+import { ATTENDABLE_PATH_SEPARATOR } from '@dxos/plugin-deck/types';
 import { SpaceAction } from '@dxos/plugin-space/types';
 import { Filter } from '@dxos/react-client/echo';
 
@@ -54,7 +62,13 @@ export default (context: PluginsContext) =>
         return {
           intents: [
             createIntent(SpaceAction.AddObject, { object: trigger, target: space }),
-            createIntent(SpaceAction.OpenSettings, { space }),
+            createIntent(LayoutAction.Open, {
+              part: 'main',
+              subject: [`automation-settings${ATTENDABLE_PATH_SEPARATOR}${space.id}`],
+              options: {
+                workspace: space.id,
+              },
+            }),
           ],
         };
       },
diff --git a/packages/plugins/plugin-inbox/package.json b/packages/plugins/plugin-inbox/package.json
index 809c0c44de8..2ddf68c30b6 100644
--- a/packages/plugins/plugin-inbox/package.json
+++ b/packages/plugins/plugin-inbox/package.json
@@ -84,6 +84,7 @@
     "@types/lodash.get": "^4.4.7",
     "@types/react": "~18.2.0",
     "@types/react-dom": "~18.2.0",
+    "effect": "3.13.3",
     "react": "~18.2.0",
     "react-dom": "~18.2.0",
     "vite": "5.4.7"
@@ -92,6 +93,7 @@
     "@dxos/react-ui": "workspace:*",
     "@dxos/react-ui-theme": "workspace:*",
     "@phosphor-icons/react": "^2.1.5",
+    "effect": "3.13.3",
     "react": "~18.2.0",
     "react-dom": "~18.2.0"
   },
diff --git a/packages/plugins/plugin-inbox/src/capabilities/intent-resolver.ts b/packages/plugins/plugin-inbox/src/capabilities/intent-resolver.ts
index 9b122f42b1c..6bf4714e651 100644
--- a/packages/plugins/plugin-inbox/src/capabilities/intent-resolver.ts
+++ b/packages/plugins/plugin-inbox/src/capabilities/intent-resolver.ts
@@ -2,11 +2,21 @@
 // Copyright 2025 DXOS.org
 //
 
-import { contributes, Capabilities, createResolver, type PluginsContext, createIntent } from '@dxos/app-framework';
+import { pipe } from 'effect';
+
+import {
+  contributes,
+  Capabilities,
+  createResolver,
+  type PluginsContext,
+  createIntent,
+  chain,
+} from '@dxos/app-framework';
 import { ObjectId } from '@dxos/echo-schema';
 import { QueueSubspaceTags, DXN } from '@dxos/keys';
 import { live, refFromDXN } from '@dxos/live-object';
 import { log } from '@dxos/log';
+import { SpaceAction } from '@dxos/plugin-space/types';
 import { TableAction } from '@dxos/plugin-table';
 import { Filter, makeRef } from '@dxos/react-client/echo';
 import { TableType } from '@dxos/react-ui-table';
@@ -126,11 +136,14 @@ export default (context: PluginsContext) =>
           log.info('No table found for contacts, creating one.');
           return {
             intents: [
-              createIntent(TableAction.Create, {
-                space,
-                name: 'Contacts',
-                typename: Contact.typename,
-              }),
+              pipe(
+                createIntent(TableAction.Create, {
+                  space,
+                  name: 'Contacts',
+                  typename: Contact.typename,
+                }),
+                chain(SpaceAction.AddObject, { target: space }),
+              ),
             ],
           };
         }
diff --git a/packages/plugins/plugin-inbox/src/capabilities/react-surface.tsx b/packages/plugins/plugin-inbox/src/capabilities/react-surface.tsx
index d8605e8df8c..3d037945080 100644
--- a/packages/plugins/plugin-inbox/src/capabilities/react-surface.tsx
+++ b/packages/plugins/plugin-inbox/src/capabilities/react-surface.tsx
@@ -2,18 +2,27 @@
 // Copyright 2025 DXOS.org
 //
 
-import React from 'react';
+import { pipe } from 'effect';
+import React, { useCallback } from 'react';
 
-import { Capabilities, contributes, createSurface } from '@dxos/app-framework';
+import {
+  Capabilities,
+  chain,
+  contributes,
+  createIntent,
+  createSurface,
+  LayoutAction,
+  useIntentDispatcher,
+} from '@dxos/app-framework';
 import { isInstanceOf } from '@dxos/echo-schema';
-import { Filter, getSpace, useQuery, useQueue, useSpace } from '@dxos/react-client/echo';
-import { useTranslation } from '@dxos/react-ui';
+import { Filter, fullyQualifiedId, getSpace, useQuery, useQueue, useSpace } from '@dxos/react-client/echo';
+import { TableType } from '@dxos/react-ui-table';
 import { Contact, MessageType, Organization } from '@dxos/schema';
 
 import { EventsContainer, MailboxContainer, MessageContainer, MailboxObjectSettings } from '../components';
 import { RelatedContacts, RelatedMessages } from '../components/Related';
 import { INBOX_PLUGIN } from '../meta';
-import { CalendarType, MailboxType } from '../types';
+import { CalendarType, InboxAction, MailboxType } from '../types';
 
 export default () =>
   contributes(Capabilities.ReactSurface, [
@@ -31,13 +40,13 @@ export default () =>
         isInstanceOf(MailboxType, data.companionTo) &&
         (data.subject === 'message' || isInstanceOf(MessageType, data.subject)),
       component: ({ data: { companionTo, subject: message } }) => {
-        const { t } = useTranslation(INBOX_PLUGIN);
         const space = getSpace(companionTo);
-        return typeof message === 'string' ? (
-          // TODO(burdon): Move into message container.
-          <p className='p-8 text-center text-description'>{t('no message message')}</p>
-        ) : (
-          <MessageContainer message={message} space={space} inMailbox={companionTo} />
+        return (
+          <MessageContainer
+            message={typeof message === 'string' ? undefined : message}
+            space={space}
+            inMailbox={companionTo}
+          />
         );
       },
     }),
@@ -53,11 +62,14 @@ export default () =>
       filter: (data): data is { subject: MailboxType } => isInstanceOf(MailboxType, data.subject),
       component: ({ data }) => <MailboxObjectSettings object={data.subject} />,
     }),
+
+    // TODO(wittjosiah): Generalize the mess below.
     createSurface({
       id: `${INBOX_PLUGIN}/contact-related`,
       role: 'related',
       filter: (data): data is { subject: Contact } => isInstanceOf(Contact, data.subject),
       component: ({ data: { subject: contact } }) => {
+        const { dispatchPromise: dispatch } = useIntentDispatcher();
         const space = useSpace();
         const [mailbox] = useQuery(space, Filter.schema(MailboxType));
         const queue = useQueue<MessageType>(mailbox?.queue.dxn);
@@ -68,8 +80,27 @@ export default () =>
               contact.emails?.some((email) => email.value === message.sender.email) ||
               message.sender.contact?.target === contact,
           )
-          .filter((message) => message.properties?.subject);
-        return <RelatedMessages messages={related} />;
+          .filter((message) => message.properties?.subject)
+          .toSorted((a, b) => new Date(b.created).getTime() - new Date(a.created).getTime())
+          .slice(0, 5);
+
+        const handleSelect = useCallback(
+          (message: MessageType) => {
+            void dispatch(
+              pipe(
+                createIntent(LayoutAction.Open, {
+                  part: 'main',
+                  subject: [fullyQualifiedId(mailbox)],
+                  options: { workspace: space?.id },
+                }),
+                chain(InboxAction.SelectMessage, { mailboxId: fullyQualifiedId(mailbox), message }),
+              ),
+            );
+          },
+          [dispatch, space, mailbox],
+        );
+
+        return <RelatedMessages messages={related} onSelect={handleSelect} />;
       },
     }),
     createSurface({
@@ -77,12 +108,52 @@ export default () =>
       role: 'related',
       filter: (data): data is { subject: Organization } => isInstanceOf(Organization, data.subject),
       component: ({ data: { subject: organization } }) => {
-        const space = useSpace();
-        const contacts = useQuery(space, Filter.schema(Contact));
+        const { dispatchPromise: dispatch } = useIntentDispatcher();
+        const space = getSpace(organization);
+        const defaultSpace = useSpace();
+        const currentSpaceContacts = useQuery(space, Filter.schema(Contact));
+        const defaultSpaceContacts = useQuery(
+          defaultSpace === space ? undefined : defaultSpace,
+          Filter.schema(Contact),
+        );
+        const contacts = [...(currentSpaceContacts ?? []), ...(defaultSpaceContacts ?? [])];
         const related = contacts.filter((contact) =>
           typeof contact.organization === 'string' ? false : contact.organization?.target === organization,
         );
-        return <RelatedContacts contacts={related} />;
+
+        const currentSpaceTables = useQuery(space, Filter.schema(TableType));
+        const defaultSpaceTables = useQuery(defaultSpace, Filter.schema(TableType));
+        const currentSpaceContactTable = currentSpaceTables?.find((table) => {
+          return table.view?.target?.query?.typename === Contact.typename;
+        });
+        const defaultSpaceContactTable = defaultSpaceTables?.find((table) => {
+          return table.view?.target?.query?.typename === Contact.typename;
+        });
+
+        const handleSelect = useCallback(
+          (contact: Contact) => {
+            if (currentSpaceContacts.includes(contact)) {
+              void dispatch(
+                createIntent(LayoutAction.Open, {
+                  part: 'main',
+                  subject: [fullyQualifiedId(currentSpaceContactTable)],
+                  options: { workspace: space?.id },
+                }),
+              );
+            } else {
+              void dispatch(
+                createIntent(LayoutAction.Open, {
+                  part: 'main',
+                  subject: [fullyQualifiedId(defaultSpaceContactTable)],
+                  options: { workspace: defaultSpace?.id },
+                }),
+              );
+            }
+          },
+          [dispatch, currentSpaceContacts, currentSpaceContactTable, defaultSpaceContactTable, space, defaultSpace],
+        );
+
+        return <RelatedContacts contacts={related} onSelect={handleSelect} />;
       },
     }),
   ]);
diff --git a/packages/plugins/plugin-inbox/src/components/Message/Message.tsx b/packages/plugins/plugin-inbox/src/components/Message/Message.tsx
index dcf0000c885..a53e56e022d 100644
--- a/packages/plugins/plugin-inbox/src/components/Message/Message.tsx
+++ b/packages/plugins/plugin-inbox/src/components/Message/Message.tsx
@@ -2,7 +2,7 @@
 // Copyright 2025 DXOS.org
 //
 
-import React, { useMemo } from 'react';
+import React, { forwardRef, useMemo } from 'react';
 
 import { useClient } from '@dxos/react-client';
 import { type Space } from '@dxos/react-client/echo';
@@ -26,44 +26,47 @@ export type MessageProps = ThemedClassName<{
   message: MessageType;
   viewMode: ViewMode;
   hasEnrichedContent: boolean;
+  onSenderClick?: () => void;
 }>;
 
-export const Message = ({ space, message, viewMode, hasEnrichedContent, classNames }: MessageProps) => {
-  const client = useClient();
-  const { themeMode } = useThemeContext();
+export const Message = forwardRef<HTMLDivElement, MessageProps>(
+  ({ space, message, viewMode, hasEnrichedContent, onSenderClick, classNames }, ref) => {
+    const client = useClient();
+    const { themeMode } = useThemeContext();
 
-  const content = useMemo(() => {
-    const textBlocks = message.blocks.filter((block) => 'text' in block);
-    // If we're in plain-only mode or plain view, show the first block.
-    if (viewMode === 'plain-only' || viewMode === 'plain') {
-      return textBlocks[0]?.text || '';
-    }
-    // Otherwise show enriched content (second block).
-    return textBlocks[1]?.text || '';
-  }, [message.blocks, viewMode]);
+    const content = useMemo(() => {
+      const textBlocks = message.blocks.filter((block) => 'text' in block);
+      // If we're in plain-only mode or plain view, show the first block.
+      if (viewMode === 'plain-only' || viewMode === 'plain') {
+        return textBlocks[0]?.text || '';
+      }
+      // Otherwise show enriched content (second block).
+      return textBlocks[1]?.text || '';
+    }, [message.blocks, viewMode]);
 
-  // TODO(ZaymonFC): How to prevent caret and selection?
-  const extensions = useMemo(() => {
-    if (space) {
-      return [
-        createBasicExtensions({ readOnly: true, lineWrapping: true }),
-        createMarkdownExtensions({ themeMode }),
-        createThemeExtensions({ themeMode }),
-        decorateMarkdown(),
-        preview(),
-      ];
-    }
-    return [];
-  }, [space, client, themeMode]);
+    // TODO(ZaymonFC): How to prevent caret and selection?
+    const extensions = useMemo(() => {
+      if (space) {
+        return [
+          createBasicExtensions({ readOnly: true, lineWrapping: true }),
+          createMarkdownExtensions({ themeMode }),
+          createThemeExtensions({ themeMode }),
+          decorateMarkdown(),
+          preview(),
+        ];
+      }
+      return [];
+    }, [space, client, themeMode]);
 
-  const { parentRef } = useTextEditor({ initialValue: content, extensions }, [content, extensions]);
+    const { parentRef } = useTextEditor({ initialValue: content, extensions }, [content, extensions]);
 
-  return (
-    <div role='none' className='grid grid-rows-[min-content_1fr] relative h-full overflow-hidden'>
-      <MessageHeader message={message} viewMode={viewMode} />
-      <div role='none' className='overflow-y-auto h-full min-h-0 p-2'>
-        <div ref={parentRef} className={mx(classNames)} />
+    return (
+      <div role='none' className='grid grid-rows-[min-content_1fr] relative h-full overflow-hidden'>
+        <MessageHeader ref={ref} message={message} viewMode={viewMode} onSenderClick={onSenderClick} />
+        <div role='none' className='overflow-y-auto h-full min-h-0 p-2'>
+          <div ref={parentRef} className={mx(classNames)} />
+        </div>
       </div>
-    </div>
-  );
-};
+    );
+  },
+);
diff --git a/packages/plugins/plugin-inbox/src/components/Message/MessageContainer.tsx b/packages/plugins/plugin-inbox/src/components/Message/MessageContainer.tsx
index e175c1ca472..57b7a7100d8 100644
--- a/packages/plugins/plugin-inbox/src/components/Message/MessageContainer.tsx
+++ b/packages/plugins/plugin-inbox/src/components/Message/MessageContainer.tsx
@@ -3,11 +3,11 @@
 //
 
 import { useComputed, useSignal } from '@preact/signals-react';
-import React, { useMemo, useCallback, useEffect } from 'react';
+import React, { useMemo, useCallback, useEffect, useRef } from 'react';
 
-import { createIntent, useIntentDispatcher } from '@dxos/app-framework';
+import { createIntent, LayoutAction, useIntentDispatcher } from '@dxos/app-framework';
 import { fullyQualifiedId, type Space, Filter, useQuery } from '@dxos/react-client/echo';
-import { ElevationProvider } from '@dxos/react-ui';
+import { ElevationProvider, useTranslation } from '@dxos/react-ui';
 import { stackItemContentToolbarClassNames } from '@dxos/react-ui-editor';
 import { MenuProvider, ToolbarMenu } from '@dxos/react-ui-menu';
 import { type MenuActionHandler } from '@dxos/react-ui-menu';
@@ -17,17 +17,21 @@ import { type MessageType, Contact } from '@dxos/schema';
 import { Message } from './Message';
 import { type ViewMode } from './MessageHeader';
 import { useMessageToolbarActions, type MessageToolbarAction } from './toolbar';
+import { INBOX_PLUGIN } from '../../meta';
 import { type MailboxType, InboxAction } from '../../types';
 
 export type MessageContainerProps = {
   space?: Space;
-  message: MessageType;
+  message?: MessageType;
   inMailbox: MailboxType;
 };
 
 export const MessageContainer = ({ space, message, inMailbox }: MessageContainerProps) => {
+  const { t } = useTranslation(INBOX_PLUGIN);
+  const senderRef = useRef<HTMLDivElement>(null);
+
   const hasEnrichedContent = useMemo(() => {
-    const textBlocks = message.blocks.filter((block) => 'text' in block);
+    const textBlocks = message?.blocks.filter((block) => 'text' in block) ?? [];
     return textBlocks.length > 1 && !!textBlocks[1]?.text;
   }, [message]);
 
@@ -37,15 +41,15 @@ export const MessageContainer = ({ space, message, inMailbox }: MessageContainer
 
   const viewMode = useSignal<ViewMode>(initialViewMode);
 
-  const hasEmail = useComputed(() => !!message.sender.email);
+  const hasEmail = useComputed(() => !!message?.sender.email);
   const contacts = useQuery(space, Filter.schema(Contact));
   const existingContact = useSignal<Contact | undefined>(undefined);
 
   useEffect(() => {
     existingContact.value = contacts.find((contact) =>
-      contact.emails?.find((email) => email.value === message.sender.email),
+      contact.emails?.find((email) => email.value === message?.sender.email),
     );
-  }, [contacts, message.sender.email, hasEmail, existingContact]);
+  }, [contacts, message?.sender.email, hasEmail, existingContact]);
 
   const { dispatchPromise: dispatch } = useIntentDispatcher();
 
@@ -59,7 +63,7 @@ export const MessageContainer = ({ space, message, inMailbox }: MessageContainer
           break;
         }
         case 'extractContact': {
-          if (!space) {
+          if (!space || !message) {
             return;
           }
           void dispatch(createIntent(InboxAction.ExtractContact, { space, message }));
@@ -70,6 +74,22 @@ export const MessageContainer = ({ space, message, inMailbox }: MessageContainer
     [viewMode, message, space, dispatch],
   );
 
+  const handleSenderClick = useCallback(() => {
+    if (existingContact.value && message && senderRef.current) {
+      void dispatch(
+        createIntent(LayoutAction.UpdatePopover, {
+          part: 'popover',
+          subject: existingContact.value,
+          options: { state: true, variant: 'virtual', anchor: senderRef.current },
+        }),
+      );
+    }
+  }, [existingContact, message]);
+
+  if (!message) {
+    return <p className='p-8 text-center text-description'>{t('no message message')}</p>;
+  }
+
   return (
     <StackItem.Content classNames='relative'>
       <div role='none' className='grid grid-rows-[min-content_1fr]'>
@@ -80,7 +100,14 @@ export const MessageContainer = ({ space, message, inMailbox }: MessageContainer
             </MenuProvider>
           </ElevationProvider>
         </div>
-        <Message space={space} message={message} viewMode={viewMode.value} hasEnrichedContent={hasEnrichedContent} />
+        <Message
+          ref={senderRef}
+          space={space}
+          message={message}
+          viewMode={viewMode.value}
+          hasEnrichedContent={hasEnrichedContent}
+          onSenderClick={handleSenderClick}
+        />
       </div>
     </StackItem.Content>
   );
diff --git a/packages/plugins/plugin-inbox/src/components/Message/MessageHeader.tsx b/packages/plugins/plugin-inbox/src/components/Message/MessageHeader.tsx
index b20dc486e40..aba57a10ae9 100644
--- a/packages/plugins/plugin-inbox/src/components/Message/MessageHeader.tsx
+++ b/packages/plugins/plugin-inbox/src/components/Message/MessageHeader.tsx
@@ -2,7 +2,7 @@
 // Copyright 2025 DXOS.org
 //
 
-import React from 'react';
+import React, { forwardRef } from 'react';
 
 import { Avatar } from '@dxos/react-ui';
 import { type ThemedClassName, useTranslation } from '@dxos/react-ui';
@@ -17,42 +17,49 @@ export type ViewMode = 'plain' | 'enriched' | 'plain-only';
 export type MessageHeaderProps = ThemedClassName<{
   message: MessageType;
   viewMode?: ViewMode;
+  // TODO(wittjosiah): This should probably be on hover.
+  onSenderClick?: () => void;
 }>;
 
-export const MessageHeader = ({ message, viewMode, classNames }: MessageHeaderProps) => {
-  const { t } = useTranslation(INBOX_PLUGIN);
+// TODO(wittjosiah): Is there a better way hook up the popover than ref drilling?
+export const MessageHeader = forwardRef<HTMLDivElement, MessageHeaderProps>(
+  ({ message, viewMode, onSenderClick }, ref) => {
+    const { t } = useTranslation(INBOX_PLUGIN);
 
-  return (
-    <div className='grid grid-flow-row pli-2 plb-2 bs-[56px] gap-2 min-bs-0 border-be border-separator'>
-      <div className='grid grid-cols-[auto_1fr_auto] gap-x-3'>
-        <Avatar.Root>
-          <Avatar.Content
-            hue={message.sender.name ? toHue(hashString(message.sender.name)) : undefined}
-            hueVariant='surface'
-            variant='square'
-            size={8}
-            fallback={message.sender.name ? getFirstTwoRenderableChars(message.sender.name).join('') : '?'}
-          />
-          <Avatar.Label srOnly>{message.sender.name || 'Unknown'}</Avatar.Label>
-        </Avatar.Root>
-        <div className='grid gap-0.5 self-center'>
-          <h3 className='truncate'>{message.sender.name || 'Unknown'}</h3>
-          {message.sender.email && <div className='text-xs text-description truncate'>{message.sender.email}</div>}
-        </div>
-        <div className='grid gap-1 justify-items-end'>
-          <div className='text-xs text-description'>
-            {message.created && formatDate(new Date(), new Date(message.created))}
+    return (
+      <div className='grid grid-flow-row pli-2 plb-2 bs-[56px] gap-2 min-bs-0 border-be border-separator'>
+        <div ref={ref} className='grid grid-cols-[auto_1fr_auto] gap-x-3'>
+          <Avatar.Root>
+            <Avatar.Content
+              hue={message.sender.name ? toHue(hashString(message.sender.name)) : undefined}
+              hueVariant='surface'
+              variant='square'
+              size={8}
+              fallback={message.sender.name ? getFirstTwoRenderableChars(message.sender.name).join('') : '?'}
+              classNames='cursor-pointer'
+              onClick={onSenderClick}
+            />
+            <Avatar.Label srOnly>{message.sender.name || 'Unknown'}</Avatar.Label>
+          </Avatar.Root>
+          <div className='grid gap-0.5 self-center cursor-pointer' onClick={onSenderClick}>
+            <h3 className='truncate'>{message.sender.name || 'Unknown'}</h3>
+            {message.sender.email && <div className='text-xs text-description truncate'>{message.sender.email}</div>}
           </div>
-          {/* View mode indicator */}
-          {viewMode && (
-            <div className='dx-tag' data-hue={viewMode === 'enriched' ? 'emerald' : 'neutral'}>
-              {viewMode === 'plain' && t('message header view mode plain')}
-              {viewMode === 'enriched' && t('message header view mode enriched')}
-              {viewMode === 'plain-only' && t('message header view mode plain only')}
+          <div className='grid gap-1 justify-items-end'>
+            <div className='text-xs text-description'>
+              {message.created && formatDate(new Date(), new Date(message.created))}
             </div>
-          )}
+            {/* View mode indicator */}
+            {viewMode && (
+              <div className='dx-tag' data-hue={viewMode === 'enriched' ? 'emerald' : 'neutral'}>
+                {viewMode === 'plain' && t('message header view mode plain')}
+                {viewMode === 'enriched' && t('message header view mode enriched')}
+                {viewMode === 'plain-only' && t('message header view mode plain only')}
+              </div>
+            )}
+          </div>
         </div>
       </div>
-    </div>
-  );
-};
+    );
+  },
+);
diff --git a/packages/plugins/plugin-inbox/src/components/Related.tsx b/packages/plugins/plugin-inbox/src/components/Related.tsx
index e7ba05e58f1..79af2ee0c74 100644
--- a/packages/plugins/plugin-inbox/src/components/Related.tsx
+++ b/packages/plugins/plugin-inbox/src/components/Related.tsx
@@ -11,7 +11,13 @@ import { type Contact, type MessageType } from '@dxos/schema';
 
 import { INBOX_PLUGIN } from '../meta';
 
-export const RelatedContacts = ({ contacts }: { contacts: Contact[] }) => {
+export const RelatedContacts = ({
+  contacts,
+  onSelect,
+}: {
+  contacts: Contact[];
+  onSelect?: (contact: Contact) => void;
+}) => {
   const { t } = useTranslation(INBOX_PLUGIN);
   return contacts.length ? (
     <>
@@ -21,8 +27,8 @@ export const RelatedContacts = ({ contacts }: { contacts: Contact[] }) => {
       <ul className={previewChrome}>
         {contacts.map((contact) => (
           <Avatar.Root key={contact.id}>
-            {/* TODO(thure): This should become a link. */}
-            <li className='dx-button gap-2 mbe-1 last:mbe-0'>
+            {/* TODO(thure): This should use a button. */}
+            <li className='dx-button gap-2 mbe-1 last:mbe-0' onClick={() => onSelect?.(contact)}>
               <Avatar.Content
                 hue='neutral'
                 size={5}
@@ -40,7 +46,13 @@ export const RelatedContacts = ({ contacts }: { contacts: Contact[] }) => {
   ) : null;
 };
 
-export const RelatedMessages = ({ messages }: { messages: MessageType[] }) => {
+export const RelatedMessages = ({
+  messages,
+  onSelect,
+}: {
+  messages: MessageType[];
+  onSelect?: (message: MessageType) => void;
+}) => {
   const { t } = useTranslation(INBOX_PLUGIN);
   return messages.length ? (
     <>
@@ -48,8 +60,13 @@ export const RelatedMessages = ({ messages }: { messages: MessageType[] }) => {
         {t('related messages title')}
       </h3>
       <ul className={previewChrome}>
+        {/* TODO(thure): This should use a button. */}
         {messages.map((message) => (
-          <li key={message.id} className='dx-button font-normal gap-2 mbe-1 last:mbe-0'>
+          <li
+            key={message.id}
+            className='dx-button font-normal gap-2 mbe-1 last:mbe-0'
+            onClick={() => onSelect?.(message)}
+          >
             <Icon icon='ph--envelope-simple--regular' classNames='mli-0.5' />
             <p className='min-is-0 flex-1 truncate'>{message.properties?.subject}</p>
             <Icon icon='ph--arrow-right--regular' />
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 395b24f6ce5..d6b03ecd0c4 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -6776,6 +6776,9 @@ importers:
       '@types/react-dom':
         specifier: ~18.2.0
         version: 18.2.25
+      effect:
+        specifier: 3.13.3
+        version: 3.13.3
       react:
         specifier: ~18.2.0
         version: 18.2.0


Reviews:
user: login: zaymonoid
body: Nice!
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-06T18:03:19Z
user: login: thure
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-06T18:14:59Z
user: login: thure
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-06T18:15:09Z
user: login: thure
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-06T18:15:18Z
user: login: thure
body: lgtm!
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-06T18:15:22Z

Review comments:
diff_hunk: @@ -22,7 +28,7 @@ export const RelatedContacts = ({ contacts }: { contacts: Contact[] }) => {
         {contacts.map((contact) => (
           <Avatar.Root key={contact.id}>
             {/* TODO(thure): This should become a link. */}
-            <li className='dx-button gap-2 mbe-1 last:mbe-0'>
+            <li className='dx-button gap-2 mbe-1 last:mbe-0' onClick={() => onSelect?.(contact)}>
path: packages/plugins/plugin-inbox/src/components/Related.tsx
user: login: thure
body: This should use Button, but can be done in a follow-up.
created_at: 2025-05-06T18:14:59Z
author_association: CONTRIBUTOR
diff_hunk: @@ -22,7 +28,7 @@ export const RelatedContacts = ({ contacts }: { contacts: Contact[] }) => {
         {contacts.map((contact) => (
           <Avatar.Root key={contact.id}>
             {/* TODO(thure): This should become a link. */}
-            <li className='dx-button gap-2 mbe-1 last:mbe-0'>
+            <li className='dx-button gap-2 mbe-1 last:mbe-0' onClick={() => onSelect?.(contact)}>
path: packages/plugins/plugin-inbox/src/components/Related.tsx
user: login: thure
body: Leave a TODO for me?
created_at: 2025-05-06T18:15:09Z
author_association: CONTRIBUTOR
diff_hunk: @@ -49,7 +61,11 @@ export const RelatedMessages = ({ messages }: { messages: MessageType[] }) => {
       </h3>
       <ul className={previewChrome}>
         {messages.map((message) => (
-          <li key={message.id} className='dx-button font-normal gap-2 mbe-1 last:mbe-0'>
+          <li
+            key={message.id}
+            className='dx-button font-normal gap-2 mbe-1 last:mbe-0'
+            onClick={() => onSelect?.(message)}
path: packages/plugins/plugin-inbox/src/components/Related.tsx
user: login: thure
body: ditto here
created_at: 2025-05-06T18:15:18Z
author_association: CONTRIBUTOR

PR comments:
user: login: semanticdiff-com[bot]
created_at: 2025-05-06T17:50:56Z
body: Review changes with &nbsp;<a href="https://app.semanticdiff.com/gh/dxos/dxos/pull/9127/changes"><picture><source media="(prefers-color-scheme: dark)" srcset="https://app.semanticdiff.com/images/github_button.svg?theme=dark&smaller=63"><source media="(prefers-color-scheme: light)" srcset="https://app.semanticdiff.com/images/github_button.svg?theme=light&smaller=63"><img src="https://app.semanticdiff.com/images/github_button.svg?theme=light&smaller=63" align="center" alt="SemanticDiff"></picture></a>
<details>
<summary>Changed Files</summary>
<br/>

| File | Status |
| :--- | :---   |
| [<img src="https://app.semanticdiff.com/static/file-icons/react_ts.svg" width="20" height="20" align="center">](https://app.semanticdiff.com/gh/dxos/dxos/pull/9127/changes#packages/plugins/plugin-inbox/src/components/Message/Message.tsx)&nbsp;&nbsp;[packages/plugins/plugin\-inbox/src/components/Message/Message\.tsx](https://app.semanticdiff.com/gh/dxos/dxos/pull/9127/changes#packages/plugins/plugin-inbox/src/components/Message/Message.tsx) | [<img src="https://app.semanticdiff.com/images/progressbar.svg?progress=96">](https://app.semanticdiff.com/gh/dxos/dxos/pull/9127/changes#packages/plugins/plugin-inbox/src/components/Message/Message.tsx) &nbsp;96% smaller |
| [<img src="https://app.semanticdiff.com/static/file-icons/react_ts.svg" width="20" height="20" align="center">](https://app.semanticdiff.com/gh/dxos/dxos/pull/9127/changes#packages/plugins/plugin-inbox/src/components/Message/MessageHeader.tsx)&nbsp;&nbsp;[packages/plugins/plugin\-inbox/src/components/Message/MessageHeader\.tsx](https://app.semanticdiff.com/gh/dxos/dxos/pull/9127/changes#packages/plugins/plugin-inbox/src/components/Message/MessageHeader.tsx) | [<img src="https://app.semanticdiff.com/images/progressbar.svg?progress=89">](https://app.semanticdiff.com/gh/dxos/dxos/pull/9127/changes#packages/plugins/plugin-inbox/src/components/Message/MessageHeader.tsx) &nbsp;89% smaller |
| [<img src="https://app.semanticdiff.com/static/file-icons/typescript.svg" width="20" height="20" align="center">](https://app.semanticdiff.com/gh/dxos/dxos/pull/9127/changes#packages/plugins/plugin-inbox/src/capabilities/intent-resolver.ts)&nbsp;&nbsp;[packages/plugins/plugin\-inbox/src/capabilities/intent\-resolver\.ts](https://app.semanticdiff.com/gh/dxos/dxos/pull/9127/changes#packages/plugins/plugin-inbox/src/capabilities/intent-resolver.ts) | [<img src="https://app.semanticdiff.com/images/progressbar.svg?progress=79">](https://app.semanticdiff.com/gh/dxos/dxos/pull/9127/changes#packages/plugins/plugin-inbox/src/capabilities/intent-resolver.ts) &nbsp;79% smaller |
| [<img src="https://app.semanticdiff.com/static/file-icons/react_ts.svg" width="20" height="20" align="center">](https://app.semanticdiff.com/gh/dxos/dxos/pull/9127/changes#packages/plugins/plugin-inbox/src/components/Related.tsx)&nbsp;&nbsp;[packages/plugins/plugin\-inbox/src/components/Related\.tsx](https://app.semanticdiff.com/gh/dxos/dxos/pull/9127/changes#packages/plugins/plugin-inbox/src/components/Related.tsx) | [<img src="https://app.semanticdiff.com/images/progressbar.svg?progress=71">](https://app.semanticdiff.com/gh/dxos/dxos/pull/9127/changes#packages/plugins/plugin-inbox/src/components/Related.tsx) &nbsp;71% smaller |
| [<img src="https://app.semanticdiff.com/static/file-icons/typescript.svg" width="20" height="20" align="center">](https://app.semanticdiff.com/gh/dxos/dxos/pull/9127/changes#packages/plugins/plugin-automation/src/capabilities/intent-resolver.ts)&nbsp;&nbsp;[packages/plugins/plugin\-automation/src/capabilities/intent\-resolver\.ts](https://app.semanticdiff.com/gh/dxos/dxos/pull/9127/changes#packages/plugins/plugin-automation/src/capabilities/intent-resolver.ts) | [<img src="https://app.semanticdiff.com/images/progressbar.svg?progress=59">](https://app.semanticdiff.com/gh/dxos/dxos/pull/9127/changes#packages/plugins/plugin-automation/src/capabilities/intent-resolver.ts) &nbsp;59% smaller |
| [<img src="https://app.semanticdiff.com/static/file-icons/react_ts.svg" width="20" height="20" align="center">](https://app.semanticdiff.com/gh/dxos/dxos/pull/9127/changes#packages/plugins/plugin-inbox/src/capabilities/react-surface.tsx)&nbsp;&nbsp;[packages/plugins/plugin\-inbox/src/capabilities/react\-surface\.tsx](https://app.semanticdiff.com/gh/dxos/dxos/pull/9127/changes#packages/plugins/plugin-inbox/src/capabilities/react-surface.tsx) | [<img src="https://app.semanticdiff.com/images/progressbar.svg?progress=27">](https://app.semanticdiff.com/gh/dxos/dxos/pull/9127/changes#packages/plugins/plugin-inbox/src/capabilities/react-surface.tsx) &nbsp;27% smaller |
| [<img src="https://app.semanticdiff.com/static/file-icons/react_ts.svg" width="20" height="20" align="center">](https://app.semanticdiff.com/gh/dxos/dxos/pull/9127/changes#packages/plugins/plugin-inbox/src/components/Message/MessageContainer.tsx)&nbsp;&nbsp;[packages/plugins/plugin\-inbox/src/components/Message/MessageContainer\.tsx](https://app.semanticdiff.com/gh/dxos/dxos/pull/9127/changes#packages/plugins/plugin-inbox/src/components/Message/MessageContainer.tsx) | [<img src="https://app.semanticdiff.com/images/progressbar.svg?progress=26">](https://app.semanticdiff.com/gh/dxos/dxos/pull/9127/changes#packages/plugins/plugin-inbox/src/components/Message/MessageContainer.tsx) &nbsp;26% smaller |
| [<img src="https://app.semanticdiff.com/static/file-icons/file.svg" width="20" height="20" align="center">](https://app.semanticdiff.com/gh/dxos/dxos/pull/9127/changes#.github/workflows/env/labs)&nbsp;&nbsp;[\.github/workflows/env/labs](https://app.semanticdiff.com/gh/dxos/dxos/pull/9127/changes#.github/workflows/env/labs) | Unsupported file format |
| [<img src="https://app.semanticdiff.com/static/file-icons/json.svg" width="20" height="20" align="center">](https://app.semanticdiff.com/gh/dxos/dxos/pull/9127/changes#packages/plugins/plugin-inbox/package.json)&nbsp;&nbsp;[packages/plugins/plugin\-inbox/package\.json](https://app.semanticdiff.com/gh/dxos/dxos/pull/9127/changes#packages/plugins/plugin-inbox/package.json) | [<img src="https://app.semanticdiff.com/images/progressbar.svg?progress=0">](https://app.semanticdiff.com/gh/dxos/dxos/pull/9127/changes#packages/plugins/plugin-inbox/package.json) &nbsp;0% smaller |
| [<img src="https://app.semanticdiff.com/static/file-icons/pnpm.svg" width="20" height="20" align="center">](https://app.semanticdiff.com/gh/dxos/dxos/pull/9127/changes#pnpm-lock.yaml)&nbsp;&nbsp;[pnpm\-lock\.yaml](https://app.semanticdiff.com/gh/dxos/dxos/pull/9127/changes#pnpm-lock.yaml) | [<img src="https://app.semanticdiff.com/images/progressbar.svg?progress=0">](https://app.semanticdiff.com/gh/dxos/dxos/pull/9127/changes#pnpm-lock.yaml) &nbsp;0% smaller |

</details>
author_association: NONE
user: login: nx-cloud[bot]
created_at: 2025-05-06T17:50:57Z
body: 
View your [CI Pipeline Execution ↗](https://cloud.nx.app/cipes/681a68e6177e4b5722f8eeaa?utm_source=pull-request&utm_medium=comment) for commit 42fa75d4b55681adebcd712f15ff65c1a68c89bb.

| Command | Status | Duration | Result |
|---------|--------|----------:|--------|
| `nx affected --base=3d2b6a53b18cf437adb3c0f0b633...` | ✅ Succeeded | 10s | [View ↗](https://cloud.nx.app/runs/AN9K6EwUG4?utm_source=pull-request&utm_medium=comment) |
| `nx affected --base=3d2b6a53b18cf437adb3c0f0b633...` | ✅ Succeeded | 46s | [View ↗](https://cloud.nx.app/runs/Jh0MShGopw?utm_source=pull-request&utm_medium=comment) |
| `nx affected --base=3d2b6a53b18cf437adb3c0f0b633...` | ✅ Succeeded | 12s | [View ↗](https://cloud.nx.app/runs/A7tAD3Zp1B?utm_source=pull-request&utm_medium=comment) |

---

☁️ [Nx Cloud](https://cloud.nx.app?utm_source=pull-request&utm_medium=comment) last updated this comment at `2025-05-06 19:55:46` UTC

<!-- NX_CLOUD_APP_COMMENT_END -->
author_association: NONE
user: login: nx-cloud[bot]
created_at: 2025-05-06T17:50:57Z
body: 
View your [CI Pipeline Execution ↗](https://cloud.nx.app/cipes/681a4bf72ece2f2caae3b048?utm_source=pull-request&utm_medium=comment) for commit e6787e448f1787538104f987e2c2c098e698e289.

| Command | Status | Duration | Result |
|---------|--------|----------:|--------|
| `nx affected --base=bb14fc80413849c9b2b498dc2a3f...` | ✅ Succeeded | <1s | [View ↗](https://cloud.nx.app/runs/R8Fz2STaPD?utm_source=pull-request&utm_medium=comment) |

---

☁️ [Nx Cloud](https://cloud.nx.app?utm_source=pull-request&utm_medium=comment) last updated this comment at `2025-05-06 17:50:57` UTC

<!-- NX_CLOUD_APP_COMMENT_END -->
author_association: NONE