fix(desktop): improve inbox preview readability

Keep long agent previews bounded, preserve explicit unread styling in the inbox, and restore the inbox header/title layout.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Thomas Petersen
2026-06-18 08:42:18 -04:00
parent c338d8840c
commit df8855fa03
5 changed files with 165 additions and 27 deletions

View File

@@ -0,0 +1,81 @@
import assert from "node:assert/strict";
import test from "node:test";
import { buildInboxItems, INBOX_PREVIEW_MAX_LENGTH } from "./inbox.ts";
function feedItem(overrides = {}) {
return {
id: overrides.id ?? "event-1",
kind: overrides.kind ?? 45003,
pubkey: overrides.pubkey ?? "pubkey-1",
content: overrides.content ?? "hello",
createdAt: overrides.createdAt ?? 1_700_000_000,
channelId: overrides.channelId ?? "channel-1",
channelName: overrides.channelName ?? "general",
channelType: overrides.channelType,
tags: overrides.tags ?? [],
category: overrides.category ?? "activity",
};
}
function homeFeed({
mentions = [],
needsAction = [],
activity = [],
agentActivity = [],
} = {}) {
return {
feed: {
mentions,
needsAction,
activity,
agentActivity,
},
meta: {
since: 0,
total:
mentions.length +
needsAction.length +
activity.length +
agentActivity.length,
generatedAt: 1_700_000_000,
},
};
}
test("buildInboxItems caps regular and agent previews to the same length", () => {
const longHumanMessage = `human ${"message ".repeat(50)}`;
const longAgentMessage = `agent\n\n${"response ".repeat(50)}`;
const items = buildInboxItems({
feed: homeFeed({
activity: [
feedItem({
id: "human-message",
content: longHumanMessage,
createdAt: 1_700_000_000,
category: "activity",
}),
],
agentActivity: [
feedItem({
id: "agent-message",
content: longAgentMessage,
createdAt: 1_700_000_001,
category: "agent_activity",
}),
],
}),
});
const human = items.find((item) => item.id === "human-message");
const agent = items.find((item) => item.id === "agent-message");
assert.ok(human);
assert.ok(agent);
assert.equal(human.preview.length, INBOX_PREVIEW_MAX_LENGTH);
assert.equal(agent.preview.length, INBOX_PREVIEW_MAX_LENGTH);
assert.equal(human.preview.endsWith("..."), true);
assert.equal(agent.preview.endsWith("..."), true);
assert.equal(agent.preview.includes("\n"), false);
});

View File

@@ -88,6 +88,9 @@ const weekdayFormatter = new Intl.DateTimeFormat("en-US", {
weekday: "long",
});
/** Shared text cap for inbox list previews, including long agent responses. */
export const INBOX_PREVIEW_MAX_LENGTH = 160;
function startOfDay(value: Date) {
return new Date(value.getFullYear(), value.getMonth(), value.getDate());
}
@@ -134,9 +137,13 @@ function feedHeadline(item: FeedItem) {
}
function feedPreview(item: FeedItem) {
const content = item.content.trim();
const content = item.content.trim().replace(/\s+/g, " ");
if (content.length > 0) {
return content;
if (content.length <= INBOX_PREVIEW_MAX_LENGTH) {
return content;
}
return `${content.slice(0, INBOX_PREVIEW_MAX_LENGTH - 3).trimEnd()}...`;
}
if (item.kind === 46010) {

View File

@@ -414,7 +414,6 @@ export function HomeView({
onFilterChange={setFilter}
onSelect={(itemId) => {
handleUserSelectItem(itemId);
markItemRead(itemId);
}}
reminderPubkey={currentPubkey}
selectedId={selectedItemId}

View File

@@ -1,4 +1,4 @@
import { ChevronDown } from "lucide-react";
import { ChevronDown, Mail } from "lucide-react";
import * as React from "react";
import {
@@ -88,22 +88,32 @@ export function InboxListPane({
</div>
<div className="min-w-0 flex-1">
<div className="flex items-start gap-2">
<div className="flex items-baseline gap-2">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<p className="truncate text-sm font-semibold text-foreground">
{item.senderLabel}
<div className="min-w-0">
<div className="flex items-center gap-2">
<p className="truncate text-sm font-semibold text-foreground">
{item.senderLabel}
</p>
{item.isActionRequired ? (
<span className="inline-flex shrink-0 items-center text-2xs font-semibold uppercase tracking-[0.14em] text-amber-600 dark:text-amber-300">
Needs action
</span>
) : null}
</div>
<p
className={cn(
"mt-0.5 truncate text-2xs text-muted-foreground",
isDone ? "font-normal" : "font-semibold",
)}
>
{typeLabel}
</p>
{item.isActionRequired ? (
<span className="inline-flex shrink-0 items-center text-2xs font-semibold uppercase tracking-[0.14em] text-amber-600 dark:text-amber-300">
Needs action
</span>
) : null}
</div>
</div>
<span
className={cn(
"shrink-0 text-xs text-muted-foreground",
"shrink-0 text-xs leading-5 text-muted-foreground",
isDone ? "font-normal" : "font-semibold",
)}
>
@@ -126,17 +136,6 @@ export function InboxListPane({
mentionNames={item.mentionNames}
/>
</div>
<div className="mt-1 flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<span
className={cn(
"text-2xs text-muted-foreground",
isDone ? "font-normal" : "font-semibold",
)}
>
{typeLabel}
</span>
</div>
</div>
</button>
);
@@ -153,7 +152,11 @@ export function InboxListPane({
<div className="px-5 py-1">
{/* Cap to the list-column width so the right-aligned dropdown stays
put when the pane goes full-width in reminders mode. */}
<div className="flex min-w-0 max-w-[var(--home-inbox-list-width)] items-center justify-end gap-3">
<div className="flex min-w-0 max-w-[var(--home-inbox-list-width)] items-center justify-between gap-3">
<h1 className="flex min-w-0 items-center gap-1.5 text-sm font-semibold leading-5 tracking-tight text-foreground">
<Mail className="h-4 w-4 shrink-0 text-muted-foreground" />
<span className="truncate">Inbox</span>
</h1>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button

View File

@@ -45,15 +45,39 @@ export function useHomeInboxReadState({
markDoneLocal,
undoDoneLocal,
}: UseHomeInboxReadStateOptions) {
const [forcedUnreadItemIds, setForcedUnreadItemIds] = React.useState<
ReadonlySet<string>
>(() => new Set());
const itemById = React.useMemo(
() => new Map(items.map((item) => [item.id, item])),
[items],
);
React.useEffect(() => {
setForcedUnreadItemIds((current) => {
if (current.size === 0) {
return current;
}
const next = new Set<string>();
for (const item of items) {
if (current.has(item.id)) {
next.add(item.id);
}
}
return next.size === current.size ? current : next;
});
}, [items]);
// biome-ignore lint/correctness/useExhaustiveDependencies: readStateVersion invalidates getChannelReadAt
const effectiveDoneSet = React.useMemo<ReadonlySet<string>>(() => {
const result = new Set<string>();
for (const item of items) {
if (forcedUnreadItemIds.has(item.id)) {
continue;
}
const channelId = item.item.channelId;
if (channelId) {
const readAt = getChannelReadAt(channelId);
@@ -67,10 +91,26 @@ export function useHomeInboxReadState({
}
}
return result;
}, [getChannelReadAt, items, localDoneSet, readStateVersion]);
}, [
forcedUnreadItemIds,
getChannelReadAt,
items,
localDoneSet,
readStateVersion,
]);
const markItemRead = React.useCallback(
(itemId: string) => {
setForcedUnreadItemIds((current) => {
if (!current.has(itemId)) {
return current;
}
const next = new Set(current);
next.delete(itemId);
return next;
});
const item = itemById.get(itemId);
const channelId = item?.item.channelId ?? null;
if (item && channelId) {
@@ -87,6 +127,14 @@ export function useHomeInboxReadState({
const markItemUnread = React.useCallback(
(itemId: string) => {
setForcedUnreadItemIds((current) => {
if (current.has(itemId)) {
return current;
}
return new Set(current).add(itemId);
});
const item = itemById.get(itemId);
const channelId = item?.item.channelId ?? null;
if (item && channelId) {