diff --git a/desktop/src/features/home/lib/inbox.test.mjs b/desktop/src/features/home/lib/inbox.test.mjs new file mode 100644 index 000000000..783636855 --- /dev/null +++ b/desktop/src/features/home/lib/inbox.test.mjs @@ -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); +}); diff --git a/desktop/src/features/home/lib/inbox.ts b/desktop/src/features/home/lib/inbox.ts index 99d4fe31b..ff53134c9 100644 --- a/desktop/src/features/home/lib/inbox.ts +++ b/desktop/src/features/home/lib/inbox.ts @@ -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) { diff --git a/desktop/src/features/home/ui/HomeView.tsx b/desktop/src/features/home/ui/HomeView.tsx index fd5085bb3..a1361200f 100644 --- a/desktop/src/features/home/ui/HomeView.tsx +++ b/desktop/src/features/home/ui/HomeView.tsx @@ -41,6 +41,7 @@ import { countDueReminders, useRemindersQuery, } from "@/features/reminders/hooks"; +import { useRemindLater } from "@/features/reminders/ui/RemindMeLaterProvider"; import { deleteMessage, sendChannelMessage } from "@/shared/api/tauri"; import type { HomeFeedResponse } from "@/shared/api/types"; import { KIND_REACTION } from "@/shared/constants/kinds"; @@ -108,6 +109,7 @@ export function HomeView({ ); const [isDeletingMessage, setIsDeletingMessage] = React.useState(false); const [isSendingReply, setIsSendingReply] = React.useState(false); + const { openReminder, activeReminderEventIds } = useRemindLater(); const [localRepliesByItemId, setLocalRepliesByItemId] = React.useState< Record >({}); @@ -407,11 +409,29 @@ export function HomeView({ > {showListPane ? ( { + const channelId = item.item.channelId; + if (!channelId) { + return; + } + + onOpenContext(channelId, item.id); + }} + onRemindLater={(item) => { + openReminder({ + eventId: item.id, + channelId: item.item.channelId ?? "", + preview: item.preview.slice(0, 100), + authorPubkey: item.item.pubkey, + }); + }} onSelect={(itemId) => { handleUserSelectItem(itemId); markItemRead(itemId); diff --git a/desktop/src/features/home/ui/InboxListPane.tsx b/desktop/src/features/home/ui/InboxListPane.tsx index 670c95b46..205e8ad13 100644 --- a/desktop/src/features/home/ui/InboxListPane.tsx +++ b/desktop/src/features/home/ui/InboxListPane.tsx @@ -1,4 +1,4 @@ -import { ChevronDown } from "lucide-react"; +import { Clock, ExternalLink, Mail, MailOpen, ChevronDown } from "lucide-react"; import * as React from "react"; import { @@ -12,6 +12,7 @@ import { TopChromeInsetHeader } from "@/shared/layout/TopChromeInsetHeader"; import { cn } from "@/shared/lib/cn"; import { Button } from "@/shared/ui/button"; import { Markdown } from "@/shared/ui/markdown"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip"; import { DropdownMenu, DropdownMenuContent, @@ -36,11 +37,15 @@ type InboxListPaneProps = { filter: InboxFilter; items: InboxItem[]; onFilterChange: (filter: InboxFilter) => void; + onMarkUnread: (itemId: string) => void; + onOpenDirect: (item: InboxItem) => void; + onRemindLater: (item: InboxItem) => void; onSelect: (itemId: string) => void; selectedId: string | null; showRightDivider?: boolean; dueReminderCount: number; reminderPubkey?: string; + activeReminderEventIds?: ReadonlySet; }; export function InboxListPane({ @@ -48,11 +53,15 @@ export function InboxListPane({ filter, items, onFilterChange, + onMarkUnread, + onOpenDirect, + onRemindLater, onSelect, selectedId, showRightDivider = false, dueReminderCount, reminderPubkey, + activeReminderEventIds, }: InboxListPaneProps) { const activeFilter = FILTER_OPTIONS.find((option) => option.value === filter); const isReminders = filter === "reminders"; @@ -61,84 +70,116 @@ export function InboxListPane({ const renderItem = (item: InboxItem) => { const isSelected = item.id === selectedId; const isDone = doneSet.has(item.id); + const hasActiveReminder = activeReminderEventIds?.has(item.id) ?? false; + const hasChannelTarget = Boolean(item.item.channelId); const typeLabel = formatInboxTypeLabel(item); return ( - -
- - {typeLabel} - -
+
+ onMarkUnread(item.id)} + > + + + onOpenDirect(item)} + > + + + onRemindLater(item)} + > + +
- + ); }; @@ -153,7 +194,11 @@ export function InboxListPane({
{/* Cap to the list-column width so the right-aligned dropdown stays put when the pane goes full-width in reminders mode. */} -
+
+

+ + Inbox +

+ + {label} + + ); +} diff --git a/desktop/src/features/home/useHomeInboxReadState.ts b/desktop/src/features/home/useHomeInboxReadState.ts index 75134abed..275b698e2 100644 --- a/desktop/src/features/home/useHomeInboxReadState.ts +++ b/desktop/src/features/home/useHomeInboxReadState.ts @@ -45,15 +45,39 @@ export function useHomeInboxReadState({ markDoneLocal, undoDoneLocal, }: UseHomeInboxReadStateOptions) { + const [forcedUnreadItemIds, setForcedUnreadItemIds] = React.useState< + ReadonlySet + >(() => 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(); + 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>(() => { const result = new Set(); 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) { diff --git a/desktop/src/shared/ui/markdown.tsx b/desktop/src/shared/ui/markdown.tsx index 516ffceb4..468be9ca9 100644 --- a/desktop/src/shared/ui/markdown.tsx +++ b/desktop/src/shared/ui/markdown.tsx @@ -238,6 +238,7 @@ type MarkdownProps = { customEmoji?: CustomEmoji[]; imetaByUrl?: ImetaLookup; interactive?: boolean; + plainInlineReferences?: boolean; agentMentionPubkeysByName?: Record; mentionNames?: string[]; mentionPubkeysByName?: Record; @@ -1678,6 +1679,7 @@ function SpoilerInline({ function createMarkdownComponents( runtimeRef: React.RefObject, interactive = true, + plainInlineReferences = false, ): Components { const paragraphClassName = "leading-[inherit]"; const listItemClassName = "my-1 [&_p]:inline"; @@ -1799,6 +1801,14 @@ function createMarkdownComponents( ); } + if (plainInlineReferences) { + return ( + + {children} + + ); + } + return ( {children} @@ -1937,6 +1947,10 @@ function createMarkdownComponents( const { agentMentionPubkeysByName, mentionPubkeysByName } = runtimeRef.current; const mentionText = String(children ?? ""); + if (plainInlineReferences) { + return {mentionText}; + } + const mentionName = mentionText.replace(/^@/, "").trim().toLowerCase(); const pubkey = mentionPubkeysByName?.[mentionName]; const isAgentMention = @@ -1988,6 +2002,10 @@ function createMarkdownComponents( return ; }, "channel-link": ({ children }: { children?: React.ReactNode }) => { + if (plainInlineReferences) { + return {children}; + } + const { channels, onOpenChannel } = runtimeRef.current; const text = String(children ?? ""); const channelName = text.startsWith("#") ? text.slice(1) : text; @@ -2075,6 +2093,7 @@ function MarkdownInner({ customEmoji, imetaByUrl, interactive = true, + plainInlineReferences = false, agentMentionPubkeysByName, mentionNames, mentionPubkeysByName, @@ -2116,8 +2135,9 @@ function MarkdownInner({ }); const components = React.useMemo( - () => createMarkdownComponents(runtimeRef, interactive), - [runtimeRef, interactive], + () => + createMarkdownComponents(runtimeRef, interactive, plainInlineReferences), + [runtimeRef, interactive, plainInlineReferences], ); // biome-ignore lint/suspicious/noExplicitAny: PluggableList type not directly importable @@ -2200,6 +2220,7 @@ export const Markdown = React.memo( prev.className === next.className && prev.customEmoji === next.customEmoji && prev.interactive === next.interactive && + prev.plainInlineReferences === next.plainInlineReferences && prev.agentMentionPubkeysByName === next.agentMentionPubkeysByName && prev.mentionPubkeysByName === next.mentionPubkeysByName && shallowArrayEqual(prev.mentionNames, next.mentionNames) &&