diff --git a/AGENTS.md b/AGENTS.md index 2d3939bbb36..0346116eb3d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -509,6 +509,7 @@ reconnects preserve pending avatar verification work): - `clearSearchHitEventCache()` — search result event cache - `clearMarkdownNodeCache()` — markdown parse-node cache - `resetLinkPreviewTitleCache()` — link preview title cache (Buzz entity titles come from relay events) +- `resetChannelPanelMemory()` — per-channel thread-panel memory (sessionStorage-backed) **If you add a new module-level cache, Map, or class instance that holds community-scoped data, you must add its reset to `resetCommunityState()`.** diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index a9ec17a249f..98cdb355ebc 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -86,6 +86,7 @@ export default defineConfig({ "**/thread-reply-anchor-roleplay.spec.ts", "**/threadpane-ultrawide.spec.ts", "**/thread-focus-mode.spec.ts", + "**/thread-panel-persistence.spec.ts", "**/animated-avatar.spec.ts", "**/reminders.spec.ts", "**/reminder-click-repro.spec.ts", diff --git a/desktop/src/app/navigation/useAppNavigation.ts b/desktop/src/app/navigation/useAppNavigation.ts index 4c7382a306b..3dd91b20216 100644 --- a/desktop/src/app/navigation/useAppNavigation.ts +++ b/desktop/src/app/navigation/useAppNavigation.ts @@ -8,6 +8,7 @@ import { import { cacheSearchHitEvent } from "@/app/navigation/searchHitEventCache"; import { resolveSearchHitDestination } from "@/app/navigation/resolveSearchHitDestination"; +import { recallChannelThread } from "@/features/channels/channelPanelMemory"; import type { SearchHit } from "@/shared/api/types"; type NavigationBehavior = { @@ -161,6 +162,14 @@ export function useAppNavigation() { [commitNavigation], ); + /** + * Navigate to a channel. A navigation with no explicit target restores the + * channel's remembered thread panel (`channelPanelMemory.ts`) by seeding + * `thread` into the URL it builds — the entry is right the first time, so + * a switch stays one history entry, and back/forward (which bypasses this + * builder) keeps carrying each entry's own params. Explicit targets + * (`thread`, `messageId`, `agentSession`, `autoSend`) win over memory. + */ const goChannel = React.useCallback( ( channelId: string, @@ -179,8 +188,18 @@ export function useAppNavigation() { thread?: string; threadRootId?: string | null; }, - ) => - commitNavigation( + ) => { + const hasExplicitTarget = Boolean( + options?.thread || + options?.messageId || + options?.agentSession || + options?.autoSend, + ); + const thread = hasExplicitTarget + ? options?.thread + : (recallChannelThread(channelId) ?? undefined); + + return commitNavigation( { to: "/channels/$channelId", params: { @@ -196,7 +215,7 @@ export function useAppNavigation() { ...(options?.agentSession ? { agentSession: options.agentSession } : {}), - ...(options?.thread ? { thread: options.thread } : {}), + ...(thread ? { thread } : {}), ...(options?.autoSend ? { autoSend: options.autoSend } : {}), }, }, @@ -204,7 +223,8 @@ export function useAppNavigation() { replace: options?.replace, resetScroll: options?.messageId ? true : undefined, }, - ), + ); + }, [commitNavigation], ); diff --git a/desktop/src/features/channels/channelPanelMemory.test.mjs b/desktop/src/features/channels/channelPanelMemory.test.mjs new file mode 100644 index 00000000000..55c73bf42a1 --- /dev/null +++ b/desktop/src/features/channels/channelPanelMemory.test.mjs @@ -0,0 +1,179 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + recallChannelThread, + rememberChannelThread, + resetChannelPanelMemory, +} from "./channelPanelMemory.ts"; + +const SESSION_KEY = "buzz.channels.thread-panel-memory"; + +/** Minimal sessionStorage stub installed on globalThis.window. */ +function installSessionStorage(initial = {}) { + const store = new Map(Object.entries(initial)); + const stub = { + getItem: (key) => (store.has(key) ? store.get(key) : null), + setItem: (key, value) => { + store.set(key, String(value)); + }, + removeItem: (key) => { + store.delete(key); + }, + }; + + const previousWindow = globalThis.window; + if (previousWindow === undefined) { + globalThis.window = {}; + } + const previousSessionStorage = globalThis.window.sessionStorage; + globalThis.window.sessionStorage = stub; + + return { + store, + restore: () => { + if (previousWindow === undefined) { + delete globalThis.window; + } else { + globalThis.window.sessionStorage = previousSessionStorage; + } + }, + }; +} + +function withSessionStorage(initial, fn) { + const { store, restore } = installSessionStorage(initial); + try { + fn(store); + } finally { + restore(); + } +} + +// The module hydrates from sessionStorage on first access, so the hydration +// test must run before anything else touches the memory in this process. +test("hydrates existing session state on first access", () => { + withSessionStorage( + { + [SESSION_KEY]: JSON.stringify({ + "channel-open": "thread-1", + "channel-closed": null, + }), + }, + () => { + assert.equal(recallChannelThread("channel-open"), "thread-1"); + assert.equal(recallChannelThread("channel-closed"), null); + assert.equal(recallChannelThread("channel-unknown"), undefined); + }, + ); +}); + +test("remember/recall round-trip is tri-state", () => { + withSessionStorage({}, () => { + resetChannelPanelMemory(); + + assert.equal(recallChannelThread("channel-a"), undefined); + + rememberChannelThread("channel-a", "thread-a"); + assert.equal(recallChannelThread("channel-a"), "thread-a"); + + // Explicitly closed is remembered as null, distinct from "no memory". + rememberChannelThread("channel-a", null); + assert.equal(recallChannelThread("channel-a"), null); + assert.equal(recallChannelThread("channel-b"), undefined); + }); +}); + +test("channels remember independently", () => { + withSessionStorage({}, () => { + resetChannelPanelMemory(); + + rememberChannelThread("channel-a", "thread-a"); + rememberChannelThread("channel-b", "thread-b"); + rememberChannelThread("channel-c", null); + + assert.equal(recallChannelThread("channel-a"), "thread-a"); + assert.equal(recallChannelThread("channel-b"), "thread-b"); + assert.equal(recallChannelThread("channel-c"), null); + }); +}); + +test("writes through to sessionStorage", () => { + withSessionStorage({}, (store) => { + resetChannelPanelMemory(); + + rememberChannelThread("channel-a", "thread-a"); + assert.deepEqual(JSON.parse(store.get(SESSION_KEY)), { + "channel-a": "thread-a", + }); + + rememberChannelThread("channel-a", null); + assert.deepEqual(JSON.parse(store.get(SESSION_KEY)), { + "channel-a": null, + }); + }); +}); + +test("redundant writes are skipped", () => { + withSessionStorage({}, (store) => { + resetChannelPanelMemory(); + + rememberChannelThread("channel-a", "thread-a"); + store.delete(SESSION_KEY); + + // Same value again: no new storage write. + rememberChannelThread("channel-a", "thread-a"); + assert.equal(store.has(SESSION_KEY), false); + + // Changed value: writes. + rememberChannelThread("channel-a", "thread-b"); + assert.equal(store.has(SESSION_KEY), true); + }); +}); + +test("reset forgets memory and clears storage", () => { + withSessionStorage({}, (store) => { + resetChannelPanelMemory(); + + rememberChannelThread("channel-a", "thread-a"); + resetChannelPanelMemory(); + + assert.equal(recallChannelThread("channel-a"), undefined); + assert.equal(store.has(SESSION_KEY), false); + }); +}); + +test("storage failures leave the in-memory map working", () => { + const throwingStub = { + getItem: () => { + throw new Error("storage unavailable"); + }, + setItem: () => { + throw new Error("storage unavailable"); + }, + removeItem: () => { + throw new Error("storage unavailable"); + }, + }; + + const previousWindow = globalThis.window; + if (previousWindow === undefined) { + globalThis.window = {}; + } + const previousSessionStorage = globalThis.window?.sessionStorage; + globalThis.window.sessionStorage = throwingStub; + + try { + resetChannelPanelMemory(); + rememberChannelThread("channel-a", "thread-a"); + assert.equal(recallChannelThread("channel-a"), "thread-a"); + resetChannelPanelMemory(); + assert.equal(recallChannelThread("channel-a"), undefined); + } finally { + if (previousWindow === undefined) { + delete globalThis.window; + } else { + globalThis.window.sessionStorage = previousSessionStorage; + } + } +}); diff --git a/desktop/src/features/channels/channelPanelMemory.ts b/desktop/src/features/channels/channelPanelMemory.ts new file mode 100644 index 00000000000..4517e42b158 --- /dev/null +++ b/desktop/src/features/channels/channelPanelMemory.ts @@ -0,0 +1,123 @@ +/** + * Per-channel memory of the thread panel, so returning to a channel restores + * the panel the way it was left: open on the same thread, or closed. + * + * ChannelScreen records the current `?thread` search value continuously; + * `goChannel` recalls it when building the URL for a navigation that carries + * no explicit target, so the restored URL is right the first time — one + * history entry per switch, and explicit targets (deep links, search hits, + * mention clicks) win by construction. + * + * The memory is tri-state per channel: a thread head id ("open on this + * thread"), `null` ("the user left it closed" — a closed panel must stay + * closed on return), or no entry ("never visited this session"). + * + * Session-scoped by design: backed by sessionStorage (the thread-panel width + * precedent, `useThreadPanelWidth`) so it survives a reload but not an app + * restart. Channel ids are community-local, so this module-level singleton is + * community-scoped state and its reset is wired into `resetCommunityState()` + * (`useCommunityInit.ts`). + */ + +const CHANNEL_PANEL_MEMORY_SESSION_KEY = "buzz.channels.thread-panel-memory"; + +let memoryByChannelId: Map | null = null; + +function readStoredMemory(): Map { + if (typeof window === "undefined") { + return new Map(); + } + + try { + const raw = window.sessionStorage.getItem(CHANNEL_PANEL_MEMORY_SESSION_KEY); + if (!raw) { + return new Map(); + } + + const parsed: unknown = JSON.parse(raw); + if ( + parsed === null || + typeof parsed !== "object" || + Array.isArray(parsed) + ) { + return new Map(); + } + + const entries = Object.entries(parsed).filter( + (entry): entry is [string, string | null] => + typeof entry[1] === "string" || entry[1] === null, + ); + return new Map(entries); + } catch { + return new Map(); + } +} + +function memory(): Map { + if (!memoryByChannelId) { + memoryByChannelId = readStoredMemory(); + } + return memoryByChannelId; +} + +function persistMemory(current: Map): void { + if (typeof window === "undefined") { + return; + } + + try { + window.sessionStorage.setItem( + CHANNEL_PANEL_MEMORY_SESSION_KEY, + JSON.stringify(Object.fromEntries(current)), + ); + } catch { + // Persistence is best-effort; the in-memory map still applies. + } +} + +/** + * Record the thread panel state currently showing in a channel. + * `null` means the panel is closed. + */ +export function rememberChannelThread( + channelId: string, + threadHeadId: string | null, +): void { + const current = memory(); + if (current.has(channelId) && current.get(channelId) === threadHeadId) { + return; + } + + current.set(channelId, threadHeadId); + persistMemory(current); +} + +/** + * The thread panel state to restore when re-entering a channel: a thread head + * id to reopen, `null` if the user left the panel closed, or `undefined` if + * the channel has no memory this session. + */ +export function recallChannelThread( + channelId: string, +): string | null | undefined { + return memory().get(channelId); +} + +/** + * Forget every channel's panel state. Wired into `resetCommunityState()` — + * channel ids are community-local, so remembered panel state must not leak + * across a community switch. + */ +export function resetChannelPanelMemory(): void { + memoryByChannelId = new Map(); + + if (typeof window === "undefined") { + return; + } + + try { + window.sessionStorage.removeItem(CHANNEL_PANEL_MEMORY_SESSION_KEY); + } catch { + // Best-effort; the in-memory map is already cleared. + } +} diff --git a/desktop/src/features/channels/ui/ChannelScreen.tsx b/desktop/src/features/channels/ui/ChannelScreen.tsx index 8150f6df7de..8b3ebcaedf9 100644 --- a/desktop/src/features/channels/ui/ChannelScreen.tsx +++ b/desktop/src/features/channels/ui/ChannelScreen.tsx @@ -73,6 +73,7 @@ import { useElementWidth } from "@/shared/hooks/use-mobile"; import { useThreadPanelWidth } from "@/shared/hooks/useThreadPanelWidth"; import { AUXILIARY_PANEL_SINGLE_COLUMN_BREAKPOINT_PX } from "@/shared/layout/AuxiliaryPanel"; import { normalizePubkey } from "@/shared/lib/pubkey"; +import { useChannelThreadMemory } from "@/features/channels/useChannelThreadMemory"; import { useChannelActivityTyping } from "./useChannelActivityTyping"; import { useChannelAgentSessions } from "./useChannelAgentSessions"; import { useMessageProfiles } from "./useMessageProfiles"; @@ -188,6 +189,12 @@ export function ChannelScreen({ : current; }); }, [activeChannelId, openThreadHeadId]); + useChannelThreadMemory({ + activeChannelId, + isHuddleTranscript, + openThreadHeadId, + selectedForumPostId, + }); const messagesQuery = useChannelMessagesQuery(activeChannel); const windowQuery = useChannelWindowQuery(activeChannel); const threadRepliesQuery = useThreadReplies( diff --git a/desktop/src/features/channels/ui/useChannelPanelHistoryState.ts b/desktop/src/features/channels/ui/useChannelPanelHistoryState.ts index 24ccf1cce93..818e8445af2 100644 --- a/desktop/src/features/channels/ui/useChannelPanelHistoryState.ts +++ b/desktop/src/features/channels/ui/useChannelPanelHistoryState.ts @@ -21,6 +21,12 @@ export type { ChannelSearchKey } from "./channelSearchKeys"; * via useHistorySearchState: back/forward restores the panel a given entry * was showing, and reloads restore the panel from the URL. * + * Forward navigation extends the same philosophy: `goChannel` seeds the + * `thread` param of a fresh channel entry from the per-channel memory + * (`channelPanelMemory.ts`, recorded by `useChannelThreadMemory`), so + * returning to a channel restores its thread panel. Each history entry still + * carries its own truth — memory only seeds brand-new entries. + * * Params: `thread` (open thread head id), `profile` (profile panel pubkey), * `profileView` (profile panel focused view), `profileTab` (profile summary * tab), `agentSession` (agent session panel pubkey), `agentSessionChannel` diff --git a/desktop/src/features/channels/useChannelThreadMemory.ts b/desktop/src/features/channels/useChannelThreadMemory.ts new file mode 100644 index 00000000000..442cff90509 --- /dev/null +++ b/desktop/src/features/channels/useChannelThreadMemory.ts @@ -0,0 +1,43 @@ +import * as React from "react"; + +import { rememberChannelThread } from "@/features/channels/channelPanelMemory"; + +/** + * Continuously mirrors the channel's `?thread` search value into the + * per-channel panel memory (`channelPanelMemory.ts`), so `goChannel` can + * restore the thread panel when the user returns to the channel. + * + * Recording continuously (rather than on exit) keeps every leave-path correct + * for free: closing the panel records `null`, leaving via Home/Settings needs + * no hook, and a stale thread snap-closed by `useThreadTargetSync` records + * `null` so it is forgotten rather than retried. + * + * Records the raw URL value, not `effectiveOpenThreadHeadId` — the effective + * id is huddle-suppressed/optimistic and does not represent what a returning + * visit should restore. Huddle transcripts force-close threads + * (`useHuddleThreadIsolation`) and the forum-post view does not use `thread`, + * so both are skipped. + */ +export function useChannelThreadMemory({ + activeChannelId, + isHuddleTranscript, + openThreadHeadId, + selectedForumPostId, +}: { + activeChannelId: string | null; + isHuddleTranscript: boolean; + openThreadHeadId: string | null; + selectedForumPostId: string | null; +}) { + React.useEffect(() => { + if (!activeChannelId || isHuddleTranscript || selectedForumPostId) { + return; + } + rememberChannelThread(activeChannelId, openThreadHeadId); + }, [ + activeChannelId, + isHuddleTranscript, + openThreadHeadId, + selectedForumPostId, + ]); +} diff --git a/desktop/src/features/communities/useCommunityInit.ts b/desktop/src/features/communities/useCommunityInit.ts index 0c27ab0541f..9dce3265aba 100644 --- a/desktop/src/features/communities/useCommunityInit.ts +++ b/desktop/src/features/communities/useCommunityInit.ts @@ -28,6 +28,7 @@ import { } from "@/features/agents/activeAgentTurnsStore"; import { resetAgentWorkingSignal } from "@/features/agents/agentWorkingSignal"; import { resetAgentObserverStore } from "@/features/agents/observerRelayStore"; +import { resetChannelPanelMemory } from "@/features/channels/channelPanelMemory"; import { resetAvatarPresentations } from "@/features/profile/avatarPresentationStore"; import { resetAvatarProfileSync } from "@/features/profile/avatarProfileSync"; import { resetSidebarRelayConnectionCardState } from "@/features/sidebar/ui/useSidebarRelayConnectionCard"; @@ -73,6 +74,7 @@ function resetCommunityState({ resetBackgroundMediaUploads(); clearSearchHitEventCache(); clearMarkdownNodeCache(); + resetChannelPanelMemory(); } type CommunityInitResult = diff --git a/desktop/tests/e2e/thread-panel-persistence.spec.ts b/desktop/tests/e2e/thread-panel-persistence.spec.ts new file mode 100644 index 00000000000..fa64b38ff4e --- /dev/null +++ b/desktop/tests/e2e/thread-panel-persistence.spec.ts @@ -0,0 +1,75 @@ +import { expect, test } from "@playwright/test"; + +import { installMockBridge } from "../helpers/bridge"; + +test.beforeEach(async ({ page }) => { + await installMockBridge(page); +}); + +async function seedThread(page: import("@playwright/test").Page) { + await expect + .poll(() => + page.evaluate( + () => typeof window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__ === "function", + ), + ) + .toBe(true); + return page.evaluate(() => { + const root = window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({ + channelName: "general", + content: "Thread persistence root", + createdAt: 1_700_900_000, + }); + if (!root) throw new Error("Failed to seed thread root"); + window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({ + channelName: "general", + content: "Thread persistence reply", + parentEventId: root.id, + createdAt: 1_700_900_001, + }); + return root.id; + }); +} + +/** + * Per-channel thread-panel memory: leaving a channel and returning restores + * the panel the way it was left — open on the same thread, or closed + * (`channelPanelMemory.ts`, seeded into the URL by `goChannel`). + */ +test("thread panel is restored per channel across sidebar switches", async ({ + page, +}) => { + await page.goto("/"); + const rootId = await seedThread(page); + + // Open the seeded thread in #general. + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("chat-title")).toHaveText("general"); + const summary = page.locator( + `[data-testid="message-thread-summary"][data-thread-head-id="${rootId}"]`, + ); + await expect(summary).toBeVisible(); + await summary.click(); + await expect(page.getByTestId("message-thread-panel")).toBeVisible(); + + // Switching to a channel with no memory leaves its panel closed. + await page.getByTestId("channel-random").click(); + await expect(page.getByTestId("chat-title")).toHaveText("random"); + await expect(page.getByTestId("message-thread-panel")).not.toBeVisible(); + + // Returning restores the same thread from memory. + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("chat-title")).toHaveText("general"); + await expect(page.getByTestId("message-thread-panel")).toBeVisible(); + await expect(page).toHaveURL(new RegExp(`thread=${rootId}`)); + + // Explicitly closing the panel is remembered: it stays closed on return. + await page.getByRole("button", { name: "Close panel" }).click(); + await expect(page.getByTestId("message-thread-panel")).not.toBeVisible(); + await page.getByTestId("channel-random").click(); + await expect(page.getByTestId("chat-title")).toHaveText("random"); + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("chat-title")).toHaveText("general"); + await expect(page.getByTestId("message-thread-panel")).not.toBeVisible(); + await expect(page).not.toHaveURL(/thread=/); +});