feat(desktop): dock bounce, mark-as-read toggle, and bulk mark-all-read (#753)

This commit is contained in:
Will Pfleger
2026-05-26 17:38:29 -07:00
committed by GitHub
parent 4d77a5227f
commit 7df4681fee
12 changed files with 242 additions and 31 deletions
+1 -1
View File
@@ -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
@@ -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",
+25 -6
View File
@@ -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() {
<ChannelNavigationProvider channels={channels}>
<AppShellProvider
value={{
markAllChannelsRead,
markChannelRead,
markChannelUnread,
openChannelManagement: () => {
@@ -693,6 +710,8 @@ export function AppShell() {
void applyAgents(templateId, createdForum.id);
}}
onHideDm={handleHideDm}
onMarkAllChannelsRead={markAllChannelsRead}
onMarkChannelRead={markChannelRead}
onMarkChannelUnread={markChannelUnread}
onOpenBrowseChannels={handleOpenBrowseChannels}
onOpenBrowseForums={handleOpenBrowseForums}
+2
View File
@@ -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<AppShellContextValue>({
markAllChannelsRead: () => {},
markChannelRead: () => {},
markChannelUnread: () => {},
openChannelManagement: () => {},
+50
View File
@@ -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,
]);
}
@@ -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)
) {
@@ -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
@@ -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<void> {
}
}
export async function requestDockBounce(): Promise<void> {
if (!isTauri()) {
return;
}
if (document.hasFocus()) {
return;
}
try {
await getCurrentWindow().requestUserAttention(
UserAttentionType.Informational,
);
} catch {
// Best effort; ignore unsupported platforms.
}
}
export async function revealDesktopAppWindow(): Promise<void> {
if (!isTauri()) {
if (typeof window !== "undefined") {
+61 -8
View File
@@ -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<void>;
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 (
<div
@@ -179,6 +190,17 @@ function SectionHeaderActions({
className,
)}
>
{hasUnread && onMarkAllRead ? (
<button
aria-label="Mark all as read"
className={SECTION_ICON_BUTTON_CLASS}
onClick={onMarkAllRead}
title="Mark all as read"
type="button"
>
<CheckCheck className="h-3.5 w-3.5" />
</button>
) : null}
<button
aria-label={browseAriaLabel}
className={SECTION_ICON_BUTTON_CLASS}
@@ -209,12 +231,15 @@ function ChannelGroupSection({
browseTestId,
createAriaLabel,
groupClassName,
hasUnread,
isCollapsed,
isActiveChannel,
items,
listTestId,
onBrowse,
onCreateClick,
onMarkAllRead,
onMarkChannelRead,
onMarkChannelUnread,
onSelectChannel,
onToggleCollapsed,
@@ -232,6 +257,10 @@ function ChannelGroupSection({
listTestId: string;
onBrowse: () => void;
onCreateClick: () => void;
onMarkChannelRead: (
channelId: string,
lastMessageAt: string | null | undefined,
) => void;
onMarkChannelUnread: (
channelId: string,
lastMessageAt: string | null | undefined,
@@ -241,6 +270,8 @@ function ChannelGroupSection({
selectedChannelId: string | null;
title: string;
unreadChannelIds: Set<string>;
hasUnread?: boolean;
onMarkAllRead?: () => void;
}) {
const contentId = `sidebar-${listTestId}`;
@@ -270,8 +301,10 @@ function ChannelGroupSection({
browseTestId={browseTestId}
className={SECTION_ACTION_VISIBILITY_CLASS}
createAriaLabel={createAriaLabel}
hasUnread={hasUnread}
onBrowse={onBrowse}
onCreateClick={onCreateClick}
onMarkAllRead={onMarkAllRead}
/>
</div>
{!isCollapsed ? (
@@ -293,14 +326,25 @@ function ChannelGroupSection({
</SidebarMenuItem>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem
onClick={() =>
onMarkChannelUnread(channel.id, channel.lastMessageAt)
}
>
<CircleDot className="h-4 w-4" />
Mark unread
</ContextMenuItem>
{unreadChannelIds.has(channel.id) ? (
<ContextMenuItem
onClick={() =>
onMarkChannelRead(channel.id, channel.lastMessageAt)
}
>
<CheckCircle2 className="h-4 w-4" />
Mark as read
</ContextMenuItem>
) : (
<ContextMenuItem
onClick={() =>
onMarkChannelUnread(channel.id, channel.lastMessageAt)
}
>
<CircleDot className="h-4 w-4" />
Mark unread
</ContextMenuItem>
)}
</ContextMenuContent>
</ContextMenu>
))}
@@ -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")}
@@ -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<string>;
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({
</SidebarMenuItem>
);
return onMarkChannelUnread ? (
const hasContextAction =
(unreadChannelIds.has(channel.id) && onMarkChannelRead) ||
(!unreadChannelIds.has(channel.id) && onMarkChannelUnread);
return hasContextAction ? (
<ContextMenu key={channel.id}>
<ContextMenuTrigger asChild>{menuItem}</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem
onClick={() =>
onMarkChannelUnread(channel.id, channel.lastMessageAt)
}
>
<CircleDot className="h-4 w-4" />
Mark unread
</ContextMenuItem>
{unreadChannelIds.has(channel.id) && onMarkChannelRead ? (
<ContextMenuItem
onClick={() =>
onMarkChannelRead(channel.id, channel.lastMessageAt)
}
>
<CheckCircle2 className="h-4 w-4" />
Mark as read
</ContextMenuItem>
) : onMarkChannelUnread ? (
<ContextMenuItem
onClick={() =>
onMarkChannelUnread(
channel.id,
channel.lastMessageAt,
)
}
>
<CircleDot className="h-4 w-4" />
Mark unread
</ContextMenuItem>
) : null}
</ContextMenuContent>
</ContextMenu>
) : (
@@ -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
{
+9 -5
View File
@@ -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 ({