diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs index 35fd60fa4..e2cb0aa88 100644 --- a/desktop/scripts/check-file-sizes.mjs +++ b/desktop/scripts/check-file-sizes.mjs @@ -34,7 +34,7 @@ const overrides = new Map([ ["src-tauri/src/managed_agents/personas.rs", 950], // built-in persona system prompts (Solo + Kit + Scout) + merge_personas inequality checks + persona pack import/uninstall/list + uninstall safety check ["src-tauri/src/managed_agents/teams.rs", 580], // built-in team registry (Kit & Scout) + merge_teams + validate_team_deletion + JSON export/import + tests ["src-tauri/src/managed_agents/persona_card.rs", 970], // PNG/ZIP/MD persona card codec + pack-zip detection + nested root finder + provider/model/namePool fields + 27 unit tests - ["src/app/AppShell.tsx", 815], // message edit state + handlers + ChannelPane edit prop threading + scrollback pagination + workflows view + projects view + memory-leak safeguards + home-badge state lifted here so it consumes the same NIP-RS read-state as the sidebar (single ReadStateManager) + ["src/app/AppShell.tsx", 835], // message edit state + handlers + ChannelPane edit prop threading + scrollback pagination + workflows view + projects view + memory-leak safeguards + home-badge state lifted here so it consumes the same NIP-RS read-state as the sidebar (single ReadStateManager) + dock bounce wiring + mark-all-read context + channel notification callback + desktopEnabled guard ["src/features/channels/hooks.ts", 550], // canvas query + mutation hooks + DM hide mutation ["src/features/channels/ui/ChannelManagementSheet.tsx", 800], ["src/features/channels/ui/ChannelPane.tsx", 520], // composer/timeline/sidebar orchestration + anchored agent activity footers diff --git a/desktop/src-tauri/capabilities/default.json b/desktop/src-tauri/capabilities/default.json index bf7580f7d..035b32799 100644 --- a/desktop/src-tauri/capabilities/default.json +++ b/desktop/src-tauri/capabilities/default.json @@ -7,6 +7,7 @@ "core:default", "core:webview:allow-set-webview-zoom", "core:window:allow-set-badge-count", + "core:window:allow-request-user-attention", "core:window:allow-set-focus", "core:window:allow-start-dragging", "core:window:allow-toggle-maximize", diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index 837191eb9..ba033d194 100644 --- a/desktop/src/app/AppShell.tsx +++ b/desktop/src/app/AppShell.tsx @@ -11,6 +11,7 @@ import { } from "@/app/AppShellOverlays"; import { useAppNavigation } from "@/app/navigation/useAppNavigation"; import { useBackForwardControls } from "@/app/navigation/useBackForwardControls"; +import { useMarkAsReadShortcuts } from "@/app/useMarkAsReadShortcuts"; import { useWebviewZoomShortcuts } from "@/app/useWebviewZoomShortcuts"; import { channelsQueryKey, @@ -26,6 +27,7 @@ import { } from "@/features/notifications/hooks"; import { listenForDesktopNotificationActions, + requestDockBounce, revealDesktopAppWindow, sendDesktopNotification, setDesktopAppBadgeCount, @@ -209,6 +211,10 @@ export function AppShell() { const refetchHomeFeedOnLiveMention = React.useEffectEvent(() => { void homeFeedQuery.refetch(); }); + const handleChannelNotification = React.useEffectEvent(() => { + if (!notificationSettings.settings.desktopEnabled) return; + void requestDockBounce(); + }); const handleDmNotification = React.useEffectEvent( (event: RelayEvent, channel: Channel) => { @@ -238,9 +244,9 @@ export function AppShell() { pubkey: event.pubkey, }, }).then((didSend) => { - if (didSend && notificationSettings.settings.soundEnabled) { - playNotificationSound(); - } + if (!didSend) return; + if (notificationSettings.settings.soundEnabled) playNotificationSound(); + void requestDockBounce(); }); }, ); @@ -265,13 +271,14 @@ export function AppShell() { ); const { + markAllChannelsRead, markChannelRead, markChannelUnread, unreadChannelIds, getEffectiveTimestamp: getChannelReadAt, readStateVersion, } = useUnreadChannels( - channels, + sidebarChannels, activeChannel, // Wait for ChannelScreen to report the latest loaded message before // advancing unread state for the active channel. @@ -280,6 +287,7 @@ export function AppShell() { pubkey: identityQuery.data?.pubkey, relayClient, currentPubkey: identityQuery.data?.pubkey, + onChannelMessage: handleChannelNotification, onDmMessage: handleDmNotification, onLiveMention: refetchHomeFeedOnLiveMention, }, @@ -439,8 +447,8 @@ export function AppShell() { }, []); React.useEffect(() => { - void setDesktopAppBadgeCount(homeBadgeCount); - }, [homeBadgeCount]); + void setDesktopAppBadgeCount(unreadChannelIds.size + homeBadgeCount); + }, [homeBadgeCount, unreadChannelIds.size]); React.useEffect(() => { let isCancelled = false; @@ -546,6 +554,14 @@ export function AppShell() { }; }, [handleCloseSettings, handleOpenSettings, settingsOpen]); + useMarkAsReadShortcuts({ + activeChannelId: activeChannel?.id ?? null, + activeChannelLastMessageAt: activeChannel?.lastMessageAt, + markAllChannelsRead, + markChannelRead, + selectedView, + }); + React.useEffect(() => { function handlePointerDown(event: PointerEvent) { if (event.button !== 0 || event.detail > 1) { @@ -581,6 +597,7 @@ export function AppShell() { { @@ -693,6 +710,8 @@ export function AppShell() { void applyAgents(templateId, createdForum.id); }} onHideDm={handleHideDm} + onMarkAllChannelsRead={markAllChannelsRead} + onMarkChannelRead={markChannelRead} onMarkChannelUnread={markChannelUnread} onOpenBrowseChannels={handleOpenBrowseChannels} onOpenBrowseForums={handleOpenBrowseForums} diff --git a/desktop/src/app/AppShellContext.tsx b/desktop/src/app/AppShellContext.tsx index 533e50a3f..f61df09e6 100644 --- a/desktop/src/app/AppShellContext.tsx +++ b/desktop/src/app/AppShellContext.tsx @@ -1,6 +1,7 @@ import * as React from "react"; type AppShellContextValue = { + markAllChannelsRead: () => void; markChannelRead: ( channelId: string, readAt: string | null | undefined, @@ -20,6 +21,7 @@ type AppShellContextValue = { }; const AppShellContext = React.createContext({ + markAllChannelsRead: () => {}, markChannelRead: () => {}, markChannelUnread: () => {}, openChannelManagement: () => {}, diff --git a/desktop/src/app/useMarkAsReadShortcuts.ts b/desktop/src/app/useMarkAsReadShortcuts.ts new file mode 100644 index 000000000..281447730 --- /dev/null +++ b/desktop/src/app/useMarkAsReadShortcuts.ts @@ -0,0 +1,50 @@ +import * as React from "react"; + +import { hasPrimaryShortcutModifier } from "@/shared/lib/platform"; + +export function useMarkAsReadShortcuts({ + activeChannelId, + activeChannelLastMessageAt, + markAllChannelsRead, + markChannelRead, + selectedView, +}: { + activeChannelId: string | null; + activeChannelLastMessageAt: string | null | undefined; + markAllChannelsRead: () => void; + markChannelRead: ( + channelId: string, + lastMessageAt: string | null | undefined, + ) => void; + selectedView: string; +}) { + React.useEffect(() => { + function handleKeyDown(event: KeyboardEvent) { + if (event.key !== "Escape") return; + if (event.defaultPrevented) return; + if (hasPrimaryShortcutModifier(event) || event.altKey) return; + + if (event.shiftKey) { + event.preventDefault(); + markAllChannelsRead(); + return; + } + + if (selectedView === "channel" && activeChannelId) { + event.preventDefault(); + markChannelRead(activeChannelId, activeChannelLastMessageAt ?? null); + } + } + + window.addEventListener("keydown", handleKeyDown); + return () => { + window.removeEventListener("keydown", handleKeyDown); + }; + }, [ + activeChannelId, + activeChannelLastMessageAt, + markAllChannelsRead, + markChannelRead, + selectedView, + ]); +} diff --git a/desktop/src/features/channels/useLiveChannelUpdates.ts b/desktop/src/features/channels/useLiveChannelUpdates.ts index 546410d76..c330ad2e4 100644 --- a/desktop/src/features/channels/useLiveChannelUpdates.ts +++ b/desktop/src/features/channels/useLiveChannelUpdates.ts @@ -150,6 +150,7 @@ export function useLiveChannelUpdates( // reactions / edits / system messages aren't "new content". if ( UNREAD_TRIGGER_KINDS.has(event.kind) && + channelId !== activeChannelId && (normalizedCurrentPubkey.length === 0 || event.pubkey.toLowerCase() !== normalizedCurrentPubkey) ) { diff --git a/desktop/src/features/channels/useUnreadChannels.ts b/desktop/src/features/channels/useUnreadChannels.ts index 568e650ba..e6ab70954 100644 --- a/desktop/src/features/channels/useUnreadChannels.ts +++ b/desktop/src/features/channels/useUnreadChannels.ts @@ -314,8 +314,26 @@ export function useUnreadChannels( readStateVersion, ]); + const unreadChannelIdsRef = React.useRef(unreadChannelIds); + unreadChannelIdsRef.current = unreadChannelIds; + + const markAllChannelsRead = React.useCallback(() => { + for (const channelId of unreadChannelIdsRef.current) { + forcedUnreadRef.current.delete(channelId); + const unixSeconds = + latestByChannelRef.current.get(channelId) ?? + getEffectiveTimestamp(channelId) ?? + null; + if (unixSeconds !== null) { + markContextRead(channelId, unixSeconds); + } + } + bumpLatestVersion(); + }, [getEffectiveTimestamp, markContextRead]); + return { unreadChannelIds, + markAllChannelsRead, markChannelRead, markChannelUnread, // Exposed so other surfaces (e.g. Home) can project per-item read state diff --git a/desktop/src/features/notifications/lib/desktop.ts b/desktop/src/features/notifications/lib/desktop.ts index 384f6da80..e08c189ff 100644 --- a/desktop/src/features/notifications/lib/desktop.ts +++ b/desktop/src/features/notifications/lib/desktop.ts @@ -1,5 +1,5 @@ import { isTauri } from "@tauri-apps/api/core"; -import { getCurrentWindow } from "@tauri-apps/api/window"; +import { UserAttentionType, getCurrentWindow } from "@tauri-apps/api/window"; import { isPermissionGranted, onAction, @@ -204,6 +204,22 @@ export async function setDesktopAppBadgeCount(count: number): Promise { } } +export async function requestDockBounce(): Promise { + if (!isTauri()) { + return; + } + if (document.hasFocus()) { + return; + } + try { + await getCurrentWindow().requestUserAttention( + UserAttentionType.Informational, + ); + } catch { + // Best effort; ignore unsupported platforms. + } +} + export async function revealDesktopAppWindow(): Promise { if (!isTauri()) { if (typeof window !== "undefined") { diff --git a/desktop/src/features/sidebar/ui/AppSidebar.tsx b/desktop/src/features/sidebar/ui/AppSidebar.tsx index a984f0766..f905f21d0 100644 --- a/desktop/src/features/sidebar/ui/AppSidebar.tsx +++ b/desktop/src/features/sidebar/ui/AppSidebar.tsx @@ -2,6 +2,8 @@ import { Activity, Bot, + CheckCheck, + CheckCircle2, ChevronDown, CircleDot, FolderGit2, @@ -130,6 +132,11 @@ type AppSidebarProps = { channelId: string, lastMessageAt: string | null | undefined, ) => void; + onMarkChannelRead: ( + channelId: string, + lastMessageAt: string | null | undefined, + ) => void; + onMarkAllChannelsRead: () => void; onOpenDm: (input: { pubkeys: string[] }) => Promise; onUpdateWorkspace: ( id: string, @@ -162,15 +169,19 @@ function SectionHeaderActions({ browseTestId, className, createAriaLabel, + hasUnread, onBrowse, onCreateClick, + onMarkAllRead, }: { browseAriaLabel: string; browseTestId?: string; className?: string; createAriaLabel: string; + hasUnread?: boolean; onBrowse: () => void; onCreateClick: () => void; + onMarkAllRead?: () => void; }) { return (
+ {hasUnread && onMarkAllRead ? ( + + ) : null}
{!isCollapsed ? ( @@ -293,14 +326,25 @@ function ChannelGroupSection({ - - onMarkChannelUnread(channel.id, channel.lastMessageAt) - } - > - - Mark unread - + {unreadChannelIds.has(channel.id) ? ( + + onMarkChannelRead(channel.id, channel.lastMessageAt) + } + > + + Mark as read + + ) : ( + + onMarkChannelUnread(channel.id, channel.lastMessageAt) + } + > + + Mark unread + + )} ))} @@ -344,6 +388,8 @@ export function AppSidebar({ onOpenSearch, onHideDm, onMarkChannelUnread, + onMarkChannelRead, + onMarkAllChannelsRead, onOpenDm, onUpdateWorkspace, onRemoveWorkspace, @@ -589,12 +635,15 @@ export function AppSidebar({ browseTestId="browse-channels" createAriaLabel="Create a channel" groupClassName="pt-1" + hasUnread={unreadChannelIds.size > 0} isCollapsed={collapsedGroups.channels} isActiveChannel={selectedView === "channel"} items={streamChannels} listTestId="stream-list" onBrowse={onOpenBrowseChannels} onCreateClick={() => setCreateDialogKind("stream")} + onMarkAllRead={onMarkAllChannelsRead} + onMarkChannelRead={onMarkChannelRead} onMarkChannelUnread={onMarkChannelUnread} onSelectChannel={onSelectChannel} onToggleCollapsed={() => toggleCollapsedGroup("channels")} @@ -606,12 +655,15 @@ export function AppSidebar({ browseAriaLabel="Browse forums" browseTestId="browse-forums" createAriaLabel="Create a forum" + hasUnread={unreadChannelIds.size > 0} isCollapsed={collapsedGroups.forums} isActiveChannel={selectedView === "channel"} items={forumChannels} listTestId="forum-list" onBrowse={onOpenBrowseForums} onCreateClick={() => setCreateDialogKind("forum")} + onMarkAllRead={onMarkAllChannelsRead} + onMarkChannelRead={onMarkChannelRead} onMarkChannelUnread={onMarkChannelUnread} onSelectChannel={onSelectChannel} onToggleCollapsed={() => toggleCollapsedGroup("forums")} @@ -643,6 +695,7 @@ export function AppSidebar({ items={directMessages} channelLabels={dmChannelLabels} onHideDm={onHideDm} + onMarkChannelRead={onMarkChannelRead} onMarkChannelUnread={onMarkChannelUnread} onSelectChannel={onSelectChannel} onToggleCollapsed={() => toggleCollapsedGroup("directMessages")} diff --git a/desktop/src/features/sidebar/ui/SidebarSection.tsx b/desktop/src/features/sidebar/ui/SidebarSection.tsx index 16bc3e495..50245c235 100644 --- a/desktop/src/features/sidebar/ui/SidebarSection.tsx +++ b/desktop/src/features/sidebar/ui/SidebarSection.tsx @@ -1,5 +1,13 @@ import type * as React from "react"; -import { ChevronDown, CircleDot, FileText, Hash, Lock, X } from "lucide-react"; +import { + CheckCircle2, + ChevronDown, + CircleDot, + FileText, + Hash, + Lock, + X, +} from "lucide-react"; import { ContextMenu, @@ -207,6 +215,7 @@ export function SidebarSection({ testId, unreadChannelIds, onHideDm, + onMarkChannelRead, onMarkChannelUnread, onSelectChannel, onToggleCollapsed, @@ -224,6 +233,10 @@ export function SidebarSection({ testId: string; unreadChannelIds: Set; onHideDm?: (channelId: string) => void; + onMarkChannelRead?: ( + channelId: string, + lastMessageAt: string | null | undefined, + ) => void; onMarkChannelUnread?: ( channelId: string, lastMessageAt: string | null | undefined, @@ -308,18 +321,36 @@ export function SidebarSection({ ); - return onMarkChannelUnread ? ( + const hasContextAction = + (unreadChannelIds.has(channel.id) && onMarkChannelRead) || + (!unreadChannelIds.has(channel.id) && onMarkChannelUnread); + + return hasContextAction ? ( {menuItem} - - onMarkChannelUnread(channel.id, channel.lastMessageAt) - } - > - - Mark unread - + {unreadChannelIds.has(channel.id) && onMarkChannelRead ? ( + + onMarkChannelRead(channel.id, channel.lastMessageAt) + } + > + + Mark as read + + ) : onMarkChannelUnread ? ( + + onMarkChannelUnread( + channel.id, + channel.lastMessageAt, + ) + } + > + + Mark unread + + ) : null} ) : ( diff --git a/desktop/src/shared/lib/keyboard-shortcuts.ts b/desktop/src/shared/lib/keyboard-shortcuts.ts index 0fb2f2043..087418d91 100644 --- a/desktop/src/shared/lib/keyboard-shortcuts.ts +++ b/desktop/src/shared/lib/keyboard-shortcuts.ts @@ -89,6 +89,22 @@ export const KEYBOARD_SHORTCUTS: KeyboardShortcut[] = [ keysWindows: "Ctrl+S", category: "Navigation", }, + { + id: "mark-current-read", + label: "Mark as read", + description: "Mark the current conversation as read", + keys: "Escape", + keysWindows: "Escape", + category: "Navigation", + }, + { + id: "mark-all-read", + label: "Mark all as read", + description: "Mark all conversations as read", + keys: "⇧Escape", + keysWindows: "Shift+Escape", + category: "Navigation", + }, // Zoom { diff --git a/desktop/tests/e2e/profile.spec.ts b/desktop/tests/e2e/profile.spec.ts index 893a2e8d1..aab4c706a 100644 --- a/desktop/tests/e2e/profile.spec.ts +++ b/desktop/tests/e2e/profile.spec.ts @@ -81,7 +81,6 @@ test("notification settings drive the Home badge and desktop alerts", async ({ await page.goto("/"); await expect(page.getByTestId("sidebar-home-count")).toHaveCount(0); - await expect.poll(getAppBadgeCount).toBe(0); await openSettings(page, "notifications"); await expect(page.getByTestId("settings-notifications")).toBeVisible(); @@ -93,6 +92,11 @@ test("notification settings drive the Home badge and desktop alerts", async ({ await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); + // The dock badge sums unreadChannelIds.size + homeBadgeCount. Seeded test + // channels may start with unreads, so capture the baseline after navigating + // to general (which marks it read) but before injecting the mock mention. + const baseline = await getAppBadgeCount(); + await page.evaluate(() => { const win = window as Window & { __SPROUT_E2E_PUSH_MOCK_FEED_ITEM__?: (item: { @@ -129,7 +133,7 @@ test("notification settings drive the Home badge and desktop alerts", async ({ }); await expect(page.getByTestId("sidebar-home-count")).toHaveText("1"); - await expect.poll(getAppBadgeCount).toBe(1); + await expect.poll(getAppBadgeCount).toBe(baseline + 1); await expect .poll(() => @@ -183,18 +187,18 @@ test("notification settings drive the Home badge and desktop alerts", async ({ await page.getByTestId("settings-close").click(); await expect(page.getByTestId("chat-title")).toHaveText("engineering"); await expect(page.getByTestId("sidebar-home-count")).toHaveCount(0); - await expect.poll(getAppBadgeCount).toBe(0); + await expect.poll(getAppBadgeCount).toBe(baseline); await openSettings(page, "notifications"); await page.getByTestId("notifications-home-badge-toggle").click(); await page.getByTestId("settings-close").click(); await expect(page.getByTestId("sidebar-home-count")).toHaveText("1"); - await expect.poll(getAppBadgeCount).toBe(1); + await expect.poll(getAppBadgeCount).toBe(baseline + 1); await page.getByRole("button", { name: "Home" }).click(); await expect(page.getByTestId("chat-title")).toHaveText("Home"); await expect(page.getByTestId("sidebar-home-count")).toHaveCount(0); - await expect.poll(getAppBadgeCount).toBe(0); + await expect.poll(getAppBadgeCount).toBe(baseline); }); test("desktop notification clicks open the matching forum thread", async ({