diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index afe8b883bf..bcf0a6a511 100644 --- a/desktop/src/app/AppShell.tsx +++ b/desktop/src/app/AppShell.tsx @@ -684,10 +684,9 @@ export function AppShell() { open: isCompanionWindow ? undefined : settingsOpen, }); useMarkAsReadShortcuts({ - activeChannelId: ownedEffects.markAsReadShortcuts - ? (activeChannel?.id ?? null) - : null, + activeChannelId: activeChannel?.id ?? null, activeChannelLastMessageAt: activeChannel?.lastMessageAt, + enabled: ownedEffects.markAsReadShortcuts, markAllChannelsRead, markChannelRead, selectedView, diff --git a/desktop/src/app/useMarkAsReadShortcuts.test.mjs b/desktop/src/app/useMarkAsReadShortcuts.test.mjs new file mode 100644 index 0000000000..75f3032ae5 --- /dev/null +++ b/desktop/src/app/useMarkAsReadShortcuts.test.mjs @@ -0,0 +1,73 @@ +import assert from "node:assert/strict"; +import { after, afterEach, before, test } from "node:test"; + +import { JSDOM } from "jsdom"; + +const dom = new JSDOM("", { + url: "http://localhost", +}); + +before(() => { + Object.assign(globalThis, { + document: dom.window.document, + HTMLElement: dom.window.HTMLElement, + IS_REACT_ACT_ENVIRONMENT: true, + KeyboardEvent: dom.window.KeyboardEvent, + window: dom.window, + }); +}); + +afterEach(async () => { + const { cleanup } = await import("@testing-library/react"); + cleanup(); +}); + +after(() => dom.window.close()); + +async function renderShortcuts(enabled) { + const { renderHook } = await import("@testing-library/react"); + const { useMarkAsReadShortcuts } = await import( + "./useMarkAsReadShortcuts.ts" + ); + const calls = []; + const view = renderHook(() => + useMarkAsReadShortcuts({ + activeChannelId: "general", + activeChannelLastMessageAt: "2026-08-24T00:00:00.000Z", + enabled, + markAllChannelsRead: () => calls.push("all"), + markChannelRead: () => calls.push("channel"), + selectedView: "channel", + }), + ); + return { calls, view }; +} + +function pressEscape({ shiftKey = false } = {}) { + window.dispatchEvent( + new KeyboardEvent("keydown", { + bubbles: true, + cancelable: true, + key: "Escape", + shiftKey, + }), + ); +} + +test("disabled shortcuts ignore Escape and Shift+Escape", async () => { + const { calls } = await renderShortcuts(false); + + pressEscape(); + pressEscape({ shiftKey: true }); + + assert.deepEqual(calls, []); +}); + +test("enabled shortcuts preserve channel and all-channel actions", async () => { + const { calls } = await renderShortcuts(true); + + pressEscape(); + pressEscape({ shiftKey: true }); + + assert.deepEqual(calls, ["channel", "all"]); +}); diff --git a/desktop/src/app/useMarkAsReadShortcuts.ts b/desktop/src/app/useMarkAsReadShortcuts.ts index d2499d80bb..2daa079707 100644 --- a/desktop/src/app/useMarkAsReadShortcuts.ts +++ b/desktop/src/app/useMarkAsReadShortcuts.ts @@ -9,6 +9,7 @@ export function useMarkAsReadShortcuts({ markAllChannelsRead, markChannelRead, selectedView, + enabled = true, }: { activeChannelId: string | null; activeChannelLastMessageAt: string | null | undefined; @@ -18,8 +19,11 @@ export function useMarkAsReadShortcuts({ lastMessageAt: string | null | undefined, ) => void; selectedView: string; + enabled?: boolean; }) { React.useEffect(() => { + if (!enabled) return; + function handleKeyDown(event: KeyboardEvent) { if (event.key !== "Escape") return; if (event.defaultPrevented) return; @@ -51,6 +55,7 @@ export function useMarkAsReadShortcuts({ }, [ activeChannelId, activeChannelLastMessageAt, + enabled, markAllChannelsRead, markChannelRead, selectedView, diff --git a/desktop/src/features/channels/ui/ChannelPane.helpers.test.mjs b/desktop/src/features/channels/ui/ChannelPane.helpers.test.mjs new file mode 100644 index 0000000000..620bec568f --- /dev/null +++ b/desktop/src/features/channels/ui/ChannelPane.helpers.test.mjs @@ -0,0 +1,26 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { shouldShowAgentSessionUnavailable } from "./ChannelPane.helpers.ts"; + +test("unavailable activity state is exclusive to dedicated windows", () => { + const unresolvedSession = { + openAgentSessionPubkey: "agent-pubkey", + selectedAgent: null, + }; + + assert.equal( + shouldShowAgentSessionUnavailable({ + ...unresolvedSession, + isDedicatedActivityWindow: true, + }), + true, + ); + assert.equal( + shouldShowAgentSessionUnavailable({ + ...unresolvedSession, + isDedicatedActivityWindow: false, + }), + false, + ); +}); diff --git a/desktop/src/features/channels/ui/ChannelPane.helpers.ts b/desktop/src/features/channels/ui/ChannelPane.helpers.ts index cb0600a28a..c46369dbb3 100644 --- a/desktop/src/features/channels/ui/ChannelPane.helpers.ts +++ b/desktop/src/features/channels/ui/ChannelPane.helpers.ts @@ -57,6 +57,22 @@ export function isChannelCreatedSystemMessage(message: TimelineMessage) { } } +export function shouldShowAgentSessionUnavailable({ + isDedicatedActivityWindow, + openAgentSessionPubkey, + selectedAgent, +}: { + isDedicatedActivityWindow: boolean; + openAgentSessionPubkey: string | null; + selectedAgent: unknown; +}): boolean { + return ( + isDedicatedActivityWindow && + openAgentSessionPubkey !== null && + selectedAgent == null + ); +} + export function mentionsKnownAgent( mentionPubkeys: string[], knownAgentPubkeys: ReadonlySet, diff --git a/desktop/src/features/channels/ui/ChannelPane.tsx b/desktop/src/features/channels/ui/ChannelPane.tsx index 131c9df0e1..74ac1b6463 100644 --- a/desktop/src/features/channels/ui/ChannelPane.tsx +++ b/desktop/src/features/channels/ui/ChannelPane.tsx @@ -44,7 +44,10 @@ import { WelcomeComposerGuidanceLayer, } from "@/features/channels/ui/WelcomeComposerBanner"; import { useWelcomeComposerBanner } from "@/features/channels/ui/useWelcomeComposerBanner"; -import { mentionsKnownAgent } from "@/features/channels/ui/ChannelPane.helpers"; +import { + mentionsKnownAgent, + shouldShowAgentSessionUnavailable, +} from "@/features/channels/ui/ChannelPane.helpers"; import { HuddleStartingView, HuddleTranscriptIntro } from "@/features/huddle"; import { useChannelIntro } from "@/features/channels/ui/useChannelIntro"; import type { ChannelPaneProps } from "@/features/channels/ui/ChannelPane.types"; @@ -81,6 +84,7 @@ export const ChannelPane = React.memo(function ChannelPane({ historyExhausted, isFetchingOlder, isHuddleTranscript = false, + isDedicatedActivityWindow = false, followThreadById, isFollowingThread, isFollowingThreadById, @@ -915,7 +919,11 @@ export const ChannelPane = React.memo(function ChannelPane({ ); return wrapAux(panel, "agent-session-thread-panel"); })() - ) : isSinglePanelView && openAgentSessionPubkey ? ( + ) : shouldShowAgentSessionUnavailable({ + isDedicatedActivityWindow, + openAgentSessionPubkey, + selectedAgent, + }) ? (
0 && channelContentWidthPx < AUXILIARY_PANEL_SINGLE_COLUMN_BREAKPOINT_PX; - // A dedicated activity window is the panel itself, not a responsive channel - // view. Keep the channel timeline out of that window at every width. const isSinglePanelView = isDedicatedActivityWindow || (isNarrowPanelViewport && @@ -871,6 +870,7 @@ export function ChannelScreen({ onOpenMembers={handleOpenMembersSidebar} isFetchingOlder={isFetchingOlder} isHuddleTranscript={isHuddleTranscript} + isDedicatedActivityWindow={isDedicatedActivityWindow} entranceMessageId={welcomeEntranceMessageId} onEntranceMessageComplete={handleWelcomeEntranceComplete} welcomeKickoffStage={welcomeKickoffStage} diff --git a/desktop/src/features/channels/ui/useChannelOpenReadState.ts b/desktop/src/features/channels/ui/useChannelOpenReadState.ts index 85a18b1e14..a8336cd584 100644 --- a/desktop/src/features/channels/ui/useChannelOpenReadState.ts +++ b/desktop/src/features/channels/ui/useChannelOpenReadState.ts @@ -39,7 +39,16 @@ export function useChannelOpenReadState( useAppShell(); React.useEffect(() => { - if (!activeChannelId || !enabled || isChannelMember === false) return; + if ( + !shouldAdvanceChannelReadState({ + activeChannelId, + enabled, + isChannelMember, + }) || + !activeChannelId + ) { + return; + } for (const itemId of getTopLevelInboxUnreadOverrideIds( locallyUnreadFeedItems, activeChannelId, diff --git a/desktop/src/features/channels/ui/useHuddleReadMarker.test.mjs b/desktop/src/features/channels/ui/useHuddleReadMarker.test.mjs new file mode 100644 index 0000000000..90d4512e42 --- /dev/null +++ b/desktop/src/features/channels/ui/useHuddleReadMarker.test.mjs @@ -0,0 +1,64 @@ +import assert from "node:assert/strict"; +import { after, afterEach, before, test } from "node:test"; + +import { JSDOM } from "jsdom"; + +const dom = new JSDOM("", { + url: "http://localhost", +}); + +before(() => { + Object.assign(globalThis, { + document: dom.window.document, + HTMLElement: dom.window.HTMLElement, + IS_REACT_ACT_ENVIRONMENT: true, + window: dom.window, + }); +}); + +afterEach(async () => { + const { cleanup } = await import("@testing-library/react"); + cleanup(); +}); + +after(() => dom.window.close()); + +const topLevelMessage = { + content: "message", + created_at: 1_724_457_600, + id: "message-id", + kind: 9, + pubkey: "author", + sig: "signature", + tags: [["h", "general"]], +}; + +async function mountReadMarker(enabled) { + const { renderHook } = await import("@testing-library/react"); + const { useHuddleReadMarker } = await import("./useHuddleReadMarker.ts"); + const calls = []; + const view = renderHook(() => + useHuddleReadMarker({ + activeChannelId: "general", + activeChannelIsMember: true, + enabled, + isHuddleTranscript: false, + markChannelRead: (...args) => calls.push(args), + messages: [topLevelMessage], + resolvedMessages: [topLevelMessage], + }), + ); + return { calls, view }; +} + +test("disabled activity companions do not advance the huddle read marker", async () => { + const { calls } = await mountReadMarker(false); + assert.deepEqual(calls, []); +}); + +test("enabled channel screens retain the top-level read marker", async () => { + const { calls } = await mountReadMarker(true); + assert.deepEqual(calls, [ + ["general", "2024-08-24T00:00:00.000Z", { topLevelOnly: true }], + ]); +}); diff --git a/desktop/src/features/channels/ui/useHuddleReadMarker.ts b/desktop/src/features/channels/ui/useHuddleReadMarker.ts index a9188fa27f..4c17cd0d5a 100644 --- a/desktop/src/features/channels/ui/useHuddleReadMarker.ts +++ b/desktop/src/features/channels/ui/useHuddleReadMarker.ts @@ -13,6 +13,7 @@ type HuddleReadMarkerOptions = { activeChannelId: string | null; activeChannelIsMember: boolean | undefined; isHuddleTranscript: boolean; + enabled?: boolean; markChannelRead: MarkChannelRead; messages: RelayEvent[] | undefined; resolvedMessages: RelayEvent[]; @@ -22,6 +23,7 @@ export function useHuddleReadMarker({ activeChannelId, activeChannelIsMember, isHuddleTranscript, + enabled = true, markChannelRead, messages, resolvedMessages, @@ -65,7 +67,7 @@ export function useHuddleReadMarker({ const lastHuddleReadKeyRef = React.useRef(null); React.useEffect(() => { - if (!activeChannelId || activeChannelIsMember === false) return; + if (!enabled || !activeChannelId || activeChannelIsMember === false) return; const huddleReadKey = hasFlattenedHuddleReplies ? `${activeChannelId}:${huddleReadAt}` : null; @@ -82,6 +84,7 @@ export function useHuddleReadMarker({ }, [ activeChannelId, activeChannelIsMember, + enabled, hasFlattenedHuddleReplies, huddleReadAt, markChannelRead,