Repository: dxos/dxos
Pull request #8918: refactor(plugin-inbox): Use Grid and Plank companions for InboxPlugin
Author: login: thure
Created: 2025-04-08T01:28:19Z | Merged: 2025-04-09T19:38:14Z

This PR:
- begins a resolution of #8912
- refactors `Mailbox` to use `Grid`
- uses the new `dx-avatar` to render message sender avatar (which will soon double as a checkbox)
- refactors actions to prepare for message-scoped actions (like navigate-to and select)

This should have parity with the old `MessagesList`.

[See Discord for screen capture](https://discord.com/channels/837138313172353095/1309686963699191882/1359343448267689984) using messages from my work email.


Diff:
diff --git a/packages/apps/composer-app/src/plugins.tsx b/packages/apps/composer-app/src/plugins.tsx
index 8998318ad52..97feb728e77 100644
--- a/packages/apps/composer-app/src/plugins.tsx
+++ b/packages/apps/composer-app/src/plugins.tsx
@@ -153,7 +153,7 @@ export const plugins = ({ appKey, config, services, observability, isDev, isLabs
     FilesPlugin(),
     GraphPlugin(),
     HelpPlugin({ steps }),
-    isLabs && InboxPlugin(),
+    InboxPlugin(),
     IntentPlugin(),
     KanbanPlugin(),
     MapPlugin(),
diff --git a/packages/common/util/src/first-two-chars.ts b/packages/common/util/src/first-two-chars.ts
new file mode 100644
index 00000000000..688c3797d9a
--- /dev/null
+++ b/packages/common/util/src/first-two-chars.ts
@@ -0,0 +1,44 @@
+//
+// Copyright 2025 DXOS.org
+//
+
+// Regular expression to match renderable characters
+// Excludes control characters, combining marks, and other non-renderable characters. Also excludes punctuation.
+const renderableCharRegex =
+  /^(?![\p{Control}\p{Mark}\p{Separator}\p{Surrogate}\p{Unassigned}\p{P}])[\p{L}\p{N}\p{S}\p{Emoji}]$/u;
+
+/**
+ * Returns the first two renderable characters from a string that are separated by non-word characters.
+ * Handles Unicode characters correctly.
+ *
+ * @param {string} label - The input string to process
+ * @returns {[string, string]} Array containing the two characters, or empty strings if not found
+ */
+export const getFirstTwoRenderableChars = (label: string) => {
+  // Convert string to array of Unicode characters
+  const characters = Array.from(label);
+
+  // Keep track of found renderable characters
+  const result = ['', ''];
+  let foundFirst = false;
+
+  for (let i = 0; i < characters.length; i++) {
+    const char = characters[i];
+
+    if (renderableCharRegex.test(char)) {
+      if (!foundFirst) {
+        result[0] = char;
+        foundFirst = true;
+      } else {
+        // Check if there's at least one non-word character between the first and current char
+        const textBetween = characters.slice(result[0].length, i).join('');
+        if (/[^\p{L}\p{N}_]/u.test(textBetween)) {
+          result[1] = char;
+          break;
+        }
+      }
+    }
+  }
+
+  return result;
+};
diff --git a/packages/common/util/src/index.ts b/packages/common/util/src/index.ts
index 3698ae93108..77bc2a0a70e 100644
--- a/packages/common/util/src/index.ts
+++ b/packages/common/util/src/index.ts
@@ -16,6 +16,7 @@ export * from './defer';
 export * from './defer-function';
 export * from './deep';
 export * from './entry';
+export * from './first-two-chars';
 export * from './for-each-async';
 export * from './human-hash';
 export * from './interval';
diff --git a/packages/plugins/experimental/plugin-inbox/package.json b/packages/plugins/experimental/plugin-inbox/package.json
index 1b81eabe86e..f902557eb7a 100644
--- a/packages/plugins/experimental/plugin-inbox/package.json
+++ b/packages/plugins/experimental/plugin-inbox/package.json
@@ -46,12 +46,14 @@
     "@dxos/plugin-space": "workspace:*",
     "@dxos/react-client": "workspace:*",
     "@dxos/react-ui-attention": "workspace:*",
+    "@dxos/react-ui-grid": "workspace:*",
     "@dxos/react-ui-stack": "workspace:*",
     "@dxos/schema": "workspace:*",
     "@dxos/util": "workspace:*",
     "@preact/signals-core": "^1.6.0",
     "date-fns": "^3.3.1",
-    "lodash.get": "^4.4.2"
+    "lodash.get": "^4.4.2",
+    "react-resize-detector": "^12.0.2"
   },
   "devDependencies": {
     "@dxos/random": "workspace:*",
diff --git a/packages/plugins/experimental/plugin-inbox/src/InboxPlugin.tsx b/packages/plugins/experimental/plugin-inbox/src/InboxPlugin.tsx
index 26b30ef6a56..79f875fafd6 100644
--- a/packages/plugins/experimental/plugin-inbox/src/InboxPlugin.tsx
+++ b/packages/plugins/experimental/plugin-inbox/src/InboxPlugin.tsx
@@ -28,6 +28,9 @@ export const InboxPlugin = () =>
           id: MailboxType.typename,
           metadata: {
             icon: 'ph--envelope--regular',
+            graphProps: {
+              startsWithCompanionSurfaceVariant: 'firstMessage',
+            },
           },
         }),
         contributes(Capabilities.Metadata, {
diff --git a/packages/plugins/experimental/plugin-inbox/src/components/Calendar/EventsContainer.tsx b/packages/plugins/experimental/plugin-inbox/src/components/Calendar/EventsContainer.tsx
index 27d20d59c0f..9a2c6d6d1c2 100644
--- a/packages/plugins/experimental/plugin-inbox/src/components/Calendar/EventsContainer.tsx
+++ b/packages/plugins/experimental/plugin-inbox/src/components/Calendar/EventsContainer.tsx
@@ -9,7 +9,6 @@ import { StackItem } from '@dxos/react-ui-stack';
 
 import { EventList } from './EventtList';
 import { type CalendarType, EventType } from '../../types';
-import { MasterDetail } from '../MasterDetail';
 
 const byDate =
   (direction = -1) =>
@@ -28,9 +27,7 @@ export const EventsContainer = ({ calendar }: EventsContainerProps) => {
 
   return (
     <StackItem.Content toolbar={false}>
-      <MasterDetail>
-        <EventList events={objects} selected={selected?.id} onSelect={setSelected} />
-      </MasterDetail>
+      <EventList events={objects} selected={selected?.id} onSelect={setSelected} />
     </StackItem.Content>
   );
 };
diff --git a/packages/plugins/experimental/plugin-inbox/src/components/Contacts/ContactsContainer.tsx b/packages/plugins/experimental/plugin-inbox/src/components/Contacts/ContactsContainer.tsx
index cf4c73f5724..41c50ea7bc1 100644
--- a/packages/plugins/experimental/plugin-inbox/src/components/Contacts/ContactsContainer.tsx
+++ b/packages/plugins/experimental/plugin-inbox/src/components/Contacts/ContactsContainer.tsx
@@ -10,7 +10,6 @@ import { ContactType } from '@dxos/schema';
 
 import { ContactList } from './ContactList';
 import { type ContactsType } from '../../types';
-import { MasterDetail } from '../MasterDetail';
 
 // TODO(burdon): Master detail (same as Inbox); incl. selection, cursor navigation, scrolling.
 // TODO(burdon): Nav from inbox.
@@ -42,9 +41,7 @@ export const ContactsContainer = ({ contacts }: ContactsContainerProps) => {
 
   return (
     <StackItem.Content toolbar={false}>
-      <MasterDetail>
-        <ContactList contacts={objects} selected={selected?.id} onSelect={setSelected} />
-      </MasterDetail>
+      <ContactList contacts={objects} selected={selected?.id} onSelect={setSelected} />
     </StackItem.Content>
   );
 };
diff --git a/packages/plugins/experimental/plugin-inbox/src/components/Mailbox/Mailbox.stories.tsx b/packages/plugins/experimental/plugin-inbox/src/components/Mailbox/Mailbox.stories.tsx
new file mode 100644
index 00000000000..2724f9e32ed
--- /dev/null
+++ b/packages/plugins/experimental/plugin-inbox/src/components/Mailbox/Mailbox.stories.tsx
@@ -0,0 +1,31 @@
+//
+// Copyright 2023 DXOS.org
+//
+
+import '@dxos-theme';
+import './mailbox.css';
+
+import { type Meta } from '@storybook/react';
+import React, { useState } from 'react';
+
+import { withLayout, withTheme } from '@dxos/storybook-utils';
+
+import { Mailbox } from './Mailbox';
+import { createInbox } from '../../testing';
+
+const DefaultStory = () => {
+  const [inbox] = useState(() => createInbox(100));
+
+  return <Mailbox id='story' messagesQueue={inbox.queue} />;
+};
+
+export const Default = {};
+
+const meta: Meta = {
+  title: 'plugins/plugin-inbox/Mailbox',
+  component: Mailbox,
+  render: DefaultStory,
+  decorators: [withTheme, withLayout({ fullscreen: true })],
+};
+
+export default meta;
diff --git a/packages/plugins/experimental/plugin-inbox/src/components/Mailbox/Mailbox.tsx b/packages/plugins/experimental/plugin-inbox/src/components/Mailbox/Mailbox.tsx
index 8db2ffd6a60..33bf53490bb 100644
--- a/packages/plugins/experimental/plugin-inbox/src/components/Mailbox/Mailbox.tsx
+++ b/packages/plugins/experimental/plugin-inbox/src/components/Mailbox/Mailbox.tsx
@@ -2,13 +2,20 @@
 // Copyright 2024 DXOS.org
 //
 
-import React, { useEffect, useRef, useState } from 'react';
+import './mailbox.css';
 
-import { useQueue } from '@dxos/react-client/echo';
+import React, { type MouseEvent, useCallback, useEffect, useRef, useState, useMemo, type WheelEvent } from 'react';
+import { type OnResizeCallback, useResizeDetector } from 'react-resize-detector';
+
+import { log } from '@dxos/log';
+import { type Queue } from '@dxos/react-client/echo';
+import { useAttention } from '@dxos/react-ui-attention';
+import { type DxGridPlaneCells, Grid, type GridContentProps, toPlaneCellIndex } from '@dxos/react-ui-grid';
 import { type MessageType } from '@dxos/schema';
+import { getFirstTwoRenderableChars, toHue } from '@dxos/util';
 
-import { type ActionType, MessageList } from './MessageList';
 import { type MailboxType, MessageState } from '../../types';
+import { formatDate } from '../util';
 
 const DEFAULT_READ_TIMEOUT = 3_000;
 
@@ -17,67 +24,199 @@ const byDate =
   ({ created: a = '' }: MessageType, { created: b = '' }: MessageType) =>
     a < b ? -direction : a > b ? direction : 0;
 
-const setMessageProperty = (message: MessageType, property: string, value: any) => {
-  if (!message.properties) {
-    message.properties = {};
-  }
+const updateMessageProperty = (messages: MessageType[], property: string, value: any) => {
+  messages.forEach((message) => {
+    if (!message.properties) {
+      message.properties = {};
+    }
 
-  message.properties[property] = value;
+    message.properties[property] = value;
+  });
 };
 
 export type MailboxOptions = { readTimout?: number };
 
+const messageRowDefault = {
+  grid: { size: 56 },
+};
+
+const messageColumnDefault = {
+  grid: { size: 100 },
+};
+
+const hashString = (str?: string): number => {
+  if (!str) {
+    return 0;
+  }
+  return Math.abs(str.split('').reduce((hash, char) => (hash << 5) + hash + char.charCodeAt(0), 0));
+};
+
+const renderMessageCell = (message: MessageType, now: Date) => {
+  const text = message.blocks.find((block) => block.type === 'text')?.text;
+  const date = formatDate(now, message.created ? new Date(message.created) : new Date());
+  const from = message.sender?.name ?? message.sender?.email;
+  const subject = message.properties?.subject ?? text;
+  const hue = toHue(hashString(from));
+
+  return `<button
+      class="message__thumb dx-focus-ring-inset"
+      data-inbox-action="select-message"
+      data-message-id="${message.id}"
+      ><dx-avatar
+        hue=${hue}
+        variant="square"
+        hueVariant="surface"
+        fallback="${from ? getFirstTwoRenderableChars(from).join('') : '?'}"
+      ></dx-avatar
+    ></button
+    ><button
+      class="message__abstract dx-focus-ring-inset"
+      data-inbox-action="current-message"
+      data-message-id="${message.id}"
+      ><p class="message__abstract__heading"
+        ><span>${from}</span
+        ><span>${date}</span></p
+      ><p class="message__abstract__body">${subject}</p
+  ></button>`;
+};
+
+const messageCellClassName = 'message';
+
 // TODO(burdon): Extract contacts/orgs.
 // TODO(burdon): Split message body into parts and allow trim (e.g., remove forwarded part). Message as light stack?
 // TODO(burdon): Highlight message/chunks for action (e.g., follow-up, triggers embedding).
 // TODO(burdon): Create outline/kanban.
 // TODO(burdon): Address book/cards.
 
-export type MailboxProps = { mailbox: MailboxType; options?: MailboxOptions };
+export type MailboxProps = Pick<MailboxType, 'name'> & {
+  id: string;
+  messagesQueue?: Pick<Queue<MessageType>, 'items'>;
+  options?: MailboxOptions;
+  ignoreAttention?: boolean;
+};
 
-export const Mailbox = ({ mailbox, options = {} }: MailboxProps) => {
-  const [selected, setSelected] = useState<string>();
-  const queue = useQueue<MessageType>(mailbox.queue.dxn, { pollInterval: 1_000 });
+export const Mailbox = ({ messagesQueue, options = {}, id, ignoreAttention }: MailboxProps) => {
+  const [currentMessageId, setCurrentMessageId] = useState<string>();
+  // TODO(thure): The container should manage the queue.
   const tRef = useRef<ReturnType<typeof setTimeout>>();
+  const { hasAttention } = useAttention(id);
+  const [columnDefault, setColumnDefault] = useState(messageColumnDefault);
+
   useEffect(() => {
     clearTimeout(tRef.current);
-    if (selected) {
+    if (currentMessageId) {
       tRef.current = setTimeout(() => {
-        const object = queue?.items?.find((message) => message.id === selected);
+        const object = messagesQueue?.items?.find((message) => message.id === currentMessageId);
         if (object?.properties) {
-          setMessageProperty(object, 'read', true);
+          updateMessageProperty([object], 'read', true);
         }
       }, options?.readTimout ?? DEFAULT_READ_TIMEOUT);
     }
 
     return () => clearTimeout(tRef.current);
-  }, [selected]);
+  }, [currentMessageId]);
 
-  const messages = [...(queue?.items ?? [])]
-    .filter(
-      (message) =>
-        message.properties?.state !== MessageState.ARCHIVED && message.properties?.state !== MessageState.DELETED,
-    )
-    .sort(byDate());
+  const messages = useMemo(
+    () =>
+      [...(messagesQueue?.items ?? [])]
+        .filter(
+          (message) =>
+            message.properties?.state !== MessageState.ARCHIVED && message.properties?.state !== MessageState.DELETED,
+        )
+        .sort(byDate()),
+    [messagesQueue?.items?.length],
+  );
 
-  const handleAction = (message: MessageType, action: ActionType) => {
+  const _handleMailboxAction = (messages: MessageType[], action: 'archive' | 'delete' | 'unread') => {
     switch (action) {
       case 'archive': {
-        setMessageProperty(message, 'state', MessageState.ARCHIVED);
-        setSelected(undefined);
+        updateMessageProperty(messages, 'state', MessageState.ARCHIVED);
+        setCurrentMessageId(undefined);
         break;
       }
       case 'delete': {
-        setMessageProperty(message, 'state', MessageState.DELETED);
-        setSelected(undefined);
+        updateMessageProperty(messages, 'state', MessageState.DELETED);
+        setCurrentMessageId(undefined);
         break;
       }
       case 'unread': {
-        setMessageProperty(message, 'read', false);
+        updateMessageProperty(messages, 'read', false);
         break;
       }
     }
   };
 
-  return <MessageList messages={messages} selected={selected} onSelect={setSelected} onAction={handleAction} />;
+  const handleResize = useCallback<OnResizeCallback>(
+    ({ width }) => width && setColumnDefault({ grid: { size: width } }),
+    [],
+  );
+
+  const { ref: measureRef } = useResizeDetector({
+    onResize: handleResize,
+  });
+
+  const handleWheel = useCallback(
+    (event: WheelEvent) => {
+      if (!ignoreAttention && !hasAttention) {
+        event.stopPropagation();
+      }
+    },
+    [hasAttention, ignoreAttention],
+  );
+
+  const handleClick = useCallback((event: MouseEvent) => {
+    const target = event.target as HTMLElement;
+    const actionEl = target.closest('[data-inbox-action]');
+    if (actionEl) {
+      const messageId = actionEl.getAttribute('data-message-id')!;
+      const action = actionEl.getAttribute('data-inbox-action')!;
+      switch (action) {
+        case 'select-message':
+          log.debug(`[select message] ${messageId}`);
+          break;
+        case 'current-message':
+          log.debug(`[current message] ${messageId}`);
+          break;
+      }
+    }
+  }, []);
+
+  const getCells = useCallback<NonNullable<GridContentProps['getCells']>>(
+    (range, plane) => {
+      if (messages) {
+        const now = new Date();
+        switch (plane) {
+          case 'grid': {
+            const cells: DxGridPlaneCells = {};
+            for (let row = range.start.row; row <= range.end.row && row < messages.length; row++) {
+              cells[toPlaneCellIndex({ col: 0, row })] = {
+                readonly: true,
+                accessoryHtml: renderMessageCell(messages[row], now),
+                className: messageCellClassName,
+              };
+            }
+            return cells;
+          }
+        }
+      }
+      return {} as DxGridPlaneCells;
+    },
+    [messages],
+  );
+
+  return (
+    <Grid.Root id={`${id}__grid`}>
+      <Grid.Content
+        limitColumns={1}
+        limitRows={messages.length}
+        rowDefault={messageRowDefault}
+        columnDefault={columnDefault}
+        onWheel={handleWheel}
+        onClick={handleClick}
+        getCells={getCells}
+        className='[--dx-grid-base:var(--dx-baseSurface)] [&_.dx-grid]:min-bs-0 [&_.dx-grid]:min-is-0 [&_.dx-grid]:select-auto'
+      />
+      <div role='none' {...{ inert: '' }} aria-hidden className='absolute inset-inline-0' ref={measureRef} />
+    </Grid.Root>
+  );
 };
diff --git a/packages/plugins/experimental/plugin-inbox/src/components/Mailbox/MailboxContainer.tsx b/packages/plugins/experimental/plugin-inbox/src/components/Mailbox/MailboxContainer.tsx
index 469f64239e3..9e850dff072 100644
--- a/packages/plugins/experimental/plugin-inbox/src/components/Mailbox/MailboxContainer.tsx
+++ b/packages/plugins/experimental/plugin-inbox/src/components/Mailbox/MailboxContainer.tsx
@@ -4,12 +4,23 @@
 
 import React from 'react';
 
+import { fullyQualifiedId, useQueue } from '@dxos/react-client/echo';
 import { StackItem } from '@dxos/react-ui-stack';
+import type { MessageType } from '@dxos/schema';
 
 import { Mailbox, type MailboxProps } from './Mailbox';
+import { type MailboxType } from '../../types';
 
-export const MailboxContainer = ({ mailbox, options = {} }: MailboxProps) => (
-  <StackItem.Content toolbar={false}>
-    <Mailbox mailbox={mailbox} options={options} />
-  </StackItem.Content>
-);
+export type MailboxContainerProps = {
+  mailbox: MailboxType;
+  options?: MailboxProps['options'];
+};
+
+export const MailboxContainer = ({ mailbox, options = {} }: MailboxContainerProps) => {
+  const queue = useQueue<MessageType>(mailbox.queue.dxn, { pollInterval: 1_000 });
+  return (
+    <StackItem.Content toolbar={false}>
+      <Mailbox messagesQueue={queue} id={fullyQualifiedId(mailbox)} name={mailbox.name} options={options} />
+    </StackItem.Content>
+  );
+};
diff --git a/packages/plugins/experimental/plugin-inbox/src/components/Mailbox/MessageList.stories.tsx b/packages/plugins/experimental/plugin-inbox/src/components/Mailbox/MessageList.stories.tsx
deleted file mode 100644
index 99a99777e66..00000000000
--- a/packages/plugins/experimental/plugin-inbox/src/components/Mailbox/MessageList.stories.tsx
+++ /dev/null
@@ -1,44 +0,0 @@
-//
-// Copyright 2023 DXOS.org
-//
-
-import '@dxos-theme';
-
-import { type Meta } from '@storybook/react';
-import React, { useState } from 'react';
-
-import { RefArray } from '@dxos/live-object';
-import { log } from '@dxos/log';
-import { withLayout, withTheme } from '@dxos/storybook-utils';
-
-import { MessageList, type MessageListProps } from './MessageList';
-import { createInbox } from '../../testing';
-
-const DefaultStory = () => {
-  const [inbox] = useState(() => createInbox(100));
-  const [selected, setSelected] = useState<string>();
-
-  const handleAction: MessageListProps['onAction'] = (message) => {
-    log.info('onArchive', { message });
-  };
-
-  return (
-    <MessageList
-      messages={RefArray.targets(inbox.messages)}
-      selected={selected}
-      onSelect={setSelected}
-      onAction={handleAction}
-    />
-  );
-};
-
-export const Default = {};
-
-const meta: Meta = {
-  title: 'plugins/plugin-inbox/MessageList',
-  component: MessageList,
-  render: DefaultStory,
-  decorators: [withTheme, withLayout({ fullscreen: true })],
-};
-
-export default meta;
diff --git a/packages/plugins/experimental/plugin-inbox/src/components/Mailbox/MessageList.tsx b/packages/plugins/experimental/plugin-inbox/src/components/Mailbox/MessageList.tsx
deleted file mode 100644
index f2c2c233252..00000000000
--- a/packages/plugins/experimental/plugin-inbox/src/components/Mailbox/MessageList.tsx
+++ /dev/null
@@ -1,138 +0,0 @@
-//
-// Copyright 2023 DXOS.org
-//
-
-import { Archive, ArrowClockwise, Trash } from '@phosphor-icons/react';
-import React, { type MouseEvent, useState } from 'react';
-
-import { Button, useTranslation } from '@dxos/react-ui';
-import { AttentionGlyph } from '@dxos/react-ui-attention';
-import { baseSurface, fixedBorder, focusRing, getSize, ghostHover, mx } from '@dxos/react-ui-theme';
-import { type MessageType } from '@dxos/schema';
-
-import { INBOX_PLUGIN } from '../../meta';
-import { styles } from '../styles';
-import { formatDate } from '../util';
-
-export type ActionType = 'archive' | 'delete' | 'unread';
-
-export type MessageListProps = {
-  messages?: MessageType[];
-  selected?: string;
-  onSelect?: (id: string) => void;
-  onAction?: (message: MessageType, action: ActionType) => void;
-};
-
-// TODO(burdon): Factor out list (and implement navigation).
-export const MessageList = ({ messages = [], selected, onSelect, onAction }: MessageListProps) => {
-  const { t } = useTranslation(INBOX_PLUGIN);
-
-  return (
-    <div
-      className={mx(
-        'flex flex-col is-full overflow-x-hidden overflow-y-auto divide-y divide-separator scrollbar-thin',
-        baseSurface,
-      )}
-    >
-      {!messages?.length && <div className='flex items-center justify-center p-4 font-thin'>{t('no messages')}</div>}
-      {messages?.map((message) => (
-        <MessageItem
-          key={message.id}
-          message={message}
-          selected={message.id === selected}
-          onSelect={onSelect ? () => onSelect(message.id) : undefined}
-          onAction={onAction ? (action) => onAction(message, action) : undefined}
-        />
-      ))}
-    </div>
-  );
-};
-
-export type MessageItemProps = {
-  message: MessageType;
-  selected?: boolean;
-  onSelect?: () => void;
-  onAction?: (action: ActionType) => void;
-};
-
-export const MessageItem = ({ message, selected, onSelect, onAction }: MessageItemProps) => {
-  const [expanded, setExpanded] = useState(false);
-  const { t } = useTranslation(INBOX_PLUGIN);
-
-  const handleAction = (event: MouseEvent<HTMLButtonElement>, action: ActionType) => {
-    event.stopPropagation();
-    onAction?.(action);
-  };
-
-  const text = message.blocks.find((block) => block.type === 'text')?.text;
-  const date = message.created ? new Date(message.created) : new Date();
-  const from = message.sender?.name ?? message.sender?.email;
-  const subject = message.properties?.subject ?? text;
-  const now = new Date();
-
-  return (
-    <div
-      className={mx('group flex p-2 gap-1 border', focusRing, fixedBorder, ghostHover, selected && styles.selected)}
-      tabIndex={0}
-      onClick={() => onSelect?.()}
-    >
-      <div className='flex w-8 h-8 justify-center items-center'>
-        {selected && (
-          <span {...{ 'data-attention': 'true' }}>
-            <AttentionGlyph presence='none' />
-          </span>
-        )}
-      </div>
-
-      <div className='flex flex-col is-full overflow-hidden'>
-        <div
-          className={mx('flex h-8 items-center justify-between text-sm text-description cursor-pointer')}
-          onClick={() => setExpanded((expanded) => !expanded)}
-        >
-          <div className='grow overflow-hidden truncate py-2'>{from}</div>
-          {onAction && (
-            <div className='hidden group-hover:flex shrink-0'>
-              {message.properties?.read && (
-                <Button
-                  variant='ghost'
-                  title={t('action mark read')}
-                  onClick={(event) => handleAction(event, 'unread')}
-                >
-                  <ArrowClockwise className={getSize(4)} />
-                </Button>
-              )}
-              <Button variant='ghost' title={t('action archive')} onClick={(event) => handleAction(event, 'archive')}>
-                <Archive className={getSize(4)} />
-              </Button>
-              <Button variant='ghost' title={t('action delete')} onClick={(event) => handleAction(event, 'delete')}>
-                <Trash className={getSize(4)} />
-              </Button>
-            </div>
-          )}
-          <div className={mx('shrink-0 whitespace-nowrap p-2 text-sm', onAction && 'group-hover:hidden')}>
-            {formatDate(now, date)}
-          </div>
-        </div>
-
-        <div
-          className={mx(
-            'mb-1 mr-2 overflow-hidden line-clamp-3 cursor-pointer',
-            message.properties?.read && 'text-description',
-          )}
-          onClick={() => setExpanded((event) => !event)}
-        >
-          {subject}
-        </div>
-
-        {expanded && (
-          <div className='flex flex-col gap-2 pbs-2 pb-4 mt-2'>
-            <div className='grid grid-cols-[1fr,9rem] gap-2 text-description'>
-              <div className='whitespace-pre-line'>{text}</div>
-              <div className='px-2 text-right text-sm'>{formatDate(now, new Date(message.created))}</div>
-            </div>
-          </div>
-        )}
-      </div>
-    </div>
-  );
-};
diff --git a/packages/plugins/experimental/plugin-inbox/src/components/Mailbox/mailbox.css b/packages/plugins/experimental/plugin-inbox/src/components/Mailbox/mailbox.css
new file mode 100644
index 00000000000..420b22d0228
--- /dev/null
+++ b/packages/plugins/experimental/plugin-inbox/src/components/Mailbox/mailbox.css
@@ -0,0 +1,15 @@
+.message {
+  @apply grid grid-cols-[var(--rail-action)_1fr] gap-2;
+
+  .message__thumb {
+    @apply text-start;
+  }
+  .message__abstract {
+    @apply text-start min-is-0;
+    .message__abstract__body { @apply truncate text-sm text-description; }
+    .message__abstract__heading {
+      @apply flex gap-2 text-sm;
+      span:first-child { flex: 1 1 0; @apply text-base font-medium truncate; }
+    }
+  }
+}
\ No newline at end of file
diff --git a/packages/plugins/experimental/plugin-inbox/src/components/MasterDetail/MasterDetail.tsx b/packages/plugins/experimental/plugin-inbox/src/components/MasterDetail/MasterDetail.tsx
deleted file mode 100644
index 7d040eadddd..00000000000
--- a/packages/plugins/experimental/plugin-inbox/src/components/MasterDetail/MasterDetail.tsx
+++ /dev/null
@@ -1,22 +0,0 @@
-//
-// Copyright 2023 DXOS.org
-//
-
-import React, { type JSX, type PropsWithChildren } from 'react';
-
-import { fixedBorder, attentionSurface, mx } from '@dxos/react-ui-theme';
-
-// TODO(burdon): Factor out.
-
-export type MasterDetailProps = PropsWithChildren<{
-  detail?: JSX.Element;
-}>;
-
-export const MasterDetail = ({ children, detail }: MasterDetailProps) => {
-  return (
-    <div className={mx('flex grow overflow-hidden border-t', fixedBorder)}>
-      <div className='flex shrink-0 w-[30rem] border-r'>{children}</div>
-      <div className={mx('flex w-full overflow-x-hidden overflow-y-auto p-4', attentionSurface)}>{detail}</div>
-    </div>
-  );
-};
diff --git a/packages/plugins/experimental/plugin-inbox/src/components/MasterDetail/index.ts b/packages/plugins/experimental/plugin-inbox/src/components/MasterDetail/index.ts
deleted file mode 100644
index 3ffd42db0bc..00000000000
--- a/packages/plugins/experimental/plugin-inbox/src/components/MasterDetail/index.ts
+++ /dev/null
@@ -1,5 +0,0 @@
-//
-// Copyright 2023 DXOS.org
-//
-
-export * from './MasterDetail';
diff --git a/packages/plugins/experimental/plugin-inbox/src/testing/testing.ts b/packages/plugins/experimental/plugin-inbox/src/testing/testing.ts
index c39a66ff7e0..81d28813737 100644
--- a/packages/plugins/experimental/plugin-inbox/src/testing/testing.ts
+++ b/packages/plugins/experimental/plugin-inbox/src/testing/testing.ts
@@ -3,21 +3,22 @@
 //
 
 import { IdentityDid } from '@dxos/keys';
-import { create, makeRef } from '@dxos/live-object';
-import { ThreadType } from '@dxos/plugin-space/types';
+import { create } from '@dxos/live-object';
 import { faker } from '@dxos/random';
 import { MessageType } from '@dxos/schema';
 
+import type { MailboxProps } from '../components/Mailbox/Mailbox';
+
 faker.seed(1);
 
 export const createInbox = (count = 10) => {
   // const now = new Date();
 
   // TODO(burdon): Timestamp.
-  return create(ThreadType, {
-    messages: faker.helpers.multiple(
-      () =>
-        makeRef(
+  return {
+    queue: {
+      items: faker.helpers.multiple(
+        () =>
           create(MessageType, {
             sender: {
               identityDid: IdentityDid.random(),
@@ -29,8 +30,8 @@ export const createInbox = (count = 10) => {
               subject: faker.commerce.productName(),
             },
           }),
-        ),
-      { count },
-    ),
-  });
+        { count },
+      ),
+    } as NonNullable<MailboxProps['messagesQueue']>,
+  };
 };
diff --git a/packages/plugins/experimental/plugin-inbox/tsconfig.json b/packages/plugins/experimental/plugin-inbox/tsconfig.json
index b99fdcd867f..7e4ec33d43e 100644
--- a/packages/plugins/experimental/plugin-inbox/tsconfig.json
+++ b/packages/plugins/experimental/plugin-inbox/tsconfig.json
@@ -62,6 +62,9 @@
     {
       "path": "../../../ui/react-ui-attention"
     },
+    {
+      "path": "../../../ui/react-ui-grid"
+    },
     {
       "path": "../../../ui/react-ui-stack"
     },
diff --git a/packages/plugins/plugin-navtree/src/components/L0Menu.tsx b/packages/plugins/plugin-navtree/src/components/L0Menu.tsx
index 0d8be0a9090..3c634f939a1 100644
--- a/packages/plugins/plugin-navtree/src/components/L0Menu.tsx
+++ b/packages/plugins/plugin-navtree/src/components/L0Menu.tsx
@@ -30,13 +30,13 @@ import {
 import type { StackItemRearrangeHandler } from '@dxos/react-ui-stack';
 import { Tabs } from '@dxos/react-ui-tabs';
 import { mx } from '@dxos/react-ui-theme';
-import { arrayMove } from '@dxos/util';
+import { arrayMove, getFirstTwoRenderableChars } from '@dxos/util';
 
 import { useNavTreeContext } from './NavTreeContext';
 import { NotchStart } from './NotchStart';
 import { useLoadDescendents } from '../hooks';
 import { NAVTREE_PLUGIN } from '../meta';
-import { getFirstTwoRenderableChars, l0ItemType } from '../util';
+import { l0ItemType } from '../util';
 
 type L0ItemData = { id: L0ItemProps['item']['id']; type: 'l0Item' };
 
diff --git a/packages/plugins/plugin-navtree/src/util.ts b/packages/plugins/plugin-navtree/src/util.ts
index 6e809852667..4f55f0ce843 100644
--- a/packages/plugins/plugin-navtree/src/util.ts
+++ b/packages/plugins/plugin-navtree/src/util.ts
@@ -139,44 +139,3 @@ export const l0ItemType = (item: Node<any>) => {
     return 'tab';
   }
 };
-
-// Regular expression to match renderable characters
-// Excludes control characters, combining marks, and other non-renderable characters
-const renderableCharRegex =
-  /^(?![\p{Control}\p{Mark}\p{Separator}\p{Surrogate}\p{Unassigned}])[\p{L}\p{N}\p{P}\p{S}\p{Emoji}]$/u;
-
-/**
- * Returns the first two renderable characters from a string that are separated by non-word characters.
- * Handles Unicode characters correctly.
- *
- * @param {string} label - The input string to process
- * @returns {[string, string]} Array containing the two characters, or empty strings if not found
- */
-export const getFirstTwoRenderableChars = (label: string) => {
-  // Convert string to array of Unicode characters
-  const characters = Array.from(label);
-
-  // Keep track of found renderable characters
-  const result = ['', ''];
-  let foundFirst = false;
-
-  for (let i = 0; i < characters.length; i++) {
-    const char = characters[i];
-
-    if (renderableCharRegex.test(char)) {
-      if (!foundFirst) {
-        result[0] = char;
-        foundFirst = true;
-      } else {
-        // Check if there's at least one non-word character between the first and current char
-        const textBetween = characters.slice(result[0].length, i).join('');
-        if (/[^\p{L}\p{N}_]/u.test(textBetween)) {
-          result[1] = char;
-          break;
-        }
-      }
-    }
-  }
-
-  return result;
-};
diff --git a/packages/ui/lit-ui/src/dx-avatar/dx-avatar.pcss b/packages/ui/lit-ui/src/dx-avatar/dx-avatar.pcss
index 917cde8d5e5..6e68a0c714e 100644
--- a/packages/ui/lit-ui/src/dx-avatar/dx-avatar.pcss
+++ b/packages/ui/lit-ui/src/dx-avatar/dx-avatar.pcss
@@ -29,7 +29,7 @@ dx-avatar {
 
   &[data-variant="square"] {
     .dx-avatar__frame, .dx-avatar__ring {
-      @apply rounded;
+      @apply rounded-sm;
     }
   }
 
@@ -69,10 +69,6 @@ dx-avatar {
     @apply animate-halo-pulse;
   }
 
-  .dx-avatar__fallback-text {
-    @apply fill-white;
-  }
-
   &[data-state-loading-status="loaded"] {
     .dx-avatar__fallback-text, .dx-avatar__status-icon {
       @apply hidden;
diff --git a/packages/ui/lit-ui/src/dx-avatar/dx-avatar.ts b/packages/ui/lit-ui/src/dx-avatar/dx-avatar.ts
index 7add810c170..dcbb876a8ba 100644
--- a/packages/ui/lit-ui/src/dx-avatar/dx-avatar.ts
+++ b/packages/ui/lit-ui/src/dx-avatar/dx-avatar.ts
@@ -16,7 +16,7 @@ export type AvatarVariant = 'square' | 'circle';
 export type AvatarStatus = 'active' | 'inactive' | 'current' | 'error' | 'warning' | 'internal';
 export type AvatarAnimation = 'pulse' | 'none';
 
-const rx = '0.25rem';
+const rx = '0.125rem';
 
 export type DxAvatarProps = Partial<
   Pick<
@@ -30,6 +30,7 @@ export type DxAvatarProps = Partial<
     | 'status'
     | 'animation'
     | 'hue'
+    | 'hueVariant'
     | 'size'
     | 'icon'
     | 'rootClassName'
@@ -72,6 +73,9 @@ export class DxAvatar extends LitElement {
   @property({ type: String })
   hue: string | undefined = undefined;
 
+  @property({ type: String })
+  hueVariant: 'fill' | 'surface' = 'fill';
+
   @property({ type: String })
   size: Size = 10;
 
@@ -111,6 +115,12 @@ export class DxAvatar extends LitElement {
     const r = sizePx / 2 - ringGap - ringWidth;
     const isTextOnly = Boolean(this.fallback && /[0-9a-zA-Z]+/.test(this.fallback));
     const fontScale = (isTextOnly ? 3 : 3.6) * (1 / 1.612);
+    const bg = this.hue
+      ? this.hueVariant === 'surface'
+        ? `var(--dx-${this.hue}Surface)`
+        : `var(--dx-${this.hue}Fill)`
+      : 'var(--surface-bg)';
+    const fg = this.hue && this.hueVariant === 'surface' ? `var(--dx-${this.hue}SurfaceText)` : 'var(--dx-inverse)';
     return html`<span
       role="img"
       class=${`dx-avatar${this.rootClassName ? ` ${this.rootClassName}` : ''}`}
@@ -135,8 +145,8 @@ export class DxAvatar extends LitElement {
                   fill="white"
                   width=${2 * r}
                   height=${2 * r}
-            x=${ringGap + ringWidth}
-            y=${ringGap + ringWidth}
+                  x=${ringGap + ringWidth}
+                  y=${ringGap + ringWidth}
                   rx=${rx}
                 />`
             }
@@ -148,10 +158,10 @@ export class DxAvatar extends LitElement {
               cx="50%"
               cy="50%"
               r=${r}
-              fill=${this.hue ? `var(--dx-${this.hue}Fill)` : 'var(--surface-bg)'}
+              fill=${bg}
             />`
             : svg` <rect
-              fill=${this.hue ? `var(--dx-${this.hue}Fill)` : 'var(--surface-bg)'}
+              fill=${bg}
               x=${ringGap + ringWidth}
               y=${ringGap + ringWidth}
               width=${2 * r}
@@ -185,6 +195,7 @@ export class DxAvatar extends LitElement {
                 x="50%"
                 y="50%"
                 class="dx-avatar__fallback-text"
+                fill=${fg}
                 text-anchor="middle"
                 alignment-baseline="central"
                 font-size=${this.size === 'px' ? '200%' : this.size * fontScale}
diff --git a/packages/ui/react-ui-theme/src/config/tokens/sememes-hue.ts b/packages/ui/react-ui-theme/src/config/tokens/sememes-hue.ts
index 7eb55eafbf4..2d86bf53694 100644
--- a/packages/ui/react-ui-theme/src/config/tokens/sememes-hue.ts
+++ b/packages/ui/react-ui-theme/src/config/tokens/sememes-hue.ts
@@ -5,29 +5,32 @@
 import { huePalettes } from './physical-colors';
 import { type ColorAliases, type ColorSememes } from './types';
 
-export const hueSememes: ColorSememes = Object.keys(huePalettes).reduce((acc: ColorSememes, palette) => {
-  acc[`${palette}Cursor`] = {
-    light: [palette, 400],
-    dark: [palette, 300],
-  };
-  acc[`${palette}Text`] = {
-    light: [palette, 550],
-    dark: [palette, 300],
-  };
-  acc[`${palette}Fill`] = {
-    light: [palette, 500],
-    dark: [palette, 500],
-  };
-  acc[`${palette}Surface`] = {
-    light: [palette, 200],
-    dark: [palette, 700],
-  };
-  acc[`${palette}SurfaceText`] = {
-    light: [palette, 700],
-    dark: [palette, 200],
-  };
-  return acc;
-}, {});
+export const hueSememes: ColorSememes = [...Object.keys(huePalettes), 'neutral', 'primary'].reduce(
+  (acc: ColorSememes, palette) => {
+    acc[`${palette}Cursor`] = {
+      light: [palette, 400],
+      dark: [palette, 300],
+    };
+    acc[`${palette}Text`] = {
+      light: [palette, 550],
+      dark: [palette, 300],
+    };
+    acc[`${palette}Fill`] = {
+      light: [palette, 500],
+      dark: [palette, 500],
+    };
+    acc[`${palette}Surface`] = {
+      light: [palette, 200],
+      dark: [palette, 700],
+    };
+    acc[`${palette}SurfaceText`] = {
+      light: [palette, 700],
+      dark: [palette, 200],
+    };
+    return acc;
+  },
+  {},
+);
 
 const valenceAliasSememeStems = ['Text', 'Surface', 'SurfaceText', 'Fill', 'Cursor'];
 const valenceMapping = {
@@ -35,7 +38,7 @@ const valenceMapping = {
   cyan: ['info'],
   amber: ['warning'],
   rose: ['error'],
-  blue: ['current'],
+  primary: ['current'],
   fuchsia: ['internal'],
 };
 
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 029353e476d..53334910593 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -52,7 +52,7 @@ importers:
     devDependencies:
       '@cloudflare/vitest-pool-workers':
         specifier: ^0.5.17
-        version: 0.5.17(@cloudflare/workers-types@4.20241022.0)(@vitest/runner@3.1.1)(@vitest/snapshot@3.1.1)(vitest@3.1.1)
+        version: 0.5.17(@cloudflare/workers-types@4.20241022.0)(@vitest/runner@3.1.1)(@vitest/snapshot@3.1.1)(vitest@3.1.1(@types/debug@4.1.12)(@types/node@22.10.2)(@vitest/browser@3.1.1)(@vitest/ui@3.1.1)(happy-dom@13.3.4)(jsdom@24.0.0(canvas@2.11.2(encoding@0.1.13)))(less@4.1.3)(sass@1.70.0)(stylus@0.59.0)(terser@5.19.2))
       '@cloudflare/workers-types':
         specifier: ^4.20241018.0
         version: 4.20241022.0
@@ -106,7 +106,7 @@ importers:
         version: link:packages/common/typings
       '@effect/vitest':
         specifier: ~0.17.3
-        version: 0.17.8(effect@3.13.3)(vitest@3.1.1)
+        version: 0.17.8(effect@3.13.3)(vitest@3.1.1(@types/debug@4.1.12)(@types/node@22.10.2)(@vitest/browser@3.1.1)(@vitest/ui@3.1.1)(happy-dom@13.3.4)(jsdom@24.0.0(canvas@2.11.2(encoding@0.1.13)))(less@4.1.3)(sass@1.70.0)(stylus@0.59.0)(terser@5.19.2))
       '@mdx-js/react':
         specifier: ^2.1.3
         version: 2.1.5(react@18.2.0)
@@ -127,13 +127,13 @@ importers:
         version: 19.7.2(@babel/traverse@7.25.9)(@swc-node/register@1.9.1(@swc/core@1.5.7(@swc/helpers@0.5.1))(@swc/types@0.1.5)(typescript@5.7.3))(@swc/core@1.5.7(@swc/helpers@0.5.1))(@types/node@22.10.2)(@zkochan/js-yaml@0.0.7)(eslint@8.57.0)(nx@19.7.2(@swc-node/register@1.9.1(@swc/core@1.5.7(@swc/helpers@0.5.1))(@swc/types@0.1.5)(typescript@5.7.3))(@swc/core@1.5.7(@swc/helpers@0.5.1)))
       '@nx/playwright':
         specifier: 19.7.2
-        version: 19.7.2(@babel/traverse@7.25.9)(@playwright/test@1.46.1)(@swc-node/register@1.9.1(@swc/core@1.5.7(@swc/helpers@0.5.1))(@swc/types@0.1.5)(typescript@5.7.3))(@swc/core@1.5.7(@swc/helpers@0.5.1))(@types/node@22.10.2)(@zkochan/js-yaml@0.0.7)(esbuild@0.23.1)(eslint@8.57.0)(nx@19.7.2(@swc-node/register@1.9.1(@swc/core@1.5.7(@swc/helpers@0.5.1))(@swc/types@0.1.5)(typescript@5.7.3))(@swc/core@1.5.7(@swc/helpers@0.5.1)))(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.7.3)(vite@5.4.7(patch_hash=i2cyw7vpoo7fbe7qgy5pwsznyq)(@types/node@22.10.2)(less@4.1.3)(sass@1.70.0)(stylus@0.59.0)(terser@5.19.2))(vitest@3.1.1)
+        version: 19.7.2(@babel/traverse@7.25.9)(@playwright/test@1.46.1)(@swc-node/register@1.9.1(@swc/core@1.5.7(@swc/helpers@0.5.1))(@swc/types@0.1.5)(typescript@5.7.3))(@swc/core@1.5.7(@swc/helpers@0.5.1))(@types/node@22.10.2)(@zkochan/js-yaml@0.0.7)(esbuild@0.23.1)(eslint@8.57.0)(nx@19.7.2(@swc-node/register@1.9.1(@swc/core@1.5.7(@swc/helpers@0.5.1))(@swc/types@0.1.5)(typescript@5.7.3))(@swc/core@1.5.7(@swc/helpers@0.5.1)))(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.7.3)(vite@5.4.7(patch_hash=i2cyw7vpoo7fbe7qgy5pwsznyq)(@types/node@22.10.2)(less@4.1.3)(sass@1.70.0)(stylus@0.59.0)(terser@5.19.2))(vitest@3.1.1(@types/debug@4.1.12)(@types/node@22.10.2)(@vitest/browser@3.1.1)(@vitest/ui@3.1.1)(happy-dom@13.3.4)(jsdom@24.0.0(canvas@2.11.2(encoding@0.1.13)))(less@4.1.3)(sass@1.70.0)(stylus@0.59.0)(terser@5.19.2))
       '@nx/storybook':
         specifier: 19.7.2
         version: 19.7.2(@babel/traverse@7.25.9)(@swc-node/register@1.9.1(@swc/core@1.5.7(@swc/helpers@0.5.1))(@swc/types@0.1.5)(typescript@5.7.3))(@swc/core@1.5.7(@swc/helpers@0.5.1))(@types/node@22.10.2)(@zkochan/js-yaml@0.0.7)(eslint@8.57.0)(nx@19.7.2(@swc-node/register@1.9.1(@swc/core@1.5.7(@swc/helpers@0.5.1))(@swc/types@0.1.5)(typescript@5.7.3))(@swc/core@1.5.7(@swc/helpers@0.5.1)))(typescript@5.7.3)
       '@nx/vite':
         specifier: 19.7.2
-        version: 19.7.2(@babel/traverse@7.25.9)(@swc-node/register@1.9.1(@swc/core@1.5.7(@swc/helpers@0.5.1))(@swc/types@0.1.5)(typescript@5.7.3))(@swc/core@1.5.7(@swc/helpers@0.5.1))(@types/node@22.10.2)(nx@19.7.2(@swc-node/register@1.9.1(@swc/core@1.5.7(@swc/helpers@0.5.1))(@swc/types@0.1.5)(typescript@5.7.3))(@swc/core@1.5.7(@swc/helpers@0.5.1)))(typescript@5.7.3)(vite@5.4.7(patch_hash=i2cyw7vpoo7fbe7qgy5pwsznyq)(@types/node@22.10.2)(less@4.1.3)(sass@1.70.0)(stylus@0.59.0)(terser@5.19.2))(vitest@3.1.1)
+        version: 19.7.2(@babel/traverse@7.25.9)(@swc-node/register@1.9.1(@swc/core@1.5.7(@swc/helpers@0.5.1))(@swc/types@0.1.5)(typescript@5.7.3))(@swc/core@1.5.7(@swc/helpers@0.5.1))(@types/node@22.10.2)(nx@19.7.2(@swc-node/register@1.9.1(@swc/core@1.5.7(@swc/helpers@0.5.1))(@swc/types@0.1.5)(typescript@5.7.3))(@swc/core@1.5.7(@swc/helpers@0.5.1)))(typescript@5.7.3)(vite@5.4.7(patch_hash=i2cyw7vpoo7fbe7qgy5pwsznyq)(@types/node@22.10.2)(less@4.1.3)(sass@1.70.0)(stylus@0.59.0)(terser@5.19.2))(vitest@3.1.1(@types/debug@4.1.12)(@types/node@22.10.2)(@vitest/browser@3.1.1)(@vitest/ui@3.1.1)(happy-dom@13.3.4)(jsdom@24.0.0(canvas@2.11.2(encoding@0.1.13)))(less@4.1.3)(sass@1.70.0)(stylus@0.59.0)(terser@5.19.2))
       '@nx/web':
         specifier: 19.7.2
         version: 19.7.2(@babel/traverse@7.25.9)(@swc-node/register@1.9.1(@swc/core@1.5.7(@swc/helpers@0.5.1))(@swc/types@0.1.5)(typescript@5.7.3))(@swc/core@1.5.7(@swc/helpers@0.5.1))(@types/node@22.10.2)(nx@19.7.2(@swc-node/register@1.9.1(@swc/core@1.5.7(@swc/helpers@0.5.1))(@swc/types@0.1.5)(typescript@5.7.3))(@swc/core@1.5.7(@swc/helpers@0.5.1)))(typescript@5.7.3)
@@ -229,7 +229,7 @@ importers:
         version: 3.1.1(playwright@1.46.1)(vite@5.4.7(patch_hash=i2cyw7vpoo7fbe7qgy5pwsznyq)(@types/node@22.10.2)(less@4.1.3)(sass@1.70.0)(stylus@0.59.0)(terser@5.19.2))(vitest@3.1.1)(webdriverio@8.33.1(encoding@0.1.13)(typescript@5.7.3))
       '@vitest/coverage-v8':
         specifier: ^3.1.1
-        version: 3.1.1(@vitest/browser@3.1.1)(vitest@3.1.1)
+        version: 3.1.1(@vitest/browser@3.1.1(playwright@1.46.1)(vite@5.4.7(patch_hash=i2cyw7vpoo7fbe7qgy5pwsznyq)(@types/node@22.10.2)(less@4.1.3)(sass@1.70.0)(stylus@0.59.0)(terser@5.19.2))(vitest@3.1.1)(webdriverio@8.33.1(encoding@0.1.13)(typescript@5.7.3)))(vitest@3.1.1(@types/debug@4.1.12)(@types/node@22.10.2)(@vitest/browser@3.1.1)(@vitest/ui@3.1.1)(happy-dom@13.3.4)(jsdom@24.0.0(canvas@2.11.2(encoding@0.1.13)))(less@4.1.3)(sass@1.70.0)(stylus@0.59.0)(terser@5.19.2))
       '@vitest/expect':
         specifier: ^3.1.1
         version: 3.1.1
@@ -4828,7 +4828,7 @@ importers:
     devDependencies:
       '@cloudflare/vitest-pool-workers':
         specifier: ^0.5.17
-        version: 0.5.17(@cloudflare/workers-types@4.20241022.0)(@vitest/runner@3.1.1)(@vitest/snapshot@3.1.1)(vitest@3.1.1)
+        version: 0.5.17(@cloudflare/workers-types@4.20241022.0)(@vitest/runner@3.1.1)(@vitest/snapshot@3.1.1)(vitest@3.1.1(@types/debug@4.1.12)(@types/node@22.10.2)(@vitest/browser@3.1.1)(@vitest/ui@3.1.1)(happy-dom@13.3.4)(jsdom@24.0.0(canvas@2.11.2(encoding@0.1.13)))(less@4.1.3)(sass@1.70.0)(stylus@0.59.0)(terser@5.19.2))
       '@cloudflare/workers-types':
         specifier: ^4.20241018.0
         version: 4.20241022.0
@@ -6080,6 +6080,9 @@ importers:
       '@dxos/react-ui-attention':
         specifier: workspace:*
         version: link:../../../ui/react-ui-attention
+      '@dxos/react-ui-grid':
+        specifier: workspace:*
+        version: link:../../../ui/react-ui-grid
       '@dxos/react-ui-stack':
         specifier: workspace:*
         version: link:../../../ui/react-ui-stack
@@ -6098,6 +6101,9 @@ importers:
       lodash.get:
         specifier: ^4.4.2
         version: 4.4.2
+      react-resize-detector:
+        specifier: ^12.0.2
+        version: 12.0.2(react@18.2.0)
     devDependencies:
       '@dxos/random':
         specifier: workspace:*
@@ -37655,7 +37661,7 @@ snapshots:
     dependencies:
       mime: 3.0.0
 
-  '@cloudflare/vitest-pool-workers@0.5.17(@cloudflare/workers-types@4.20241022.0)(@vitest/runner@3.1.1)(@vitest/snapshot@3.1.1)(vitest@3.1.1)':
+  '@cloudflare/vitest-pool-workers@0.5.17(@cloudflare/workers-types@4.20241022.0)(@vitest/runner@3.1.1)(@vitest/snapshot@3.1.1)(vitest@3.1.1(@types/debug@4.1.12)(@types/node@22.10.2)(@vitest/browser@3.1.1)(@vitest/ui@3.1.1)(happy-dom@13.3.4)(jsdom@24.0.0(canvas@2.11.2(encoding@0.1.13)))(less@4.1.3)(sass@1.70.0)(stylus@0.59.0)(terser@5.19.2))':
     dependencies:
       '@vitest/runner': 3.1.1
       '@vitest/snapshot': 3.1.1
@@ -38137,7 +38143,7 @@ snapshots:
       effect: 3.13.3
       fast-check: 3.23.1
 
-  '@effect/vitest@0.17.8(effect@3.13.3)(vitest@3.1.1)':
+  '@effect/vitest@0.17.8(effect@3.13.3)(vitest@3.1.1(@types/debug@4.1.12)(@types/node@22.10.2)(@vitest/browser@3.1.1)(@vitest/ui@3.1.1)(happy-dom@13.3.4)(jsdom@24.0.0(canvas@2.11.2(encoding@0.1.13)))(less@4.1.3)(sass@1.70.0)(stylus@0.59.0)(terser@5.19.2))':
     dependencies:
       effect: 3.13.3
       vitest: 3.1.1(@types/debug@4.1.12)(@types/node@22.10.2)(@vitest/browser@3.1.1)(@vitest/ui@3.1.1)(happy-dom@13.3.4)(jsdom@24.0.0(canvas@2.11.2(encoding@0.1.13)))(less@4.1.3)(sass@1.70.0)(stylus@0.59.0)(terser@5.19.2)
@@ -39867,9 +39873,9 @@ snapshots:
       - '@swc/core'
       - debug
 
-  '@nrwl/vite@19.7.2(@babel/traverse@7.25.9)(@swc-node/register@1.9.1(@swc/core@1.5.7(@swc/helpers@0.5.1))(@swc/types@0.1.5)(typescript@5.7.3))(@swc/core@1.5.7(@swc/helpers@0.5.1))(@types/node@22.10.2)(nx@19.7.2(@swc-node/register@1.9.1(@swc/core@1.5.7(@swc/helpers@0.5.1))(@swc/types@0.1.5)(typescript@5.7.3))(@swc/core@1.5.7(@swc/helpers@0.5.1)))(typescript@5.7.3)(vite@5.4.7(patch_hash=i2cyw7vpoo7fbe7qgy5pwsznyq)(@types/node@22.10.2)(less@4.1.3)(sass@1.70.0)(stylus@0.59.0)(terser@5.19.2))(vitest@3.1.1)':
+  '@nrwl/vite@19.7.2(@babel/traverse@7.25.9)(@swc-node/register@1.9.1(@swc/core@1.5.7(@swc/helpers@0.5.1))(@swc/types@0.1.5)(typescript@5.7.3))(@swc/core@1.5.7(@swc/helpers@0.5.1))(@types/node@22.10.2)(nx@19.7.2(@swc-node/register@1.9.1(@swc/core@1.5.7(@swc/helpers@0.5.1))(@swc/types@0.1.5)(typescript@5.7.3))(@swc/core@1.5.7(@swc/helpers@0.5.1)))(typescript@5.7.3)(vite@5.4.7(patch_hash=i2cyw7vpoo7fbe7qgy5pwsznyq)(@types/node@22.10.2)(less@4.1.3)(sass@1.70.0)(stylus@0.59.0)(terser@5.19.2))(vitest@3.1.1(@types/debug@4.1.12)(@types/node@22.10.2)(@vitest/browser@3.1.1)(@vitest/ui@3.1.1)(happy-dom@13.3.4)(jsdom@24.0.0(canvas@2.11.2(encoding@0.1.13)))(less@4.1.3)(sass@1.70.0)(stylus@0.59.0)(terser@5.19.2))':
     dependencies:
-      '@nx/vite': 19.7.2(@babel/traverse@7.25.9)(@swc-node/register@1.9.1(@swc/core@1.5.7(@swc/helpers@0.5.1))(@swc/types@0.1.5)(typescript@5.7.3))(@swc/core@1.5.7(@swc/helpers@0.5.1))(@types/node@22.10.2)(nx@19.7.2(@swc-node/register@1.9.1(@swc/core@1.5.7(@swc/helpers@0.5.1))(@swc/types@0.1.5)(typescript@5.7.3))(@swc/core@1.5.7(@swc/helpers@0.5.1)))(typescript@5.7.3)(vite@5.4.7(patch_hash=i2cyw7vpoo7fbe7qgy5pwsznyq)(@types/node@22.10.2)(less@4.1.3)(sass@1.70.0)(stylus@0.59.0)(terser@5.19.2))(vitest@3.1.1)
+      '@nx/vite': 19.7.2(@babel/traverse@7.25.9)(@swc-node/register@1.9.1(@swc/core@1.5.7(@swc/helpers@0.5.1))(@swc/types@0.1.5)(typescript@5.7.3))(@swc/core@1.5.7(@swc/helpers@0.5.1))(@types/node@22.10.2)(nx@19.7.2(@swc-node/register@1.9.1(@swc/core@1.5.7(@swc/helpers@0.5.1))(@swc/types@0.1.5)(typescript@5.7.3))(@swc/core@1.5.7(@swc/helpers@0.5.1)))(typescript@5.7.3)(vite@5.4.7(patch_hash=i2cyw7vpoo7fbe7qgy5pwsznyq)(@types/node@22.10.2)(less@4.1.3)(sass@1.70.0)(stylus@0.59.0)(terser@5.19.2))(vitest@3.1.1(@types/debug@4.1.12)(@types/node@22.10.2)(@vitest/browser@3.1.1)(@vitest/ui@3.1.1)(happy-dom@13.3.4)(jsdom@24.0.0(canvas@2.11.2(encoding@0.1.13)))(less@4.1.3)(sass@1.70.0)(stylus@0.59.0)(terser@5.19.2))
     transitivePeerDependencies:
       - '@babel/traverse'
       - '@swc-node/register'
@@ -40177,12 +40183,12 @@ snapshots:
   '@nx/nx-win32-x64-msvc@19.7.2':
     optional: true
 
-  '@nx/playwright@19.7.2(@babel/traverse@7.25.9)(@playwright/test@1.46.1)(@swc-node/register@1.9.1(@swc/core@1.5.7(@swc/helpers@0.5.1))(@swc/types@0.1.5)(typescript@5.7.3))(@swc/core@1.5.7(@swc/helpers@0.5.1))(@types/node@22.10.2)(@zkochan/js-yaml@0.0.7)(esbuild@0.23.1)(eslint@8.57.0)(nx@19.7.2(@swc-node/register@1.9.1(@swc/core@1.5.7(@swc/helpers@0.5.1))(@swc/types@0.1.5)(typescript@5.7.3))(@swc/core@1.5.7(@swc/helpers@0.5.1)))(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.7.3)(vite@5.4.7(patch_hash=i2cyw7vpoo7fbe7qgy5pwsznyq)(@types/node@22.10.2)(less@4.1.3)(sass@1.70.0)(stylus@0.59.0)(terser@5.19.2))(vitest@3.1.1)':
+  '@nx/playwright@19.7.2(@babel/traverse@7.25.9)(@playwright/test@1.46.1)(@swc-node/register@1.9.1(@swc/core@1.5.7(@swc/helpers@0.5.1))(@swc/types@0.1.5)(typescript@5.7.3))(@swc/core@1.5.7(@swc/helpers@0.5.1))(@types/node@22.10.2)(@zkochan/js-yaml@0.0.7)(esbuild@0.23.1)(eslint@8.57.0)(nx@19.7.2(@swc-node/register@1.9.1(@swc/core@1.5.7(@swc/helpers@0.5.1))(@swc/types@0.1.5)(typescript@5.7.3))(@swc/core@1.5.7(@swc/helpers@0.5.1)))(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.7.3)(vite@5.4.7(patch_hash=i2cyw7vpoo7fbe7qgy5pwsznyq)(@types/node@22.10.2)(less@4.1.3)(sass@1.70.0)(stylus@0.59.0)(terser@5.19.2))(vitest@3.1.1(@types/debug@4.1.12)(@types/node@22.10.2)(@vitest/browser@3.1.1)(@vitest/ui@3.1.1)(happy-dom@13.3.4)(jsdom@24.0.0(canvas@2.11.2(encoding@0.1.13)))(less@4.1.3)(sass@1.70.0)(stylus@0.59.0)(terser@5.19.2))':
     dependencies:
       '@nx/devkit': 19.7.2(nx@19.7.2(@swc-node/register@1.9.1(@swc/core@1.5.7(@swc/helpers@0.5.1))(@swc/types@0.1.5)(typescript@5.7.3))(@swc/core@1.5.7(@swc/helpers@0.5.1)))
       '@nx/eslint': 19.7.2(@babel/traverse@7.25.9)(@swc-node/register@1.9.1(@swc/core@1.5.7(@swc/helpers@0.5.1))(@swc/types@0.1.5)(typescript@5.7.3))(@swc/core@1.5.7(@swc/helpers@0.5.1))(@types/node@22.10.2)(@zkochan/js-yaml@0.0.7)(eslint@8.57.0)(nx@19.7.2(@swc-node/register@1.9.1(@swc/core@1.5.7(@swc/helpers@0.5.1))(@swc/types@0.1.5)(typescript@5.7.3))(@swc/core@1.5.7(@swc/helpers@0.5.1)))
       '@nx/js': 19.7.2(patch_hash=mavp33mumclr54vvfyyf557nvq)(@babel/traverse@7.25.9)(@swc-node/register@1.9.1(@swc/core@1.5.7(@swc/helpers@0.5.1))(@swc/types@0.1.5)(typescript@5.7.3))(@swc/core@1.5.7(@swc/helpers@0.5.1))(@types/node@22.10.2)(nx@19.7.2(@swc-node/register@1.9.1(@swc/core@1.5.7(@swc/helpers@0.5.1))(@swc/types@0.1.5)(typescript@5.7.3))(@swc/core@1.5.7(@swc/helpers@0.5.1)))(typescript@5.7.3)
-      '@nx/vite': 19.7.2(@babel/traverse@7.25.9)(@swc-node/register@1.9.1(@swc/core@1.5.7(@swc/helpers@0.5.1))(@swc/types@0.1.5)(typescript@5.7.3))(@swc/core@1.5.7(@swc/helpers@0.5.1))(@types/node@22.10.2)(nx@19.7.2(@swc-node/register@1.9.1(@swc/core@1.5.7(@swc/helpers@0.5.1))(@swc/types@0.1.5)(typescript@5.7.3))(@swc/core@1.5.7(@swc/helpers@0.5.1)))(typescript@5.7.3)(vite@5.4.7(patch_hash=i2cyw7vpoo7fbe7qgy5pwsznyq)(@types/node@22.10.2)(less@4.1.3)(sass@1.70.0)(stylus@0.59.0)(terser@5.19.2))(vitest@3.1.1)
+      '@nx/vite': 19.7.2(@babel/traverse@7.25.9)(@swc-node/register@1.9.1(@swc/core@1.5.7(@swc/helpers@0.5.1))(@swc/types@0.1.5)(typescript@5.7.3))(@swc/core@1.5.7(@swc/helpers@0.5.1))(@types/node@22.10.2)(nx@19.7.2(@swc-node/register@1.9.1(@swc/core@1.5.7(@swc/helpers@0.5.1))(@swc/types@0.1.5)(typescript@5.7.3))(@swc/core@1.5.7(@swc/helpers@0.5.1)))(typescript@5.7.3)(vite@5.4.7(patch_hash=i2cyw7vpoo7fbe7qgy5pwsznyq)(@types/node@22.10.2)(less@4.1.3)(sass@1.70.0)(stylus@0.59.0)(terser@5.19.2))(vitest@3.1.1(@types/debug@4.1.12)(@types/node@22.10.2)(@vitest/browser@3.1.1)(@vitest/ui@3.1.1)(happy-dom@13.3.4)(jsdom@24.0.0(canvas@2.11.2(encoding@0.1.13)))(less@4.1.3)(sass@1.70.0)(stylus@0.59.0)(terser@5.19.2))
       '@nx/webpack': 19.7.2(@babel/traverse@7.25.9)(@swc-node/register@1.9.1(@swc/core@1.5.7(@swc/helpers@0.5.1))(@swc/types@0.1.5)(typescript@5.7.3))(@swc/core@1.5.7(@swc/helpers@0.5.1))(@types/node@22.10.2)(esbuild@0.23.1)(nx@19.7.2(@swc-node/register@1.9.1(@swc/core@1.5.7(@swc/helpers@0.5.1))(@swc/types@0.1.5)(typescript@5.7.3))(@swc/core@1.5.7(@swc/helpers@0.5.1)))(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.7.3)
       '@phenomnomnominal/tsquery': 5.0.1(typescript@5.7.3)
       minimatch: 9.0.3
@@ -40249,9 +40255,9 @@ snapshots:
       - typescript
       - verdaccio
 
-  '@nx/vite@19.7.2(@babel/traverse@7.25.9)(@swc-node/register@1.9.1(@swc/core@1.5.7(@swc/helpers@0.5.1))(@swc/types@0.1.5)(typescript@5.7.3))(@swc/core@1.5.7(@swc/helpers@0.5.1))(@types/node@22.10.2)(nx@19.7.2(@swc-node/register@1.9.1(@swc/core@1.5.7(@swc/helpers@0.5.1))(@swc/types@0.1.5)(typescript@5.7.3))(@swc/core@1.5.7(@swc/helpers@0.5.1)))(typescript@5.7.3)(vite@5.4.7(patch_hash=i2cyw7vpoo7fbe7qgy5pwsznyq)(@types/node@22.10.2)(less@4.1.3)(sass@1.70.0)(stylus@0.59.0)(terser@5.19.2))(vitest@3.1.1)':
+  '@nx/vite@19.7.2(@babel/traverse@7.25.9)(@swc-node/register@1.9.1(@swc/core@1.5.7(@swc/helpers@0.5.1))(@swc/types@0.1.5)(typescript@5.7.3))(@swc/core@1.5.7(@swc/helpers@0.5.1))(@types/node@22.10.2)(nx@19.7.2(@swc-node/register@1.9.1(@swc/core@1.5.7(@swc/helpers@0.5.1))(@swc/types@0.1.5)(typescript@5.7.3))(@swc/core@1.5.7(@swc/helpers@0.5.1)))(typescript@5.7.3)(vite@5.4.7(patch_hash=i2cyw7vpoo7fbe7qgy5pwsznyq)(@types/node@22.10.2)(less@4.1.3)(sass@1.70.0)(stylus@0.59.0)(terser@5.19.2))(vitest@3.1.1(@types/debug@4.1.12)(@types/node@22.10.2)(@vitest/browser@3.1.1)(@vitest/ui@3.1.1)(happy-dom@13.3.4)(jsdom@24.0.0(canvas@2.11.2(encoding@0.1.13)))(less@4.1.3)(sass@1.70.0)(stylus@0.59.0)(terser@5.19.2))':
     dependencies:
-      '@nrwl/vite': 19.7.2(@babel/traverse@7.25.9)(@swc-node/register@1.9.1(@swc/core@1.5.7(@swc/helpers@0.5.1))(@swc/types@0.1.5)(typescript@5.7.3))(@swc/core@1.5.7(@swc/helpers@0.5.1))(@types/node@22.10.2)(nx@19.7.2(@swc-node/register@1.9.1(@swc/core@1.5.7(@swc/helpers@0.5.1))(@swc/types@0.1.5)(typescript@5.7.3))(@swc/core@1.5.7(@swc/helpers@0.5.1)))(typescript@5.7.3)(vite@5.4.7(patch_hash=i2cyw7vpoo7fbe7qgy5pwsznyq)(@types/node@22.10.2)(less@4.1.3)(sass@1.70.0)(stylus@0.59.0)(terser@5.19.2))(vitest@3.1.1)
+      '@nrwl/vite': 19.7.2(@babel/traverse@7.25.9)(@swc-node/register@1.9.1(@swc/core@1.5.7(@swc/helpers@0.5.1))(@swc/types@0.1.5)(typescript@5.7.3))(@swc/core@1.5.7(@swc/helpers@0.5.1))(@types/node@22.10.2)(nx@19.7.2(@swc-node/register@1.9.1(@swc/core@1.5.7(@swc/helpers@0.5.1))(@swc/types@0.1.5)(typescript@5.7.3))(@swc/core@1.5.7(@swc/helpers@0.5.1)))(typescript@5.7.3)(vite@5.4.7(patch_hash=i2cyw7vpoo7fbe7qgy5pwsznyq)(@types/node@22.10.2)(less@4.1.3)(sass@1.70.0)(stylus@0.59.0)(terser@5.19.2))(vitest@3.1.1(@types/debug@4.1.12)(@types/node@22.10.2)(@vitest/browser@3.1.1)(@vitest/ui@3.1.1)(happy-dom@13.3.4)(jsdom@24.0.0(canvas@2.11.2(encoding@0.1.13)))(less@4.1.3)(sass@1.70.0)(stylus@0.59.0)(terser@5.19.2))
       '@nx/devkit': 19.7.2(nx@19.7.2(@swc-node/register@1.9.1(@swc/core@1.5.7(@swc/helpers@0.5.1))(@swc/types@0.1.5)(typescript@5.7.3))(@swc/core@1.5.7(@swc/helpers@0.5.1)))
       '@nx/js': 19.7.2(patch_hash=mavp33mumclr54vvfyyf557nvq)(@babel/traverse@7.25.9)(@swc-node/register@1.9.1(@swc/core@1.5.7(@swc/helpers@0.5.1))(@swc/types@0.1.5)(typescript@5.7.3))(@swc/core@1.5.7(@swc/helpers@0.5.1))(@types/node@22.10.2)(nx@19.7.2(@swc-node/register@1.9.1(@swc/core@1.5.7(@swc/helpers@0.5.1))(@swc/types@0.1.5)(typescript@5.7.3))(@swc/core@1.5.7(@swc/helpers@0.5.1)))(typescript@5.7.3)
       '@phenomnomnominal/tsquery': 5.0.1(typescript@5.7.3)
@@ -45683,7 +45689,7 @@ snapshots:
       - utf-8-validate
       - vite
 
-  '@vitest/coverage-v8@3.1.1(@vitest/browser@3.1.1)(vitest@3.1.1)':
+  '@vitest/coverage-v8@3.1.1(@vitest/browser@3.1.1(playwright@1.46.1)(vite@5.4.7(patch_hash=i2cyw7vpoo7fbe7qgy5pwsznyq)(@types/node@22.10.2)(less@4.1.3)(sass@1.70.0)(stylus@0.59.0)(terser@5.19.2))(vitest@3.1.1)(webdriverio@8.33.1(encoding@0.1.13)(typescript@5.7.3)))(vitest@3.1.1(@types/debug@4.1.12)(@types/node@22.10.2)(@vitest/browser@3.1.1)(@vitest/ui@3.1.1)(happy-dom@13.3.4)(jsdom@24.0.0(canvas@2.11.2(encoding@0.1.13)))(less@4.1.3)(sass@1.70.0)(stylus@0.59.0)(terser@5.19.2))':
     dependencies:
       '@ampproject/remapping': 2.3.0
       '@bcoe/v8-coverage': 1.0.2


Reviews:
user: login: wittjosiah
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-09T18:45:21Z
user: login: thure
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-09T18:54:09Z
user: login: thure
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-09T18:55:06Z
user: login: thure
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-09T18:56:14Z
user: login: thure
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-09T18:56:49Z
user: login: thure
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-09T19:07:51Z

Review comments:
diff_hunk: @@ -28,6 +28,9 @@ export const InboxPlugin = () =>
           id: MailboxType.typename,
           metadata: {
             icon: 'ph--envelope--regular',
+            graphProps: {
+              startsWithCompanionSurfaceVariant: 'firstMessage',
path: packages/plugins/experimental/plugin-inbox/src/InboxPlugin.tsx
user: login: wittjosiah
body: video didn't show the companion at all, is this in use yet or just anticipatory?
created_at: 2025-04-09T10:41:34Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,44 @@
+// Regular expression to match renderable characters
+// Excludes control characters, combining marks, and other non-renderable characters
path: packages/common/util/src/first-two-chars.ts
user: login: wittjosiah
body: 👀 
created_at: 2025-04-09T18:39:21Z
author_association: MEMBER
diff_hunk: @@ -17,67 +24,182 @@ const byDate =
   ({ created: a = '' }: MessageType, { created: b = '' }: MessageType) =>
     a < b ? -direction : a > b ? direction : 0;
 
-const setMessageProperty = (message: MessageType, property: string, value: any) => {
-  if (!message.properties) {
-    message.properties = {};
-  }
+const updateMessageProperty = (messages: MessageType[], property: string, value: any) => {
path: packages/plugins/experimental/plugin-inbox/src/components/Mailbox/Mailbox.tsx
user: login: wittjosiah
body: anything mutating messages is deprecated and I'd remove it. There's proposals for editing messages on queues but currently none of it is implemented.

Understand if you want to try to afford some space in the UI for where actions might go but I wouldn't bother trying to retain existing implementations
created_at: 2025-04-09T18:41:02Z
author_association: MEMBER
diff_hunk: @@ -17,67 +24,182 @@ const byDate =
   ({ created: a = '' }: MessageType, { created: b = '' }: MessageType) =>
     a < b ? -direction : a > b ? direction : 0;
 
-const setMessageProperty = (message: MessageType, property: string, value: any) => {
-  if (!message.properties) {
-    message.properties = {};
-  }
+const updateMessageProperty = (messages: MessageType[], property: string, value: any) => {
+  messages.forEach((message) => {
+    if (!message.properties) {
+      message.properties = {};
+    }
 
-  message.properties[property] = value;
+    message.properties[property] = value;
+  });
 };
 
 export type MailboxOptions = { readTimout?: number };
 
+const messageRowDefault = {
+  grid: { size: 56 },
+};
+
+const messageColumnDefault = {
+  grid: { size: 100 },
+};
+
+const hashString = (str?: string): number => {
+  if (!str) {
+    return 0;
+  }
+  return Math.abs(str.split('').reduce((hash, char) => (hash << 5) + hash + char.charCodeAt(0), 0));
+};
+
+const renderMessageCell = (message: MessageType, now: Date) => {
+  const text = message.blocks.find((block) => block.type === 'text')?.text;
+  const date = formatDate(now, message.created ? new Date(message.created) : new Date());
+  const from = message.sender?.name ?? message.sender?.email;
+  const subject = message.properties?.subject ?? text;
+  const hue = toHue(hashString(from));
+
+  return `<button class="message__thumb dx-focus-ring-inset" data-inbox-action="select-message" data-message-id="${message.id}"
+    ><dx-avatar hue=${hue} variant="square" hueVariant="surface" fallback="${from ? getFirstTwoRenderableChars(from).join('') : '?'}"></dx-avatar
+  ></button><button class="message__abstract dx-focus-ring-inset" data-inbox-action="current-message" data-message-id="${message.id}"
+    ><p class="message__abstract__heading" role="none"><span>${from}</span><span>${date}</span></p
+    ><p class="message__abstract__body">${subject}</p
+  ></button>`;
+};
path: packages/plugins/experimental/plugin-inbox/src/components/Mailbox/Mailbox.tsx
user: login: wittjosiah
body: is there a nicer way to format this so its more readable?
created_at: 2025-04-09T18:43:27Z
author_association: MEMBER
diff_hunk: @@ -17,67 +24,182 @@ const byDate =
   ({ created: a = '' }: MessageType, { created: b = '' }: MessageType) =>
     a < b ? -direction : a > b ? direction : 0;
 
-const setMessageProperty = (message: MessageType, property: string, value: any) => {
-  if (!message.properties) {
-    message.properties = {};
-  }
+const updateMessageProperty = (messages: MessageType[], property: string, value: any) => {
+  messages.forEach((message) => {
+    if (!message.properties) {
+      message.properties = {};
+    }
 
-  message.properties[property] = value;
+    message.properties[property] = value;
+  });
 };
 
 export type MailboxOptions = { readTimout?: number };
 
+const messageRowDefault = {
+  grid: { size: 56 },
+};
+
+const messageColumnDefault = {
+  grid: { size: 100 },
+};
+
+const hashString = (str?: string): number => {
+  if (!str) {
+    return 0;
+  }
+  return Math.abs(str.split('').reduce((hash, char) => (hash << 5) + hash + char.charCodeAt(0), 0));
+};
+
+const renderMessageCell = (message: MessageType, now: Date) => {
+  const text = message.blocks.find((block) => block.type === 'text')?.text;
+  const date = formatDate(now, message.created ? new Date(message.created) : new Date());
+  const from = message.sender?.name ?? message.sender?.email;
+  const subject = message.properties?.subject ?? text;
+  const hue = toHue(hashString(from));
+
+  return `<button class="message__thumb dx-focus-ring-inset" data-inbox-action="select-message" data-message-id="${message.id}"
+    ><dx-avatar hue=${hue} variant="square" hueVariant="surface" fallback="${from ? getFirstTwoRenderableChars(from).join('') : '?'}"></dx-avatar
+  ></button><button class="message__abstract dx-focus-ring-inset" data-inbox-action="current-message" data-message-id="${message.id}"
+    ><p class="message__abstract__heading" role="none"><span>${from}</span><span>${date}</span></p
+    ><p class="message__abstract__body">${subject}</p
+  ></button>`;
+};
+
+const messageCellClassName = 'message';
+
 // TODO(burdon): Extract contacts/orgs.
 // TODO(burdon): Split message body into parts and allow trim (e.g., remove forwarded part). Message as light stack?
 // TODO(burdon): Highlight message/chunks for action (e.g., follow-up, triggers embedding).
 // TODO(burdon): Create outline/kanban.
 // TODO(burdon): Address book/cards.
 
-export type MailboxProps = { mailbox: MailboxType; options?: MailboxOptions };
+export type MailboxProps = { mailbox: MailboxType; options?: MailboxOptions; ignoreAttention?: boolean };
 
-export const Mailbox = ({ mailbox, options = {} }: MailboxProps) => {
-  const [selected, setSelected] = useState<string>();
+export const Mailbox = ({ mailbox, options = {}, ignoreAttention }: MailboxProps) => {
+  const [currentMessageId, setCurrentMessageId] = useState<string>();
   const queue = useQueue<MessageType>(mailbox.queue.dxn, { pollInterval: 1_000 });
   const tRef = useRef<ReturnType<typeof setTimeout>>();
+  const { hasAttention } = useAttention(mailbox.id);
path: packages/plugins/experimental/plugin-inbox/src/components/Mailbox/Mailbox.tsx
user: login: wittjosiah
body: should this be fully qualified id?
created_at: 2025-04-09T18:44:10Z
author_association: MEMBER
diff_hunk: @@ -28,6 +28,9 @@ export const InboxPlugin = () =>
           id: MailboxType.typename,
           metadata: {
             icon: 'ph--envelope--regular',
+            graphProps: {
+              startsWithCompanionSurfaceVariant: 'firstMessage',
path: packages/plugins/experimental/plugin-inbox/src/InboxPlugin.tsx
user: login: thure
body: It’s anticipatory, yes. I’d add a to-do but I’m getting to it next.
created_at: 2025-04-09T18:54:09Z
author_association: CONTRIBUTOR
diff_hunk: @@ -17,67 +24,182 @@ const byDate =
   ({ created: a = '' }: MessageType, { created: b = '' }: MessageType) =>
     a < b ? -direction : a > b ? direction : 0;
 
-const setMessageProperty = (message: MessageType, property: string, value: any) => {
-  if (!message.properties) {
-    message.properties = {};
-  }
+const updateMessageProperty = (messages: MessageType[], property: string, value: any) => {
path: packages/plugins/experimental/plugin-inbox/src/components/Mailbox/Mailbox.tsx
user: login: thure
body: This was just moving callbacks that were extant in `MessageList` — I can delete this if we don’t plan on using it.
created_at: 2025-04-09T18:55:06Z
author_association: CONTRIBUTOR
diff_hunk: @@ -17,67 +24,182 @@ const byDate =
   ({ created: a = '' }: MessageType, { created: b = '' }: MessageType) =>
     a < b ? -direction : a > b ? direction : 0;
 
-const setMessageProperty = (message: MessageType, property: string, value: any) => {
-  if (!message.properties) {
-    message.properties = {};
-  }
+const updateMessageProperty = (messages: MessageType[], property: string, value: any) => {
+  messages.forEach((message) => {
+    if (!message.properties) {
+      message.properties = {};
+    }
 
-  message.properties[property] = value;
+    message.properties[property] = value;
+  });
 };
 
 export type MailboxOptions = { readTimout?: number };
 
+const messageRowDefault = {
+  grid: { size: 56 },
+};
+
+const messageColumnDefault = {
+  grid: { size: 100 },
+};
+
+const hashString = (str?: string): number => {
+  if (!str) {
+    return 0;
+  }
+  return Math.abs(str.split('').reduce((hash, char) => (hash << 5) + hash + char.charCodeAt(0), 0));
+};
+
+const renderMessageCell = (message: MessageType, now: Date) => {
+  const text = message.blocks.find((block) => block.type === 'text')?.text;
+  const date = formatDate(now, message.created ? new Date(message.created) : new Date());
+  const from = message.sender?.name ?? message.sender?.email;
+  const subject = message.properties?.subject ?? text;
+  const hue = toHue(hashString(from));
+
+  return `<button class="message__thumb dx-focus-ring-inset" data-inbox-action="select-message" data-message-id="${message.id}"
+    ><dx-avatar hue=${hue} variant="square" hueVariant="surface" fallback="${from ? getFirstTwoRenderableChars(from).join('') : '?'}"></dx-avatar
+  ></button><button class="message__abstract dx-focus-ring-inset" data-inbox-action="current-message" data-message-id="${message.id}"
+    ><p class="message__abstract__heading" role="none"><span>${from}</span><span>${date}</span></p
+    ><p class="message__abstract__body">${subject}</p
+  ></button>`;
+};
path: packages/plugins/experimental/plugin-inbox/src/components/Mailbox/Mailbox.tsx
user: login: thure
body: Possibly, but I’d need to manually curate it, there don’t appear to be good lint rules for this, and I need to eliminate space between elements (hence lines like `></button>`, projects like Astro default to this type of formatting so there is good precedent).
created_at: 2025-04-09T18:56:14Z
author_association: CONTRIBUTOR
diff_hunk: @@ -17,67 +24,182 @@ const byDate =
   ({ created: a = '' }: MessageType, { created: b = '' }: MessageType) =>
     a < b ? -direction : a > b ? direction : 0;
 
-const setMessageProperty = (message: MessageType, property: string, value: any) => {
-  if (!message.properties) {
-    message.properties = {};
-  }
+const updateMessageProperty = (messages: MessageType[], property: string, value: any) => {
+  messages.forEach((message) => {
+    if (!message.properties) {
+      message.properties = {};
+    }
 
-  message.properties[property] = value;
+    message.properties[property] = value;
+  });
 };
 
 export type MailboxOptions = { readTimout?: number };
 
+const messageRowDefault = {
+  grid: { size: 56 },
+};
+
+const messageColumnDefault = {
+  grid: { size: 100 },
+};
+
+const hashString = (str?: string): number => {
+  if (!str) {
+    return 0;
+  }
+  return Math.abs(str.split('').reduce((hash, char) => (hash << 5) + hash + char.charCodeAt(0), 0));
+};
+
+const renderMessageCell = (message: MessageType, now: Date) => {
+  const text = message.blocks.find((block) => block.type === 'text')?.text;
+  const date = formatDate(now, message.created ? new Date(message.created) : new Date());
+  const from = message.sender?.name ?? message.sender?.email;
+  const subject = message.properties?.subject ?? text;
+  const hue = toHue(hashString(from));
+
+  return `<button class="message__thumb dx-focus-ring-inset" data-inbox-action="select-message" data-message-id="${message.id}"
+    ><dx-avatar hue=${hue} variant="square" hueVariant="surface" fallback="${from ? getFirstTwoRenderableChars(from).join('') : '?'}"></dx-avatar
+  ></button><button class="message__abstract dx-focus-ring-inset" data-inbox-action="current-message" data-message-id="${message.id}"
+    ><p class="message__abstract__heading" role="none"><span>${from}</span><span>${date}</span></p
+    ><p class="message__abstract__body">${subject}</p
+  ></button>`;
+};
+
+const messageCellClassName = 'message';
+
 // TODO(burdon): Extract contacts/orgs.
 // TODO(burdon): Split message body into parts and allow trim (e.g., remove forwarded part). Message as light stack?
 // TODO(burdon): Highlight message/chunks for action (e.g., follow-up, triggers embedding).
 // TODO(burdon): Create outline/kanban.
 // TODO(burdon): Address book/cards.
 
-export type MailboxProps = { mailbox: MailboxType; options?: MailboxOptions };
+export type MailboxProps = { mailbox: MailboxType; options?: MailboxOptions; ignoreAttention?: boolean };
 
-export const Mailbox = ({ mailbox, options = {} }: MailboxProps) => {
-  const [selected, setSelected] = useState<string>();
+export const Mailbox = ({ mailbox, options = {}, ignoreAttention }: MailboxProps) => {
+  const [currentMessageId, setCurrentMessageId] = useState<string>();
   const queue = useQueue<MessageType>(mailbox.queue.dxn, { pollInterval: 1_000 });
   const tRef = useRef<ReturnType<typeof setTimeout>>();
+  const { hasAttention } = useAttention(mailbox.id);
path: packages/plugins/experimental/plugin-inbox/src/components/Mailbox/Mailbox.tsx
user: login: thure
body: AFAIK attention does not use fully qualified IDs because the original objects are not always available (or relevant).
created_at: 2025-04-09T18:56:49Z
author_association: CONTRIBUTOR
diff_hunk: @@ -17,67 +24,182 @@ const byDate =
   ({ created: a = '' }: MessageType, { created: b = '' }: MessageType) =>
     a < b ? -direction : a > b ? direction : 0;
 
-const setMessageProperty = (message: MessageType, property: string, value: any) => {
-  if (!message.properties) {
-    message.properties = {};
-  }
+const updateMessageProperty = (messages: MessageType[], property: string, value: any) => {
+  messages.forEach((message) => {
+    if (!message.properties) {
+      message.properties = {};
+    }
 
-  message.properties[property] = value;
+    message.properties[property] = value;
+  });
 };
 
 export type MailboxOptions = { readTimout?: number };
 
+const messageRowDefault = {
+  grid: { size: 56 },
+};
+
+const messageColumnDefault = {
+  grid: { size: 100 },
+};
+
+const hashString = (str?: string): number => {
+  if (!str) {
+    return 0;
+  }
+  return Math.abs(str.split('').reduce((hash, char) => (hash << 5) + hash + char.charCodeAt(0), 0));
+};
+
+const renderMessageCell = (message: MessageType, now: Date) => {
+  const text = message.blocks.find((block) => block.type === 'text')?.text;
+  const date = formatDate(now, message.created ? new Date(message.created) : new Date());
+  const from = message.sender?.name ?? message.sender?.email;
+  const subject = message.properties?.subject ?? text;
+  const hue = toHue(hashString(from));
+
+  return `<button class="message__thumb dx-focus-ring-inset" data-inbox-action="select-message" data-message-id="${message.id}"
+    ><dx-avatar hue=${hue} variant="square" hueVariant="surface" fallback="${from ? getFirstTwoRenderableChars(from).join('') : '?'}"></dx-avatar
+  ></button><button class="message__abstract dx-focus-ring-inset" data-inbox-action="current-message" data-message-id="${message.id}"
+    ><p class="message__abstract__heading" role="none"><span>${from}</span><span>${date}</span></p
+    ><p class="message__abstract__body">${subject}</p
+  ></button>`;
+};
+
+const messageCellClassName = 'message';
+
 // TODO(burdon): Extract contacts/orgs.
 // TODO(burdon): Split message body into parts and allow trim (e.g., remove forwarded part). Message as light stack?
 // TODO(burdon): Highlight message/chunks for action (e.g., follow-up, triggers embedding).
 // TODO(burdon): Create outline/kanban.
 // TODO(burdon): Address book/cards.
 
-export type MailboxProps = { mailbox: MailboxType; options?: MailboxOptions };
+export type MailboxProps = { mailbox: MailboxType; options?: MailboxOptions; ignoreAttention?: boolean };
 
-export const Mailbox = ({ mailbox, options = {} }: MailboxProps) => {
-  const [selected, setSelected] = useState<string>();
+export const Mailbox = ({ mailbox, options = {}, ignoreAttention }: MailboxProps) => {
+  const [currentMessageId, setCurrentMessageId] = useState<string>();
   const queue = useQueue<MessageType>(mailbox.queue.dxn, { pollInterval: 1_000 });
   const tRef = useRef<ReturnType<typeof setTimeout>>();
+  const { hasAttention } = useAttention(mailbox.id);
path: packages/plugins/experimental/plugin-inbox/src/components/Mailbox/Mailbox.tsx
user: login: thure
body: I think I’m wrong on this, seeing as SheetContainer uses fullyQualified Id, will fix here.
created_at: 2025-04-09T19:07:51Z
author_association: CONTRIBUTOR

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

| File | Status |
| :--- | :---   |
| [<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/8918/changes#packages/ui/react-ui-theme/src/config/tokens/sememes-hue.ts)&nbsp;&nbsp;[packages/ui/react\-ui\-theme/src/config/tokens/sememes\-hue\.ts](https://app.semanticdiff.com/gh/dxos/dxos/pull/8918/changes#packages/ui/react-ui-theme/src/config/tokens/sememes-hue.ts) | [<img src="https://app.semanticdiff.com/images/progressbar.svg?progress=99">](https://app.semanticdiff.com/gh/dxos/dxos/pull/8918/changes#packages/ui/react-ui-theme/src/config/tokens/sememes-hue.ts) &nbsp;99% smaller |
| [<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/8918/changes#packages/plugins/experimental/plugin-inbox/package.json)&nbsp;&nbsp;[packages/plugins/experimental/plugin\-inbox/package\.json](https://app.semanticdiff.com/gh/dxos/dxos/pull/8918/changes#packages/plugins/experimental/plugin-inbox/package.json) | [<img src="https://app.semanticdiff.com/images/progressbar.svg?progress=50">](https://app.semanticdiff.com/gh/dxos/dxos/pull/8918/changes#packages/plugins/experimental/plugin-inbox/package.json) &nbsp;50% 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/8918/changes#packages/plugins/experimental/plugin-inbox/src/components/Mailbox/MailboxContainer.tsx)&nbsp;&nbsp;[packages/plugins/experimental/plugin\-inbox/src/components/Mailbox/MailboxContainer\.tsx](https://app.semanticdiff.com/gh/dxos/dxos/pull/8918/changes#packages/plugins/experimental/plugin-inbox/src/components/Mailbox/MailboxContainer.tsx) | [<img src="https://app.semanticdiff.com/images/progressbar.svg?progress=50">](https://app.semanticdiff.com/gh/dxos/dxos/pull/8918/changes#packages/plugins/experimental/plugin-inbox/src/components/Mailbox/MailboxContainer.tsx) &nbsp;50% 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/8918/changes#packages/plugins/experimental/plugin-inbox/src/testing/testing.ts)&nbsp;&nbsp;[packages/plugins/experimental/plugin\-inbox/src/testing/testing\.ts](https://app.semanticdiff.com/gh/dxos/dxos/pull/8918/changes#packages/plugins/experimental/plugin-inbox/src/testing/testing.ts) | [<img src="https://app.semanticdiff.com/images/progressbar.svg?progress=43">](https://app.semanticdiff.com/gh/dxos/dxos/pull/8918/changes#packages/plugins/experimental/plugin-inbox/src/testing/testing.ts) &nbsp;43% 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/8918/changes#packages/plugins/experimental/plugin-inbox/src/components/Calendar/EventsContainer.tsx)&nbsp;&nbsp;[packages/plugins/experimental/plugin\-inbox/src/components/Calendar/EventsContainer\.tsx](https://app.semanticdiff.com/gh/dxos/dxos/pull/8918/changes#packages/plugins/experimental/plugin-inbox/src/components/Calendar/EventsContainer.tsx) | [<img src="https://app.semanticdiff.com/images/progressbar.svg?progress=40">](https://app.semanticdiff.com/gh/dxos/dxos/pull/8918/changes#packages/plugins/experimental/plugin-inbox/src/components/Calendar/EventsContainer.tsx) &nbsp;40% 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/8918/changes#packages/plugins/experimental/plugin-inbox/src/components/Contacts/ContactsContainer.tsx)&nbsp;&nbsp;[packages/plugins/experimental/plugin\-inbox/src/components/Contacts/ContactsContainer\.tsx](https://app.semanticdiff.com/gh/dxos/dxos/pull/8918/changes#packages/plugins/experimental/plugin-inbox/src/components/Contacts/ContactsContainer.tsx) | [<img src="https://app.semanticdiff.com/images/progressbar.svg?progress=40">](https://app.semanticdiff.com/gh/dxos/dxos/pull/8918/changes#packages/plugins/experimental/plugin-inbox/src/components/Contacts/ContactsContainer.tsx) &nbsp;40% 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/8918/changes#packages/plugins/experimental/plugin-inbox/src/components/Mailbox/Mailbox.tsx)&nbsp;&nbsp;[packages/plugins/experimental/plugin\-inbox/src/components/Mailbox/Mailbox\.tsx](https://app.semanticdiff.com/gh/dxos/dxos/pull/8918/changes#packages/plugins/experimental/plugin-inbox/src/components/Mailbox/Mailbox.tsx) | [<img src="https://app.semanticdiff.com/images/progressbar.svg?progress=21">](https://app.semanticdiff.com/gh/dxos/dxos/pull/8918/changes#packages/plugins/experimental/plugin-inbox/src/components/Mailbox/Mailbox.tsx) &nbsp;21% 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/8918/changes#packages/apps/composer-app/src/plugins.tsx)&nbsp;&nbsp;[packages/apps/composer\-app/src/plugins\.tsx](https://app.semanticdiff.com/gh/dxos/dxos/pull/8918/changes#packages/apps/composer-app/src/plugins.tsx) | [<img src="https://app.semanticdiff.com/images/progressbar.svg?progress=10">](https://app.semanticdiff.com/gh/dxos/dxos/pull/8918/changes#packages/apps/composer-app/src/plugins.tsx) &nbsp;10% 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/8918/changes#packages/plugins/plugin-navtree/src/components/L0Menu.tsx)&nbsp;&nbsp;[packages/plugins/plugin\-navtree/src/components/L0Menu\.tsx](https://app.semanticdiff.com/gh/dxos/dxos/pull/8918/changes#packages/plugins/plugin-navtree/src/components/L0Menu.tsx) | [<img src="https://app.semanticdiff.com/images/progressbar.svg?progress=2">](https://app.semanticdiff.com/gh/dxos/dxos/pull/8918/changes#packages/plugins/plugin-navtree/src/components/L0Menu.tsx) &nbsp;2% 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/8918/changes#packages/ui/lit-ui/src/dx-avatar/dx-avatar.ts)&nbsp;&nbsp;[packages/ui/lit\-ui/src/dx\-avatar/dx\-avatar\.ts](https://app.semanticdiff.com/gh/dxos/dxos/pull/8918/changes#packages/ui/lit-ui/src/dx-avatar/dx-avatar.ts) | [<img src="https://app.semanticdiff.com/images/progressbar.svg?progress=2">](https://app.semanticdiff.com/gh/dxos/dxos/pull/8918/changes#packages/ui/lit-ui/src/dx-avatar/dx-avatar.ts) &nbsp;2% 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/8918/changes#packages/common/util/src/first-two-chars.ts)&nbsp;&nbsp;[packages/common/util/src/first\-two\-chars\.ts](https://app.semanticdiff.com/gh/dxos/dxos/pull/8918/changes#packages/common/util/src/first-two-chars.ts) | [<img src="https://app.semanticdiff.com/images/progressbar.svg?progress=0">](https://app.semanticdiff.com/gh/dxos/dxos/pull/8918/changes#packages/common/util/src/first-two-chars.ts) &nbsp;0% 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/8918/changes#packages/common/util/src/index.ts)&nbsp;&nbsp;[packages/common/util/src/index\.ts](https://app.semanticdiff.com/gh/dxos/dxos/pull/8918/changes#packages/common/util/src/index.ts) | [<img src="https://app.semanticdiff.com/images/progressbar.svg?progress=0">](https://app.semanticdiff.com/gh/dxos/dxos/pull/8918/changes#packages/common/util/src/index.ts) &nbsp;0% 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/8918/changes#packages/plugins/experimental/plugin-inbox/src/InboxPlugin.tsx)&nbsp;&nbsp;[packages/plugins/experimental/plugin\-inbox/src/InboxPlugin\.tsx](https://app.semanticdiff.com/gh/dxos/dxos/pull/8918/changes#packages/plugins/experimental/plugin-inbox/src/InboxPlugin.tsx) | [<img src="https://app.semanticdiff.com/images/progressbar.svg?progress=0">](https://app.semanticdiff.com/gh/dxos/dxos/pull/8918/changes#packages/plugins/experimental/plugin-inbox/src/InboxPlugin.tsx) &nbsp;0% smaller |
| [<img src="https://app.semanticdiff.com/static/file-icons/storybook.svg" width="20" height="20" align="center">](https://app.semanticdiff.com/gh/dxos/dxos/pull/8918/changes#packages/plugins/experimental/plugin-inbox/src/components/Mailbox/Mailbox.stories.tsx)&nbsp;&nbsp;[packages/plugins/experimental/plugin\-inbox/src/components/Mailbox/Mailbox\.stories\.tsx](https://app.semanticdiff.com/gh/dxos/dxos/pull/8918/changes#packages/plugins/experimental/plugin-inbox/src/components/Mailbox/Mailbox.stories.tsx) | [<img src="https://app.semanticdiff.com/images/progressbar.svg?progress=0">](https://app.semanticdiff.com/gh/dxos/dxos/pull/8918/changes#packages/plugins/experimental/plugin-inbox/src/components/Mailbox/Mailbox.stories.tsx) &nbsp;0% smaller |
| [<img src="https://app.semanticdiff.com/static/file-icons/storybook.svg" width="20" height="20" align="center">](https://app.semanticdiff.com/gh/dxos/dxos/pull/8918/changes#packages/plugins/experimental/plugin-inbox/src/components/Mailbox/MessageList.stories.tsx)&nbsp;&nbsp;[packages/plugins/experimental/plugin\-inbox/src/components/Mailbox/MessageList\.stories\.tsx](https://app.semanticdiff.com/gh/dxos/dxos/pull/8918/changes#packages/plugins/experimental/plugin-inbox/src/components/Mailbox/MessageList.stories.tsx) | [<img src="https://app.semanticdiff.com/images/progressbar.svg?progress=0">](https://app.semanticdiff.com/gh/dxos/dxos/pull/8918/changes#packages/plugins/experimental/plugin-inbox/src/components/Mailbox/MessageList.stories.tsx) &nbsp;0% 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/8918/changes#packages/plugins/experimental/plugin-inbox/src/components/Mailbox/MessageList.tsx)&nbsp;&nbsp;[packages/plugins/experimental/plugin\-inbox/src/components/Mailbox/MessageList\.tsx](https://app.semanticdiff.com/gh/dxos/dxos/pull/8918/changes#packages/plugins/experimental/plugin-inbox/src/components/Mailbox/MessageList.tsx) | [<img src="https://app.semanticdiff.com/images/progressbar.svg?progress=0">](https://app.semanticdiff.com/gh/dxos/dxos/pull/8918/changes#packages/plugins/experimental/plugin-inbox/src/components/Mailbox/MessageList.tsx) &nbsp;0% smaller |
| [<img src="https://app.semanticdiff.com/static/file-icons/css.svg" width="20" height="20" align="center">](https://app.semanticdiff.com/gh/dxos/dxos/pull/8918/changes#packages/plugins/experimental/plugin-inbox/src/components/Mailbox/mailbox.css)&nbsp;&nbsp;[packages/plugins/experimental/plugin\-inbox/src/components/Mailbox/mailbox\.css](https://app.semanticdiff.com/gh/dxos/dxos/pull/8918/changes#packages/plugins/experimental/plugin-inbox/src/components/Mailbox/mailbox.css) | Unsupported file format |
| [<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/8918/changes#packages/plugins/experimental/plugin-inbox/src/components/MasterDetail/MasterDetail.tsx)&nbsp;&nbsp;[packages/plugins/experimental/plugin\-inbox/src/components/MasterDetail/MasterDetail\.tsx](https://app.semanticdiff.com/gh/dxos/dxos/pull/8918/changes#packages/plugins/experimental/plugin-inbox/src/components/MasterDetail/MasterDetail.tsx) | [<img src="https://app.semanticdiff.com/images/progressbar.svg?progress=0">](https://app.semanticdiff.com/gh/dxos/dxos/pull/8918/changes#packages/plugins/experimental/plugin-inbox/src/components/MasterDetail/MasterDetail.tsx) &nbsp;0% 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/8918/changes#packages/plugins/experimental/plugin-inbox/src/components/MasterDetail/index.ts)&nbsp;&nbsp;[packages/plugins/experimental/plugin\-inbox/src/components/MasterDetail/index\.ts](https://app.semanticdiff.com/gh/dxos/dxos/pull/8918/changes#packages/plugins/experimental/plugin-inbox/src/components/MasterDetail/index.ts) | [<img src="https://app.semanticdiff.com/images/progressbar.svg?progress=0">](https://app.semanticdiff.com/gh/dxos/dxos/pull/8918/changes#packages/plugins/experimental/plugin-inbox/src/components/MasterDetail/index.ts) &nbsp;0% smaller |
| [<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/8918/changes#packages/plugins/experimental/plugin-inbox/tsconfig.json)&nbsp;&nbsp;[packages/plugins/experimental/plugin\-inbox/tsconfig\.json](https://app.semanticdiff.com/gh/dxos/dxos/pull/8918/changes#packages/plugins/experimental/plugin-inbox/tsconfig.json) | [<img src="https://app.semanticdiff.com/images/progressbar.svg?progress=0">](https://app.semanticdiff.com/gh/dxos/dxos/pull/8918/changes#packages/plugins/experimental/plugin-inbox/tsconfig.json) &nbsp;0% 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/8918/changes#packages/plugins/plugin-navtree/src/util.ts)&nbsp;&nbsp;[packages/plugins/plugin\-navtree/src/util\.ts](https://app.semanticdiff.com/gh/dxos/dxos/pull/8918/changes#packages/plugins/plugin-navtree/src/util.ts) | [<img src="https://app.semanticdiff.com/images/progressbar.svg?progress=0">](https://app.semanticdiff.com/gh/dxos/dxos/pull/8918/changes#packages/plugins/plugin-navtree/src/util.ts) &nbsp;0% smaller |
| [<img src="https://app.semanticdiff.com/static/file-icons/postcss.svg" width="20" height="20" align="center">](https://app.semanticdiff.com/gh/dxos/dxos/pull/8918/changes#packages/ui/lit-ui/src/dx-avatar/dx-avatar.pcss)&nbsp;&nbsp;[packages/ui/lit\-ui/src/dx\-avatar/dx\-avatar\.pcss](https://app.semanticdiff.com/gh/dxos/dxos/pull/8918/changes#packages/ui/lit-ui/src/dx-avatar/dx-avatar.pcss) | Unsupported file format |
| [<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/8918/changes#pnpm-lock.yaml)&nbsp;&nbsp;[pnpm\-lock\.yaml](https://app.semanticdiff.com/gh/dxos/dxos/pull/8918/changes#pnpm-lock.yaml) | [<img src="https://app.semanticdiff.com/images/progressbar.svg?progress=0">](https://app.semanticdiff.com/gh/dxos/dxos/pull/8918/changes#pnpm-lock.yaml) &nbsp;0% smaller |

</details>
author_association: NONE
user: login: nx-cloud[bot]
created_at: 2025-04-08T01:29:40Z
body: 
View your [CI Pipeline Execution ↗](https://cloud.nx.app/cipes/67f6ca5115c0036c762ee57c?utm_source=pull-request&utm_medium=comment) for commit 975b16f0cb231cc5f7773b675b3d8b41943bd504.

| Command | Status | Duration | Result |
|---------|--------|----------:|--------|
| `nx affected --base=d647f0418ec3fcf4e798bc7a1669...` | ✅ Succeeded | 1m 43s | [View ↗](https://cloud.nx.app/runs/TwO0qPa77V?utm_source=pull-request&utm_medium=comment) |
| `nx affected --base=d647f0418ec3fcf4e798bc7a1669...` | ✅ Succeeded | 6m 27s | [View ↗](https://cloud.nx.app/runs/DajLYkFKl8?utm_source=pull-request&utm_medium=comment) |
| `nx affected --base=d647f0418ec3fcf4e798bc7a1669...` | ✅ Succeeded | 12s | [View ↗](https://cloud.nx.app/runs/HeoESpVVlp?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-04-09 19:38:12` UTC

<!-- NX_CLOUD_APP_COMMENT_END -->
author_association: NONE