diff --git a/.sqlx/query-bd5685982d6000af9914ed99171df936cad7745bff5a5b6ea54fa7d21011e7d9.json b/.sqlx/query-bd5685982d6000af9914ed99171df936cad7745bff5a5b6ea54fa7d21011e7d9.json
new file mode 100644
index 00000000000..fd086f9de88
--- /dev/null
+++ b/.sqlx/query-bd5685982d6000af9914ed99171df936cad7745bff5a5b6ea54fa7d21011e7d9.json
@@ -0,0 +1,28 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "\n SELECT t.link_id AS \"link_id!\", COUNT(*) AS \"unread_count!\"\n FROM email_threads t\n WHERE t.link_id = ANY($1)\n AND t.inbox_visible\n AND t.is_signal\n AND NOT t.is_read\n AND t.latest_inbound_message_ts IS NOT NULL\n GROUP BY t.link_id\n ",
+ "describe": {
+ "columns": [
+ {
+ "ordinal": 0,
+ "name": "link_id!",
+ "type_info": "Uuid"
+ },
+ {
+ "ordinal": 1,
+ "name": "unread_count!",
+ "type_info": "Int8"
+ }
+ ],
+ "parameters": {
+ "Left": [
+ "UuidArray"
+ ]
+ },
+ "nullable": [
+ false,
+ null
+ ]
+ },
+ "hash": "bd5685982d6000af9914ed99171df936cad7745bff5a5b6ea54fa7d21011e7d9"
+}
diff --git a/apps/web/src/components/app/app-sidebar/sidebar.tsx b/apps/web/src/components/app/app-sidebar/sidebar.tsx
index 2155face395..c2af3c7b0e6 100644
--- a/apps/web/src/components/app/app-sidebar/sidebar.tsx
+++ b/apps/web/src/components/app/app-sidebar/sidebar.tsx
@@ -90,6 +90,7 @@ import UsersThreeIcon from '@phosphor/users-three.svg';
import XIcon from '@phosphor/x.svg';
import { isRealNamePart, useOwnUserName } from '@queries/auth/user-name-self';
import { useEmailLinksQuery } from '@queries/email/link';
+import { useEmailUnreadCounts } from '@queries/email/unread-counts';
import {
useJoinTeamMutation,
useRejectInvitationMutation,
@@ -1599,6 +1600,11 @@ interface SidebarLinkProps extends SidebarItem {
* Email link's expand chevron.
*/
trailingWhenActive?: JSX.Element;
+ /**
+ * Unread count shown as a badge at the row's right edge, or over the icon in
+ * slim mode. `0` (or omitted) renders nothing.
+ */
+ unreadCount?: number;
/**
* Swaps the icon for an X while the row is hovered (expanded sidebar only —
* in slim mode the icon is the whole row, so the swap would hijack
@@ -1607,6 +1613,36 @@ interface SidebarLinkProps extends SidebarItem {
removeAction?: { tooltip: string; onRemove: () => void };
}
+/**
+ * Unread-count badge for a sidebar row. Renders nothing at zero, so callers can
+ * pass a count unconditionally.
+ *
+ * `slim` is the compact variant that sits over a rail icon, where there is only
+ * room for a single digit before the count becomes an "attention" marker.
+ */
+const SidebarUnreadBadge = (props: { count: number; slim?: boolean }) => {
+ const label = () => {
+ if (props.slim) return props.count > 9 ? '9+' : `${props.count}`;
+ return props.count > 99 ? '99+' : `${props.count}`;
+ };
+
+ return (
+ 0}>
+
+ {label()}
+
+
+ );
+};
+
/** Which action of {@link SidebarOpenInSplitMenu} placed the content. */
type SidebarOpenAction = 'current-split' | 'new-split' | 'fullscreen';
@@ -1780,6 +1816,12 @@ const SidebarLink = (props: SidebarLinkProps) => {
globalSplitManager()?.returnFocus();
}}
>
+ {/* Slim rail has no room for a trailing badge, so the count rides the
+ icon. Top-right keeps it clear of the bottom-right hotkey chip. */}
+
+
+
+
{
{props.label}
+ {/* Sits before the chevron and hotkey hints so the count keeps its
+ place as those come and go with hover and active state. */}
+
+
+
+
{
!props.hotkeyVisible
}
>
-