Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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()`.**
Expand Down
1 change: 1 addition & 0 deletions desktop/playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
28 changes: 24 additions & 4 deletions desktop/src/app/navigation/useAppNavigation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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,
Expand All @@ -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: {
Expand All @@ -196,15 +215,16 @@ export function useAppNavigation() {
...(options?.agentSession
? { agentSession: options.agentSession }
: {}),
...(options?.thread ? { thread: options.thread } : {}),
...(thread ? { thread } : {}),
...(options?.autoSend ? { autoSend: options.autoSend } : {}),
},
},
{
replace: options?.replace,
resetScroll: options?.messageId ? true : undefined,
},
),
);
},
[commitNavigation],
);

Expand Down
179 changes: 179 additions & 0 deletions desktop/src/features/channels/channelPanelMemory.test.mjs
Original file line number Diff line number Diff line change
@@ -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;
}
}
});
123 changes: 123 additions & 0 deletions desktop/src/features/channels/channelPanelMemory.ts
Original file line number Diff line number Diff line change
@@ -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<string, string | null> | null = null;

function readStoredMemory(): Map<string, string | null> {
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<string, string | null> {
if (!memoryByChannelId) {
memoryByChannelId = readStoredMemory();
}
return memoryByChannelId;
}

function persistMemory(current: Map<string, string | null>): 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.
}
}
Loading