feat(desktop): add back history menu

Co-authored-by: Jitter <d14dfe033ef0f809866f9f984de04821b0d900d7652fd85a54776ee40ca3a68f@buzz.block.builderlab.xyz>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
This commit is contained in:
Matt Toohey
2026-08-26 15:32:49 +10:00
parent b49675894b
commit 428d98c6d6
8 changed files with 449 additions and 37 deletions

View File

@@ -13,6 +13,7 @@ import {
TerminalContextOverrideProvider,
} from "@/app/TerminalContextOverrideContext";
import { useAppNavigation } from "@/app/navigation/useAppNavigation";
import { describeHistoryLocation } from "@/app/navigation/navigationHistory";
import { useBackForwardControls } from "@/app/navigation/useBackForwardControls";
import { useCommunityNavigationTransitions } from "@/app/useCommunityNavigationTransitions";
import { useLiveHomeFeedActions } from "@/app/useLiveHomeFeedActions";
@@ -141,6 +142,8 @@ export function AppShell() {
const mainInsetRef = React.useRef<HTMLElement>(null);
const location = useLocation();
const queryClient = useQueryClient();
const channelsQuery = useChannelsQuery();
const channels = channelsQuery.data ?? [];
useManagedAgentRuntimeReconciliation(communitiesHook.communities); // sync storage snapshot
const {
goAgents,
@@ -154,8 +157,8 @@ export function AppShell() {
closeSettings,
openSearchHit,
} = useAppNavigation();
const { canGoBack, canGoForward, goBack, goForward } =
useBackForwardControls();
const { backHistory, canGoBack, canGoForward, goBack, goBackTo, goForward } =
useBackForwardControls(describeHistoryLocation(location, channels));
const { selectedChannelId, selectedView } = React.useMemo(
() => deriveShellRoute(location.pathname),
[location.pathname],
@@ -233,8 +236,6 @@ export function AppShell() {
const { feedProfilesQuery, homeFeedQuery, notificationSettings } =
useHomeFeedNotifications(identityQuery.data?.pubkey);
const feedItemState = useFeedItemState(identityQuery.data?.pubkey);
const channelsQuery = useChannelsQuery();
const channels = channelsQuery.data ?? [];
useReminderNotifications(
identityQuery.data?.pubkey,
notificationSettings.settings,
@@ -774,10 +775,12 @@ export function AppShell() {
<AppWorkflowEditorOverlayProvider>
{!settingsOpen && !isHuddleRoom ? (
<AppTopChrome
backHistory={backHistory}
canGoBack={canGoBack}
canGoForward={canGoForward}
hasCommunityRail={hasCommunityRail}
onGoBack={goBack}
onGoBackTo={goBackTo}
onGoForward={goForward}
/>
) : null}

View File

@@ -1,18 +1,27 @@
import * as React from "react";
import { ChevronLeft, ChevronRight } from "lucide-react";
import type { BackHistoryEntry } from "@/app/navigation/navigationHistory";
import { isMacPlatform } from "@/shared/lib/platform";
import { useIsFullscreen } from "@/shared/lib/useIsFullscreen";
import { Button } from "@/shared/ui/button";
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuTrigger,
} from "@/shared/ui/context-menu";
import { DrawerPanelIcon } from "@/shared/ui/DrawerPanelIcon";
import { cn } from "@/shared/lib/cn";
import { topChromeBackdrop } from "@/shared/layout/chromeLayout";
import { useOptionalSidebar } from "@/shared/ui/sidebar";
type AppTopChromeProps = {
backHistory: BackHistoryEntry[];
canGoBack: boolean;
canGoForward: boolean;
onGoBack: () => void;
onGoBackTo: (index: number) => void;
onGoForward: () => void;
hasCommunityRail?: boolean;
};
@@ -25,6 +34,7 @@ const TOP_CHROME_ICON_BUTTON_CLASS =
"h-[28px] w-[28px] rounded-[4px] text-sidebar-foreground/65 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground";
const HISTORY_ICON_BUTTON_CLASS =
"h-[28px] w-[24px] rounded-[4px] text-sidebar-foreground/65 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground [&_svg]:size-[16px]";
const BACK_HISTORY_LONG_PRESS_MS = 500;
function preventTopChromeWheel(event: WheelEvent) {
event.preventDefault();
@@ -52,10 +62,117 @@ function TopChromeSidebarTrigger() {
);
}
function BackHistoryButton({
backHistory,
canGoBack,
onGoBack,
onGoBackTo,
}: Pick<
AppTopChromeProps,
"backHistory" | "canGoBack" | "onGoBack" | "onGoBackTo"
>) {
const buttonRef = React.useRef<HTMLButtonElement>(null);
const longPressTimerRef = React.useRef<number | null>(null);
const longPressTriggeredRef = React.useRef(false);
const cancelLongPress = React.useCallback(() => {
if (longPressTimerRef.current !== null) {
window.clearTimeout(longPressTimerRef.current);
longPressTimerRef.current = null;
}
}, []);
React.useEffect(() => cancelLongPress, [cancelLongPress]);
const handlePointerDown = React.useCallback(
(event: React.PointerEvent<HTMLButtonElement>) => {
if (event.button !== 0 || backHistory.length === 0) {
return;
}
cancelLongPress();
longPressTriggeredRef.current = false;
const { clientX, clientY } = event;
longPressTimerRef.current = window.setTimeout(() => {
longPressTimerRef.current = null;
longPressTriggeredRef.current = true;
buttonRef.current?.dispatchEvent(
new MouseEvent("contextmenu", {
bubbles: true,
button: 2,
cancelable: true,
clientX,
clientY,
view: window,
}),
);
}, BACK_HISTORY_LONG_PRESS_MS);
},
[backHistory.length, cancelLongPress],
);
return (
<ContextMenu
onOpenChange={(open) => {
if (!open) {
longPressTriggeredRef.current = false;
}
}}
>
<ContextMenuTrigger asChild>
<Button
ref={buttonRef}
aria-label="Go back"
className={HISTORY_ICON_BUTTON_CLASS}
data-history-count={backHistory.length}
data-testid="global-back"
disabled={!canGoBack}
onClick={(event) => {
if (longPressTriggeredRef.current) {
longPressTriggeredRef.current = false;
event.preventDefault();
return;
}
onGoBack();
}}
onPointerCancel={cancelLongPress}
onPointerDown={handlePointerDown}
onPointerLeave={cancelLongPress}
onPointerUp={cancelLongPress}
size="icon"
variant="ghost"
>
<ChevronLeft />
</Button>
</ContextMenuTrigger>
{backHistory.length > 0 ? (
<ContextMenuContent
className="w-64"
data-testid="global-back-history-menu"
>
{backHistory.map((entry) => (
<ContextMenuItem
aria-label={`Go back to ${entry.label}`}
data-testid="global-back-history-item"
key={entry.key}
onSelect={() => onGoBackTo(entry.index)}
>
<span className="min-w-0 truncate">{entry.label}</span>
</ContextMenuItem>
))}
</ContextMenuContent>
) : null}
</ContextMenu>
);
}
export function AppTopChrome({
backHistory,
canGoBack,
canGoForward,
onGoBack,
onGoBackTo,
onGoForward,
hasCommunityRail = false,
}: AppTopChromeProps) {
@@ -132,17 +249,12 @@ export function AppTopChrome({
>
<div className={cn("flex items-center gap-0.5", navRowAlignmentClass)}>
<TopChromeSidebarTrigger />
<Button
aria-label="Go back"
className={HISTORY_ICON_BUTTON_CLASS}
data-testid="global-back"
disabled={!canGoBack}
onClick={onGoBack}
size="icon"
variant="ghost"
>
<ChevronLeft />
</Button>
<BackHistoryButton
backHistory={backHistory}
canGoBack={canGoBack}
onGoBack={onGoBack}
onGoBackTo={onGoBackTo}
/>
<Button
aria-label="Go forward"
className={HISTORY_ICON_BUTTON_CLASS}

View File

@@ -8,8 +8,12 @@ const target = {
threadRootId: "thread-a",
};
const { allowNavigation, registerNavigationGuard, traverseHistory } =
await import("./navigationGuard.ts");
const {
allowNavigation,
registerNavigationGuard,
traverseHistory,
traverseHistoryBy,
} = await import("./navigationGuard.ts");
test("all navigation consults the registered boundary guard", () => {
let received;
@@ -67,6 +71,30 @@ test("guarded history traversal invokes the selected direction when allowed", ()
assert.equal(forwardCalls, 1);
});
test("guarded multi-entry traversal checks direction before jumping", () => {
let received;
let receivedDelta;
const unregister = registerNavigationGuard((nextTarget) => {
received = nextTarget;
return true;
});
assert.equal(
traverseHistoryBy(
{
go: (delta) => {
receivedDelta = delta;
},
},
-3,
),
true,
);
assert.deepEqual(received, { kind: "history", direction: "back" });
assert.equal(receivedDelta, -3);
unregister();
});
test("unregistering the newer guard restores the prior live guard", () => {
const unregisterFirst = registerNavigationGuard(() => false);
const unregisterSecond = registerNavigationGuard(() => true);

View File

@@ -44,6 +44,23 @@ export function traverseHistory(
return true;
}
export function traverseHistoryBy(
history: Pick<History, "go">,
delta: number,
): boolean {
if (delta === 0) {
return false;
}
const direction = delta < 0 ? "back" : "forward";
if (!allowNavigation({ kind: "history", direction })) {
return false;
}
history.go(delta);
return true;
}
export function registerNavigationGuard(guard: NavigationGuard): () => void {
const registration = { guard };
activeGuards.push(registration);

View File

@@ -0,0 +1,80 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
describeHistoryLocation,
getBackHistoryEntries,
} from "./navigationHistory.ts";
function entry(index) {
return { index, key: `key-${index}`, label: `Entry ${index}` };
}
test("back history returns the nearest ten entries in reverse order", () => {
const entriesByIndex = new Map(
Array.from({ length: 13 }, (_, index) => [index, entry(index)]),
);
assert.deepEqual(
getBackHistoryEntries(entriesByIndex, 13).map(({ index }) => index),
[12, 11, 10, 9, 8, 7, 6, 5, 4, 3],
);
});
test("history labels identify channel and thread destinations", () => {
const channels = [
{
id: "channel-a",
name: "general",
channelType: "stream",
},
{
id: "dm-a",
name: "Ada Lovelace",
channelType: "dm",
},
];
assert.equal(
describeHistoryLocation(
{ pathname: "/channels/channel-a", search: {} },
channels,
),
"#general",
);
assert.equal(
describeHistoryLocation(
{
pathname: "/channels/channel-a",
search: { thread: "message-a" },
},
channels,
),
"#general thread",
);
assert.equal(
describeHistoryLocation(
{ pathname: "/channels/dm-a", search: {} },
channels,
),
"Ada Lovelace",
);
});
test("history labels cover static and detail routes", () => {
assert.equal(
describeHistoryLocation({ pathname: "/", search: {} }, []),
"Inbox",
);
assert.equal(
describeHistoryLocation({ pathname: "/projects", search: {} }, []),
"Projects",
);
assert.equal(
describeHistoryLocation(
{ pathname: "/projects/project-a", search: {} },
[],
),
"Project details",
);
});

View File

@@ -0,0 +1,85 @@
import type { Channel } from "@/shared/api/types";
export type BackHistoryEntry = {
index: number;
key: string;
label: string;
};
const MAX_BACK_HISTORY_ENTRIES = 10;
export function getBackHistoryEntries(
entriesByIndex: ReadonlyMap<number, BackHistoryEntry>,
currentIndex: number,
): BackHistoryEntry[] {
const entries: BackHistoryEntry[] = [];
for (
let index = currentIndex - 1;
index >= 0 && entries.length < MAX_BACK_HISTORY_ENTRIES;
index -= 1
) {
const entry = entriesByIndex.get(index);
if (entry) {
entries.push(entry);
}
}
return entries;
}
type HistoryLocation = {
pathname: string;
search: unknown;
};
function searchHasValue(search: unknown, key: string): boolean {
if (typeof search !== "object" || search === null) {
return false;
}
const value = (search as Record<string, unknown>)[key];
return typeof value === "string" && value.length > 0;
}
export function describeHistoryLocation(
location: HistoryLocation,
channels: readonly Channel[],
): string {
const { pathname, search } = location;
if (pathname.startsWith("/channels/")) {
const [, , encodedChannelId, childRoute] = pathname.split("/");
const channelId = encodedChannelId
? decodeURIComponent(encodedChannelId)
: "";
const channel = channels.find((candidate) => candidate.id === channelId);
const channelLabel = channel
? channel.channelType === "dm"
? channel.name
: `#${channel.name}`
: "Channel";
if (
childRoute === "posts" ||
searchHasValue(search, "thread") ||
searchHasValue(search, "messageId")
) {
return `${channelLabel} thread`;
}
return channelLabel;
}
if (pathname === "/messages/new") return "New message";
if (pathname === "/agents") return "Agents";
if (pathname === "/workflows") return "Workflows";
if (pathname.startsWith("/workflows/")) return "Workflow details";
if (pathname === "/projects") return "Projects";
if (pathname.startsWith("/projects/")) return "Project details";
if (pathname === "/pulse") return "Pulse";
if (pathname === "/reminders") return "Reminders";
if (pathname === "/settings") return "Settings";
return "Inbox";
}

View File

@@ -8,7 +8,14 @@ import { isTauri } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
import { matchBackForwardChord } from "@/app/navigation/backForwardChords";
import { traverseHistory } from "@/app/navigation/navigationGuard";
import {
type BackHistoryEntry,
getBackHistoryEntries,
} from "@/app/navigation/navigationHistory";
import {
traverseHistory,
traverseHistoryBy,
} from "@/app/navigation/navigationGuard";
import { isMacPlatform } from "@/shared/lib/platform";
import { trimMapToSize } from "@/shared/lib/trimMapToSize";
@@ -18,7 +25,9 @@ type RouterHistoryState = {
key?: string;
};
export function useBackForwardControls() {
const MAX_TRACKED_HISTORY_ENTRIES = 200;
export function useBackForwardControls(currentLabel: string) {
const router = useRouter();
const canGoBack = useCanGoBack();
const locationState = useRouterState({
@@ -27,33 +36,52 @@ export function useBackForwardControls() {
const locationIndex = locationState.__TSR_index ?? 0;
const locationKey =
locationState.__TSR_key ?? locationState.key ?? String(locationIndex);
const keysByIndexRef = React.useRef(new Map<number, string>());
const [maxIndex, setMaxIndex] = React.useState(locationIndex);
const [historyState, setHistoryState] = React.useState(() => ({
entriesByIndex: new Map<number, BackHistoryEntry>([
[
locationIndex,
{ index: locationIndex, key: locationKey, label: currentLabel },
],
]),
maxIndex: locationIndex,
}));
React.useEffect(() => {
const keysByIndex = keysByIndexRef.current;
const currentKey = keysByIndex.get(locationIndex);
setHistoryState((current) => {
const entriesByIndex = new Map(current.entriesByIndex);
const currentEntry = entriesByIndex.get(locationIndex);
const replacedForwardEntry =
currentEntry !== undefined && currentEntry.key !== locationKey;
if (currentKey && currentKey !== locationKey) {
for (const storedIndex of [...keysByIndex.keys()]) {
if (storedIndex >= locationIndex) {
keysByIndex.delete(storedIndex);
if (replacedForwardEntry) {
for (const storedIndex of entriesByIndex.keys()) {
if (storedIndex >= locationIndex) {
entriesByIndex.delete(storedIndex);
}
}
}
}
keysByIndex.set(locationIndex, locationKey);
trimMapToSize(keysByIndex, 200);
setMaxIndex((current: number) => {
if (currentKey && currentKey !== locationKey) {
return locationIndex;
}
entriesByIndex.set(locationIndex, {
index: locationIndex,
key: locationKey,
label: currentLabel,
});
trimMapToSize(entriesByIndex, MAX_TRACKED_HISTORY_ENTRIES);
return Math.max(current, locationIndex);
return {
entriesByIndex,
maxIndex: replacedForwardEntry
? locationIndex
: Math.max(current.maxIndex, locationIndex),
};
});
}, [locationIndex, locationKey]);
}, [currentLabel, locationIndex, locationKey]);
const canGoForward = locationIndex < maxIndex;
const canGoForward = locationIndex < historyState.maxIndex;
const backHistory = React.useMemo(
() => getBackHistoryEntries(historyState.entriesByIndex, locationIndex),
[historyState.entriesByIndex, locationIndex],
);
const goBack = React.useCallback(() => {
if (!canGoBack) {
@@ -71,6 +99,18 @@ export function useBackForwardControls() {
traverseHistory(router.history, "forward");
}, [canGoForward, router.history]);
const goBackTo = React.useCallback(
(index: number) => {
const delta = index - locationIndex;
if (delta >= 0 || !historyState.entriesByIndex.has(index)) {
return;
}
traverseHistoryBy(router.history, delta);
},
[historyState.entriesByIndex, locationIndex, router.history],
);
const handleKeyDown = React.useEffectEvent((event: KeyboardEvent) => {
// Note: the chords deliberately fire even when focus is inside an
// editable element. The composer autofocuses on every channel switch
@@ -129,9 +169,11 @@ export function useBackForwardControls() {
}, []);
return {
backHistory,
canGoBack,
canGoForward,
goBack,
goBackTo,
goForward,
};
}

View File

@@ -93,6 +93,51 @@ test("global back and forward move across channel routes", async ({ page }) => {
await expect(page.getByTestId("chat-title")).toHaveText("random");
});
test("back history menu opens on right click and long press", async ({
page,
}) => {
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
await page.getByTestId("channel-random").click();
await expect(page.getByTestId("chat-title")).toHaveText("random");
await page.getByTestId("channel-engineering").click();
await expect(page.getByTestId("chat-title")).toHaveText("engineering");
const backButton = page.getByTestId("global-back");
const historyMenu = page.getByTestId("global-back-history-menu");
await expect(backButton).toHaveAttribute("data-history-count", "3");
await backButton.click({ button: "right" });
await expect(historyMenu).toBeVisible();
await expect(historyMenu.getByTestId("global-back-history-item")).toHaveText([
"#random",
"#general",
"Inbox",
]);
await historyMenu
.getByRole("menuitem", { name: "Go back to #general" })
.click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
await page.getByTestId("channel-deep-history").click();
await expect(page.getByTestId("chat-title")).toHaveText("deep-history");
const bounds = await backButton.boundingBox();
expect(bounds).not.toBeNull();
await page.mouse.move(
(bounds?.x ?? 0) + (bounds?.width ?? 0) / 2,
(bounds?.y ?? 0) + (bounds?.height ?? 0) / 2,
);
await page.mouse.down();
await expect(historyMenu).toBeVisible();
await page.mouse.up();
await expect(historyMenu).toBeVisible();
await historyMenu.getByRole("menuitem", { name: "Go back to Inbox" }).click();
await expect(page.getByTestId("home-inbox")).toBeVisible();
});
test("back/forward keyboard chords work while the composer has focus", async ({
page,
}) => {