+
-
+
);
}
diff --git a/apps/desktop/src/renderer/src/components/desktop-layout.workspace-gate.test.tsx b/apps/desktop/src/renderer/src/components/desktop-layout.workspace-gate.test.tsx
index e8c223841..2b28b7af8 100644
--- a/apps/desktop/src/renderer/src/components/desktop-layout.workspace-gate.test.tsx
+++ b/apps/desktop/src/renderer/src/components/desktop-layout.workspace-gate.test.tsx
@@ -31,8 +31,12 @@ vi.mock("@/hooks/use-tab-history", () => ({
useTabHistory: () => ({
canGoBack: false,
canGoForward: false,
+ historyEntries: [],
+ historyIndex: 0,
+ browsingHistory: [],
goBack: vi.fn(),
goForward: vi.fn(),
+ goToHistoryIndex: vi.fn(),
}),
useNavigationInputBindings: () => {},
}));
diff --git a/apps/desktop/src/renderer/src/components/window-toolbar.test.tsx b/apps/desktop/src/renderer/src/components/window-toolbar.test.tsx
new file mode 100644
index 000000000..40327d38d
--- /dev/null
+++ b/apps/desktop/src/renderer/src/components/window-toolbar.test.tsx
@@ -0,0 +1,258 @@
+import type { ComponentProps } from "react";
+import { act, cleanup, fireEvent, render, screen } from "@testing-library/react";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+const historyState = vi.hoisted(() => ({
+ canGoBack: true,
+ canGoForward: true,
+ historyEntries: ["/acme/issues", "/acme/projects", "/acme/agents"],
+ historyIndex: 1,
+ browsingHistory: [
+ { url: "/acme/settings", title: "Settings" },
+ { url: "/acme/issues/issue-1", title: "MUL-1: Fix history" },
+ { url: "/acme/projects", title: "Projects" },
+ ],
+ goBack: vi.fn(),
+ goForward: vi.fn(),
+ goToHistoryIndex: vi.fn(),
+}));
+
+const navigationState = vi.hoisted(() => ({ push: vi.fn() }));
+const sidebarState = vi.hoisted(() => ({
+ state: "expanded" as "expanded" | "collapsed",
+ isCompact: false,
+}));
+
+vi.mock("@/hooks/use-tab-history", () => ({
+ useTabHistory: () => historyState,
+}));
+
+vi.mock("@multica/views/navigation", () => ({
+ useNavigation: () => navigationState,
+}));
+
+vi.mock("@multica/views/layout", () => ({
+ useTabPresentation: (url: string, fallbackTitle?: string) => ({
+ visual: { kind: "icon", icon: "Inbox" },
+ title: fallbackTitle ?? `Title ${url}`,
+ }),
+ ResourceLeadingVisual: () =>
,
+}));
+
+vi.mock("@multica/ui/components/ui/sidebar", () => ({
+ useSidebar: () => sidebarState,
+ SidebarTrigger: (props: ComponentProps<"button">) => (
+
+ ),
+}));
+
+const {
+ WINDOW_TOOLBAR_CLEARANCE,
+ WindowToolbar,
+ browsingHistoryForMenu,
+ historyIndicesForMenu,
+} = await import("./window-toolbar");
+
+beforeEach(() => {
+ historyState.canGoBack = true;
+ historyState.canGoForward = true;
+ historyState.historyEntries = [
+ "/acme/issues",
+ "/acme/projects",
+ "/acme/agents",
+ ];
+ historyState.historyIndex = 1;
+ historyState.browsingHistory = [
+ { url: "/acme/settings", title: "Settings" },
+ { url: "/acme/issues/issue-1", title: "MUL-1: Fix history" },
+ { url: "/acme/projects", title: "Projects" },
+ ];
+ historyState.goBack.mockReset();
+ historyState.goForward.mockReset();
+ historyState.goToHistoryIndex.mockReset();
+ navigationState.push.mockReset();
+ sidebarState.state = "expanded";
+ sidebarState.isCompact = false;
+ vi.useFakeTimers();
+});
+
+afterEach(() => {
+ cleanup();
+ vi.useRealTimers();
+});
+
+describe("historyIndicesForMenu", () => {
+ it("orders backward destinations from nearest to furthest", () => {
+ expect(historyIndicesForMenu("back", 3, 5)).toEqual([2, 1, 0]);
+ });
+
+ it("orders forward destinations from nearest to furthest", () => {
+ expect(historyIndicesForMenu("forward", 1, 5)).toEqual([2, 3, 4]);
+ });
+
+ it("bounds directional history to thirty entries", () => {
+ expect(historyIndicesForMenu("back", 59, 60)).toHaveLength(30);
+ expect(historyIndicesForMenu("forward", 0, 60)).toHaveLength(30);
+ });
+});
+
+describe("browsingHistoryForMenu", () => {
+ it("uses workspace browsing history across tabs and excludes the current resource", () => {
+ expect(
+ browsingHistoryForMenu(
+ [
+ { url: "/acme/issues/issue-2", title: "Issue 2" },
+ { url: "/acme/issues?filter=mine", title: "Issues" },
+ { url: "/acme/projects", title: "Projects" },
+ ],
+ "/acme/issues",
+ ),
+ ).toEqual([
+ { url: "/acme/issues/issue-2", title: "Issue 2" },
+ { url: "/acme/projects", title: "Projects" },
+ ]);
+ });
+
+ it("excludes only the current Inbox issue while keeping other Inbox visits", () => {
+ expect(
+ browsingHistoryForMenu(
+ [
+ { url: "/acme/inbox?issue=issue-b", title: "Issue B" },
+ { url: "/acme/inbox?issue=issue-a", title: "Issue A" },
+ { url: "/acme/inbox", title: "Inbox" },
+ { url: "/acme/projects", title: "Projects" },
+ ],
+ "/acme/inbox?view=archived&issue=issue-b",
+ ),
+ ).toEqual([
+ { url: "/acme/inbox?issue=issue-a", title: "Issue A" },
+ { url: "/acme/inbox", title: "Inbox" },
+ { url: "/acme/projects", title: "Projects" },
+ ]);
+ });
+
+ it("bounds the recently viewed menu to thirty entries", () => {
+ const entries = Array.from(
+ { length: 60 },
+ (_, index) => ({
+ url: `/acme/issues/issue-${index}`,
+ title: `Issue ${index}`,
+ }),
+ );
+ expect(browsingHistoryForMenu(entries, "/acme/settings")).toHaveLength(30);
+ });
+});
+
+describe("WindowToolbar history controls", () => {
+ it("right-aligns the controls to the expanded sidebar edge", () => {
+ render(
);
+
+ const toolbar = document.querySelector('[data-slot="window-toolbar"]');
+ expect(toolbar).toHaveClass("justify-end");
+ expect(toolbar).toHaveStyle({
+ width:
+ "max(var(--sidebar-live-width, var(--sidebar-width)), 256px)",
+ });
+ expect(toolbar).toHaveAttribute("data-sidebar-resize-consumer");
+ expect(toolbar).not.toHaveClass("transition-[width]");
+ });
+
+ it("keeps the controls clear of the traffic lights after toggling the sidebar", () => {
+ const { rerender } = render(
);
+ sidebarState.state = "collapsed";
+ rerender(
);
+
+ const toolbar = document.querySelector('[data-slot="window-toolbar"]');
+ expect(WINDOW_TOOLBAR_CLEARANCE).toBe(256);
+ expect(toolbar).toHaveClass("justify-end");
+ expect(toolbar).toHaveStyle({ width: "256px" });
+ });
+
+ it("opens workspace browsing history and navigates the active tab to a selected entry", () => {
+ render(
);
+
+ fireEvent.click(screen.getByRole("button", { name: "History" }));
+ expect(screen.getByText("Recently viewed")).toBeInTheDocument();
+ expect(screen.getByTitle("MUL-1: Fix history")).toBeInTheDocument();
+
+ fireEvent.click(
+ screen.getByRole("menuitem", { name: "MUL-1: Fix history" }),
+ );
+ expect(navigationState.push).toHaveBeenCalledWith(
+ "/acme/issues/issue-1",
+ );
+ expect(historyState.goToHistoryIndex).not.toHaveBeenCalled();
+ });
+
+ it("keeps browsing history available in a fresh tab while its Back and Forward stay disabled", () => {
+ historyState.canGoBack = false;
+ historyState.canGoForward = false;
+ historyState.historyEntries = ["/acme/issues"];
+ historyState.historyIndex = 0;
+ historyState.browsingHistory = [
+ { url: "/acme/issues", title: "Issues" },
+ { url: "/acme/issues/issue-1", title: "MUL-1: Fix history" },
+ ];
+
+ render(
);
+
+ expect(screen.getByRole("button", { name: "History" })).toBeEnabled();
+ expect(screen.getByRole("button", { name: "Go back" })).toBeDisabled();
+ expect(screen.getByRole("button", { name: "Go forward" })).toBeDisabled();
+ });
+
+ it("keeps a normal Back click as one-step navigation", () => {
+ render(
);
+
+ fireEvent.click(screen.getByRole("button", { name: "Go back" }));
+ expect(historyState.goBack).toHaveBeenCalledOnce();
+ });
+
+ it("opens Back history on a sustained hold without also stepping back", () => {
+ render(
);
+ const back = screen.getByRole("button", { name: "Go back" });
+
+ fireEvent.pointerDown(back, { button: 0, clientX: 20, clientY: 20 });
+ act(() => vi.advanceTimersByTime(2_000));
+ expect(screen.getByText("Back history")).toBeInTheDocument();
+
+ fireEvent.pointerUp(back, { button: 0, clientX: 20, clientY: 20 });
+ fireEvent.click(back);
+ expect(historyState.goBack).not.toHaveBeenCalled();
+ expect(screen.getByText("Back history")).toBeInTheDocument();
+ });
+
+ it("offers the same menu from ArrowDown for keyboard users", () => {
+ render(
);
+ const forward = screen.getByRole("button", { name: "Go forward" });
+
+ fireEvent.keyDown(forward, { key: "ArrowDown" });
+ expect(screen.getByText("Forward history")).toBeInTheDocument();
+ });
+
+ it("jumps to the selected Back history index", () => {
+ render(
);
+ const back = screen.getByRole("button", { name: "Go back" });
+
+ fireEvent.keyDown(back, { key: "ArrowDown" });
+ fireEvent.click(
+ screen.getByRole("menuitem", { name: "Title /acme/issues" }),
+ );
+
+ expect(historyState.goToHistoryIndex).toHaveBeenCalledWith(0);
+ });
+
+ it("restores focus to the toolbar button when its menu closes", () => {
+ render(
);
+ const forward = screen.getByRole("button", { name: "Go forward" });
+ forward.focus();
+
+ fireEvent.keyDown(forward, { key: "ArrowDown" });
+ act(() => vi.runOnlyPendingTimers());
+ expect(screen.getAllByRole("menuitem")[0]).toHaveFocus();
+ fireEvent.keyDown(screen.getByRole("menu"), { key: "Escape" });
+ act(() => vi.runOnlyPendingTimers());
+
+ expect(forward).toHaveFocus();
+ });
+});
diff --git a/apps/desktop/src/renderer/src/components/window-toolbar.tsx b/apps/desktop/src/renderer/src/components/window-toolbar.tsx
new file mode 100644
index 000000000..6607ca947
--- /dev/null
+++ b/apps/desktop/src/renderer/src/components/window-toolbar.tsx
@@ -0,0 +1,417 @@
+import {
+ useCallback,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+ type PointerEventHandler,
+} from "react";
+import { ChevronLeft, ChevronRight, History } from "lucide-react";
+import {
+ SidebarTrigger,
+ useSidebar,
+} from "@multica/ui/components/ui/sidebar";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuGroup,
+ DropdownMenuItem,
+ DropdownMenuLabel,
+} from "@multica/ui/components/ui/dropdown-menu";
+import {
+ ResourceLeadingVisual,
+ useTabPresentation,
+} from "@multica/views/layout";
+import { useNavigation } from "@multica/views/navigation";
+import {
+ useTabHistory,
+ type BrowsingHistoryEntry,
+} from "@/hooks/use-tab-history";
+import { browsingHistoryKeyForUrl } from "@/stores/tab-store";
+
+export const WINDOW_TOOLBAR_CLEARANCE = 256;
+const LONG_PRESS_DURATION_MS = 500;
+const LONG_PRESS_MOVE_TOLERANCE_PX = 8;
+const MAX_HISTORY_MENU_ITEMS = 30;
+
+type HistoryMenuMode = "all" | "back" | "forward";
+
+interface OpenHistoryMenu {
+ mode: HistoryMenuMode;
+ anchor: HTMLElement;
+}
+
+interface LongPressHandlers {
+ onPointerDown: PointerEventHandler
;
+ onPointerMove: PointerEventHandler;
+ onPointerUp: PointerEventHandler;
+ onPointerCancel: PointerEventHandler;
+ onPointerLeave: PointerEventHandler;
+ consumeClick: () => boolean;
+}
+
+function useLongPress(
+ disabled: boolean,
+ onLongPress: (anchor: HTMLButtonElement) => void,
+): LongPressHandlers {
+ const timerRef = useRef | null>(null);
+ const originRef = useRef<{ x: number; y: number } | null>(null);
+ const suppressClickRef = useRef(false);
+
+ const clearPressTimer = useCallback(() => {
+ if (timerRef.current !== null) clearTimeout(timerRef.current);
+ timerRef.current = null;
+ originRef.current = null;
+ }, []);
+
+ useEffect(() => {
+ return clearPressTimer;
+ }, [clearPressTimer]);
+
+ const onPointerDown = useCallback>(
+ (event) => {
+ if (disabled || event.button !== 0) return;
+ clearPressTimer();
+ // A completed long press suppresses the click produced by that same
+ // pointer gesture. Starting a new gesture makes any stale suppression
+ // irrelevant (for example, if the previous pointer left the button and
+ // therefore produced no click).
+ suppressClickRef.current = false;
+ originRef.current = { x: event.clientX, y: event.clientY };
+ const anchor = event.currentTarget;
+ timerRef.current = setTimeout(() => {
+ timerRef.current = null;
+ originRef.current = null;
+ suppressClickRef.current = true;
+ onLongPress(anchor);
+ }, LONG_PRESS_DURATION_MS);
+ },
+ [clearPressTimer, disabled, onLongPress],
+ );
+
+ const onPointerMove = useCallback>(
+ (event) => {
+ const origin = originRef.current;
+ if (!origin) return;
+ if (
+ Math.abs(event.clientX - origin.x) > LONG_PRESS_MOVE_TOLERANCE_PX ||
+ Math.abs(event.clientY - origin.y) > LONG_PRESS_MOVE_TOLERANCE_PX
+ ) {
+ clearPressTimer();
+ }
+ },
+ [clearPressTimer],
+ );
+
+ const consumeClick = useCallback(() => {
+ if (!suppressClickRef.current) return false;
+ suppressClickRef.current = false;
+ return true;
+ }, []);
+
+ return {
+ onPointerDown,
+ onPointerMove,
+ onPointerUp: clearPressTimer,
+ onPointerCancel: clearPressTimer,
+ onPointerLeave: clearPressTimer,
+ consumeClick,
+ };
+}
+
+export function historyIndicesForMenu(
+ mode: Exclude,
+ currentIndex: number,
+ historyLength: number,
+): number[] {
+ if (mode === "back") {
+ return Array.from(
+ { length: Math.min(currentIndex, MAX_HISTORY_MENU_ITEMS) },
+ (_, offset) => currentIndex - offset - 1,
+ );
+ }
+ return Array.from(
+ {
+ length: Math.min(
+ Math.max(0, historyLength - currentIndex - 1),
+ MAX_HISTORY_MENU_ITEMS,
+ ),
+ },
+ (_, offset) => currentIndex + offset + 1,
+ );
+}
+
+export function browsingHistoryForMenu(
+ browsingHistory: BrowsingHistoryEntry[],
+ currentUrl: string | undefined,
+): BrowsingHistoryEntry[] {
+ const currentResource = currentUrl
+ ? browsingHistoryKeyForUrl(currentUrl)
+ : undefined;
+ return browsingHistory
+ .filter(
+ (entry) => browsingHistoryKeyForUrl(entry.url) !== currentResource,
+ )
+ .slice(0, MAX_HISTORY_MENU_ITEMS);
+}
+
+function HistoryMenuItem({
+ url,
+ fallbackTitle,
+ onSelect,
+}: {
+ url: string;
+ fallbackTitle?: string;
+ onSelect: () => void;
+}) {
+ const { visual, title } = useTabPresentation(url, fallbackTitle);
+
+ return (
+
+
+
+ {title}
+
+
+ );
+}
+
+export function WindowToolbar() {
+ const { state: sidebarState, isCompact } = useSidebar();
+ const sidebarHidden = sidebarState === "collapsed" || isCompact;
+ const toolbarWidth: React.CSSProperties["width"] = sidebarHidden
+ ? WINDOW_TOOLBAR_CLEARANCE
+ : `max(var(--sidebar-live-width, var(--sidebar-width)), ${WINDOW_TOOLBAR_CLEARANCE}px)`;
+ const {
+ canGoBack,
+ canGoForward,
+ historyEntries,
+ historyIndex,
+ browsingHistory,
+ goBack,
+ goForward,
+ goToHistoryIndex,
+ } = useTabHistory();
+ const { push } = useNavigation();
+ const [menu, setMenu] = useState(null);
+ const menuAnchorRef = useRef(null);
+ const menuContentRef = useRef(null);
+
+ const openMenu = useCallback((mode: HistoryMenuMode, anchor: HTMLElement) => {
+ menuAnchorRef.current = anchor;
+ setMenu({ mode, anchor });
+ }, []);
+ useEffect(() => {
+ if (menu === null) return;
+ const focusTimer = setTimeout(() => {
+ menuContentRef.current
+ ?.querySelector('[role="menuitem"]')
+ ?.focus();
+ }, 0);
+ return () => clearTimeout(focusTimer);
+ }, [menu]);
+ const openBackMenu = useCallback(
+ (anchor: HTMLButtonElement) => openMenu("back", anchor),
+ [openMenu],
+ );
+ const openForwardMenu = useCallback(
+ (anchor: HTMLButtonElement) => openMenu("forward", anchor),
+ [openMenu],
+ );
+ const backPress = useLongPress(!canGoBack, openBackMenu);
+ const forwardPress = useLongPress(!canGoForward, openForwardMenu);
+
+ const menuIndices = useMemo(
+ () =>
+ menu && menu.mode !== "all"
+ ? historyIndicesForMenu(menu.mode, historyIndex, historyEntries.length)
+ : [],
+ [historyEntries.length, historyIndex, menu],
+ );
+ const browsingMenuEntries = useMemo(
+ () =>
+ browsingHistoryForMenu(
+ browsingHistory,
+ historyEntries[historyIndex],
+ ),
+ [browsingHistory, historyEntries, historyIndex],
+ );
+ const browsingHistoryTitles = useMemo(
+ () =>
+ new Map(
+ browsingHistory.flatMap((entry) =>
+ entry.title
+ ? [[browsingHistoryKeyForUrl(entry.url), entry.title] as const]
+ : [],
+ ),
+ ),
+ [browsingHistory],
+ );
+ const menuLabel =
+ menu?.mode === "back"
+ ? "Back history"
+ : menu?.mode === "forward"
+ ? "Forward history"
+ : "Recently viewed";
+ const navButtonClassName =
+ "flex size-7 items-center justify-center rounded-md text-faint-foreground transition-colors duration-150 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:outline-2 focus-visible:outline-offset-2 disabled:pointer-events-none disabled:opacity-30 motion-reduce:transition-none";
+
+ const selectHistoryIndex = useCallback(
+ (index: number) => {
+ goToHistoryIndex(index);
+ setMenu(null);
+ },
+ [goToHistoryIndex],
+ );
+ const selectBrowsingHistory = useCallback(
+ (url: string) => {
+ push(url);
+ setMenu(null);
+ },
+ [push],
+ );
+
+ return (
+
+
+
+ openMenu("all", event.currentTarget)}
+ >
+
+
+ {
+ event.preventDefault();
+ openMenu("back", event.currentTarget);
+ }}
+ onKeyDown={(event) => {
+ if (event.key !== "ArrowDown") return;
+ event.preventDefault();
+ openMenu("back", event.currentTarget);
+ }}
+ onClick={(event) => {
+ if (backPress.consumeClick()) {
+ event.preventDefault();
+ return;
+ }
+ goBack();
+ }}
+ >
+
+
+ {
+ event.preventDefault();
+ openMenu("forward", event.currentTarget);
+ }}
+ onKeyDown={(event) => {
+ if (event.key !== "ArrowDown") return;
+ event.preventDefault();
+ openMenu("forward", event.currentTarget);
+ }}
+ onClick={(event) => {
+ if (forwardPress.consumeClick()) {
+ event.preventDefault();
+ return;
+ }
+ goForward();
+ }}
+ >
+
+
+
+
+
{
+ if (!open) setMenu(null);
+ }}
+ onOpenChangeComplete={(open) => {
+ if (!open) menuAnchorRef.current?.focus();
+ }}
+ >
+
+
+ {menuLabel}
+ {menu?.mode === "all"
+ ? browsingMenuEntries.map((entry) => (
+ selectBrowsingHistory(entry.url)}
+ />
+ ))
+ : menuIndices.map((index) => (
+ selectHistoryIndex(index)}
+ />
+ ))}
+
+
+
+
+ );
+}
diff --git a/apps/desktop/src/renderer/src/hooks/use-navigation-input-bindings.test.tsx b/apps/desktop/src/renderer/src/hooks/use-navigation-input-bindings.test.tsx
index a1a57761d..5365618eb 100644
--- a/apps/desktop/src/renderer/src/hooks/use-navigation-input-bindings.test.tsx
+++ b/apps/desktop/src/renderer/src/hooks/use-navigation-input-bindings.test.tsx
@@ -31,6 +31,8 @@ function seedHistory() {
],
activeTabId: "t1",
recentTabIds: ["t1"],
+ browsingHistory: ["/acme/issues/abc", "/acme/issues"],
+ browsingHistoryTitles: {},
},
},
});
diff --git a/apps/desktop/src/renderer/src/hooks/use-open-settings-shortcut.test.tsx b/apps/desktop/src/renderer/src/hooks/use-open-settings-shortcut.test.tsx
index 760e4883e..442abe3e9 100644
--- a/apps/desktop/src/renderer/src/hooks/use-open-settings-shortcut.test.tsx
+++ b/apps/desktop/src/renderer/src/hooks/use-open-settings-shortcut.test.tsx
@@ -32,6 +32,8 @@ function seedTabs() {
],
activeTabId: "t1",
recentTabIds: ["t1"],
+ browsingHistory: ["/acme/issues"],
+ browsingHistoryTitles: {},
},
},
});
diff --git a/apps/desktop/src/renderer/src/hooks/use-tab-history.ts b/apps/desktop/src/renderer/src/hooks/use-tab-history.ts
index 0c3cd5d14..4271b6b1b 100644
--- a/apps/desktop/src/renderer/src/hooks/use-tab-history.ts
+++ b/apps/desktop/src/renderer/src/hooks/use-tab-history.ts
@@ -1,5 +1,16 @@
-import { useCallback, useEffect } from "react";
-import { useTabStore, useActiveTabHistory } from "@/stores/tab-store";
+import { useCallback, useEffect, useMemo } from "react";
+import {
+ browsingHistoryKeyForUrl,
+ useTabStore,
+ useActiveBrowsingHistory,
+ useActiveBrowsingHistoryTitles,
+ useActiveTabHistory,
+} from "@/stores/tab-store";
+
+export interface BrowsingHistoryEntry {
+ url: string;
+ title?: string;
+}
/**
* Shell back/forward for the active tab (MUL-4741 session architecture).
@@ -11,7 +22,17 @@ import { useTabStore, useActiveTabHistory } from "@/stores/tab-store";
* direction hints, no router.navigate(±1).
*/
export function useTabHistory() {
- const { historyIndex, historyLength } = useActiveTabHistory();
+ const { historyIndex, historyLength, historyEntries } = useActiveTabHistory();
+ const browsingHistoryUrls = useActiveBrowsingHistory();
+ const browsingHistoryTitles = useActiveBrowsingHistoryTitles();
+ const browsingHistory = useMemo(
+ () =>
+ browsingHistoryUrls.map((url) => ({
+ url,
+ title: browsingHistoryTitles[browsingHistoryKeyForUrl(url)],
+ })),
+ [browsingHistoryTitles, browsingHistoryUrls],
+ );
const canGoBack = historyIndex > 0;
const canGoForward = historyIndex < historyLength - 1;
@@ -24,7 +45,20 @@ export function useTabHistory() {
useTabStore.getState().goForward();
}, []);
- return { canGoBack, canGoForward, goBack, goForward };
+ const goToHistoryIndex = useCallback((index: number) => {
+ useTabStore.getState().goToHistoryIndex(index);
+ }, []);
+
+ return {
+ canGoBack,
+ canGoForward,
+ historyEntries,
+ historyIndex,
+ browsingHistory,
+ goBack,
+ goForward,
+ goToHistoryIndex,
+ };
}
function isEditableTarget(target: EventTarget | null): boolean {
diff --git a/apps/desktop/src/renderer/src/hooks/use-tab-selection-shortcut.test.tsx b/apps/desktop/src/renderer/src/hooks/use-tab-selection-shortcut.test.tsx
index 9982d6cdb..04d85381a 100644
--- a/apps/desktop/src/renderer/src/hooks/use-tab-selection-shortcut.test.tsx
+++ b/apps/desktop/src/renderer/src/hooks/use-tab-selection-shortcut.test.tsx
@@ -39,6 +39,8 @@ function seedTabs(count: number, activeIndex = 1): void {
tabs,
activeTabId: `t${activeIndex}`,
recentTabIds: [],
+ browsingHistory: tabs.map((tab) => tab.url).reverse(),
+ browsingHistoryTitles: {},
},
},
});
diff --git a/apps/desktop/src/renderer/src/stores/tab-store.test.ts b/apps/desktop/src/renderer/src/stores/tab-store.test.ts
index 8058b947b..84d5fec69 100644
--- a/apps/desktop/src/renderer/src/stores/tab-store.test.ts
+++ b/apps/desktop/src/renderer/src/stores/tab-store.test.ts
@@ -3,9 +3,11 @@ import { describe, expect, it, vi, beforeEach } from "vitest";
import {
sanitizeTabPath,
resourceKeyForUrl,
+ browsingHistoryKeyForUrl,
migrateV1ToV2,
migrateV2ToV3,
migrateV3ToV4,
+ migrateV4ToV5,
mergePersistedTabs,
useTabStore,
getActiveTab,
@@ -63,6 +65,26 @@ describe("resourceKeyForUrl", () => {
});
});
+describe("browsingHistoryKeyForUrl", () => {
+ it("keeps Inbox selections distinct while ignoring their other view state", () => {
+ expect(browsingHistoryKeyForUrl("/acme/inbox")).toBe("/acme/inbox");
+ expect(browsingHistoryKeyForUrl("/acme/inbox?issue=issue-a")).toBe(
+ "/acme/inbox?issue=issue-a",
+ );
+ expect(
+ browsingHistoryKeyForUrl(
+ "/acme/inbox?view=archived&issue=issue-a#comment-comment-1",
+ ),
+ ).toBe("/acme/inbox?issue=issue-a");
+ });
+
+ it("continues treating ordinary query and hash changes as one resource", () => {
+ expect(browsingHistoryKeyForUrl("/acme/issues?filter=mine#top")).toBe(
+ "/acme/issues",
+ );
+ });
+});
+
describe("migrateV1ToV2", () => {
it("groups v1 flat tabs by workspace slug", () => {
const v1 = {
@@ -125,6 +147,10 @@ describe("useTabStore actions", () => {
stack: ["/acme/issues"],
index: 0,
});
+ expect(s.byWorkspace.acme.browsingHistory).toEqual(["/acme/issues"]);
+ expect(s.byWorkspace.acme.browsingHistoryTitles).toEqual({
+ "/acme/issues": "Issues",
+ });
});
it("switchWorkspace without openPath restores the group's last active tab", () => {
@@ -158,6 +184,23 @@ describe("useTabStore actions", () => {
expect(activeTab?.url).toBe("/acme/issues");
});
+ it("records the URL of the tab actually restored by a cross-workspace open", () => {
+ const store = useTabStore.getState();
+ store.switchWorkspace("butter");
+ store.navigateActiveSession("/butter/issues?filter=mine", {
+ replace: true,
+ });
+ store.switchWorkspace("acme");
+
+ store.switchWorkspace("butter", "/butter/issues");
+
+ const state = useTabStore.getState();
+ expect(getActiveTab(state)?.url).toBe("/butter/issues?filter=mine");
+ expect(state.byWorkspace.butter.browsingHistory[0]).toBe(
+ "/butter/issues?filter=mine",
+ );
+ });
+
it("switchWorkspace with openPath not matching any tab adds a new tab", () => {
const store = useTabStore.getState();
store.switchWorkspace("acme");
@@ -179,6 +222,35 @@ describe("useTabStore actions", () => {
expect(useTabStore.getState().byWorkspace.acme.tabs).toHaveLength(2); // default + projects
});
+ it("shares browsing history across tabs while Back and Forward remain per-tab", () => {
+ const store = useTabStore.getState();
+ store.switchWorkspace("acme");
+ store.navigateActiveSession("/acme/issues/issue-1");
+ const firstTab = getActiveTab(useTabStore.getState())!;
+
+ const newTabId = store.addTab("/acme/issues", "Issues");
+ store.setActiveTab(newTabId);
+
+ const state = useTabStore.getState();
+ const newTab = getActiveTab(state)!;
+ expect(firstTab.history).toEqual({
+ stack: ["/acme/issues", "/acme/issues/issue-1"],
+ index: 1,
+ });
+ expect(newTab.history).toEqual({ stack: ["/acme/issues"], index: 0 });
+ expect(state.byWorkspace.acme.browsingHistory).toEqual([
+ "/acme/issues",
+ "/acme/issues/issue-1",
+ ]);
+
+ store.goBack();
+ expect(getActiveTab(useTabStore.getState())!.url).toBe("/acme/issues");
+ expect(useTabStore.getState().byWorkspace.acme.browsingHistory).toEqual([
+ "/acme/issues",
+ "/acme/issues/issue-1",
+ ]);
+ });
+
it("openTab with a different query focuses the existing tab and keeps its url (RFC §8.2 semantic change)", () => {
const store = useTabStore.getState();
store.switchWorkspace("acme"); // default tab at /acme/issues
@@ -273,12 +345,17 @@ describe("useTabStore actions", () => {
it("closeTab on the last tab in a workspace reseeds the default tab", () => {
const store = useTabStore.getState();
store.switchWorkspace("acme");
+ store.navigateActiveSession("/acme/issues/issue-1");
const onlyTabId = useTabStore.getState().byWorkspace.acme.tabs[0].id;
store.closeTab(onlyTabId);
const s = useTabStore.getState();
expect(s.byWorkspace.acme.tabs).toHaveLength(1);
expect(s.byWorkspace.acme.tabs[0].url).toBe("/acme/issues");
expect(s.byWorkspace.acme.tabs[0].id).not.toBe(onlyTabId); // fresh tab
+ expect(s.byWorkspace.acme.browsingHistory).toEqual([
+ "/acme/issues",
+ "/acme/issues/issue-1",
+ ]);
});
it("ignores updates addressed to a tab after it has been closed", () => {
@@ -408,6 +485,123 @@ describe("navigateActiveSession", () => {
stack: ["/acme/issues", "/acme/projects?sort=name"],
index: 1,
});
+ expect(useTabStore.getState().byWorkspace.acme.browsingHistory).toEqual([
+ "/acme/projects?sort=name",
+ "/acme/issues",
+ ]);
+ });
+
+ it("records an opened issue and keeps only the latest URL for one resource", () => {
+ const store = useTabStore.getState();
+ store.switchWorkspace("acme");
+
+ store.navigateActiveSession("/acme/issues/issue-1");
+ store.navigateActiveSession("/acme/issues/issue-1?tab=activity", {
+ replace: true,
+ });
+ store.navigateActiveSession("/acme/issues/issue-1#comment-comment-1", {
+ replace: true,
+ });
+
+ expect(useTabStore.getState().byWorkspace.acme.browsingHistory).toEqual([
+ "/acme/issues/issue-1#comment-comment-1",
+ "/acme/issues",
+ ]);
+ });
+
+ it("persists the resolved title for a visited resource", () => {
+ const store = useTabStore.getState();
+ store.switchWorkspace("acme");
+ store.navigateActiveSession("/acme/issues/issue-1");
+ const active = getActiveTab(useTabStore.getState())!;
+
+ store.updateTab(active.id, { title: "MUL-1: Fix history" });
+
+ expect(
+ useTabStore.getState().byWorkspace.acme.browsingHistoryTitles,
+ ).toEqual({
+ "/acme/issues": "Issues",
+ "/acme/issues/issue-1": "MUL-1: Fix history",
+ });
+ });
+
+ it("records each issue viewed inside Inbox without changing its replace-based session history", () => {
+ const store = useTabStore.getState();
+ store.switchWorkspace("acme");
+
+ store.navigateActiveSession("/acme/inbox");
+ store.navigateActiveSession("/acme/inbox?issue=issue-a", {
+ replace: true,
+ });
+ store.navigateActiveSession("/acme/inbox?issue=issue-b", {
+ replace: true,
+ });
+
+ const state = useTabStore.getState();
+ expect(getActiveTab(state)?.history).toEqual({
+ stack: ["/acme/issues", "/acme/inbox?issue=issue-b"],
+ index: 1,
+ });
+ expect(state.byWorkspace.acme.browsingHistory).toEqual([
+ "/acme/inbox?issue=issue-b",
+ "/acme/inbox?issue=issue-a",
+ "/acme/inbox",
+ "/acme/issues",
+ ]);
+ });
+
+ it("moves a revisited Inbox issue to the front instead of duplicating it", () => {
+ const store = useTabStore.getState();
+ store.switchWorkspace("acme");
+ store.navigateActiveSession("/acme/inbox");
+ store.navigateActiveSession("/acme/inbox?issue=issue-a", {
+ replace: true,
+ });
+ store.navigateActiveSession("/acme/inbox?issue=issue-b", {
+ replace: true,
+ });
+ store.navigateActiveSession(
+ "/acme/inbox?view=archived&issue=issue-a#comment-comment-1",
+ { replace: true },
+ );
+
+ expect(useTabStore.getState().byWorkspace.acme.browsingHistory).toEqual([
+ "/acme/inbox?view=archived&issue=issue-a#comment-comment-1",
+ "/acme/inbox?issue=issue-b",
+ "/acme/inbox",
+ "/acme/issues",
+ ]);
+ });
+
+ it("records both sides of a navigation when upgrading from an empty browsing history", () => {
+ const store = useTabStore.getState();
+ store.switchWorkspace("acme");
+ const group = useTabStore.getState().byWorkspace.acme;
+ useTabStore.setState({
+ byWorkspace: {
+ acme: { ...group, browsingHistory: [] },
+ },
+ });
+
+ store.navigateActiveSession("/acme/issues/issue-1");
+
+ expect(useTabStore.getState().byWorkspace.acme.browsingHistory).toEqual([
+ "/acme/issues/issue-1",
+ "/acme/issues",
+ ]);
+ });
+
+ it("replaces an issue UUID visit with its canonical identifier URL", () => {
+ const store = useTabStore.getState();
+ store.switchWorkspace("acme");
+
+ store.navigateActiveSession("/acme/issues/01a08a00-0000-7000-8000-000000000001");
+ store.navigateActiveSession("/acme/issues/ACM-1", { replace: true });
+
+ expect(useTabStore.getState().byWorkspace.acme.browsingHistory).toEqual([
+ "/acme/issues/ACM-1",
+ "/acme/issues",
+ ]);
});
it("replace swaps the current history entry instead of pushing", () => {
@@ -470,6 +664,28 @@ describe("navigateActiveSession", () => {
store.goForward();
expect(getActiveTab(useTabStore.getState())!.history.index).toBe(1);
});
+
+ it("goToHistoryIndex jumps directly and ignores invalid indices", () => {
+ const store = useTabStore.getState();
+ store.switchWorkspace("acme");
+ store.navigateActiveSession("/acme/projects");
+ store.navigateActiveSession("/acme/agents");
+
+ store.goToHistoryIndex(0);
+ let active = getActiveTab(useTabStore.getState())!;
+ expect(active.url).toBe("/acme/issues");
+ expect(active.history.index).toBe(0);
+
+ store.goToHistoryIndex(2);
+ active = getActiveTab(useTabStore.getState())!;
+ expect(active.url).toBe("/acme/agents");
+ expect(active.history.index).toBe(2);
+
+ store.goToHistoryIndex(-1);
+ store.goToHistoryIndex(3);
+ store.goToHistoryIndex(1.5);
+ expect(getActiveTab(useTabStore.getState())).toBe(active);
+ });
});
describe("reloadActiveTab", () => {
@@ -1105,6 +1321,54 @@ describe("migrateV3ToV4 (legacy view-state import, MUL-4741)", () => {
});
});
+describe("migrateV4ToV5", () => {
+ it("seeds cross-tab browsing history from the destinations v4 knew", () => {
+ const v5 = migrateV4ToV5({
+ activeWorkspaceSlug: "acme",
+ byWorkspace: {
+ acme: {
+ activeTabId: "t2",
+ recentTabIds: ["t1"],
+ tabs: [
+ {
+ id: "t1",
+ url: "/acme/issues/issue-1",
+ title: "Issue",
+ pinned: false,
+ history: {
+ stack: ["/acme/issues", "/acme/issues/issue-1"],
+ index: 1,
+ },
+ memento: { scroll: {}, view: {} },
+ },
+ {
+ id: "t2",
+ url: "/acme/projects",
+ title: "Projects",
+ pinned: false,
+ history: {
+ stack: ["/acme/issues", "/acme/projects"],
+ index: 1,
+ },
+ memento: { scroll: {}, view: {} },
+ },
+ ],
+ },
+ },
+ });
+
+ expect(v5.byWorkspace.acme.browsingHistory).toEqual([
+ "/acme/projects",
+ "/acme/issues",
+ "/acme/issues/issue-1",
+ ]);
+ expect(v5.byWorkspace.acme.browsingHistoryTitles).toEqual({
+ "/acme/issues/issue-1": "Issue",
+ "/acme/projects": "Projects",
+ });
+ });
+});
+
describe("mergePersistedTabs (rehydration, MUL-4370)", () => {
const emptyState = (): {
activeWorkspaceSlug: string | null;
@@ -1220,4 +1484,95 @@ describe("mergePersistedTabs (rehydration, MUL-4370)", () => {
const group = rehydrateGroup("t1", { t1: "/acme/issues" });
expect(group.recentTabIds).toEqual([]);
});
+
+ it("restores, sanitizes, and resource-deduplicates workspace browsing history", () => {
+ const result = mergePersistedTabs(
+ {
+ activeWorkspaceSlug: "acme",
+ byWorkspace: {
+ acme: {
+ activeTabId: "t1",
+ tabs: [persistedTab("/acme/issues")],
+ browsingHistory: [
+ "/acme/issues/issue-1?tab=activity",
+ "/acme/issues/issue-1",
+ "/acme/inbox?view=archived&issue=issue-b",
+ "/acme/inbox?issue=issue-a#comment-comment-1",
+ "/acme/inbox?issue=issue-a",
+ "/acme/inbox",
+ "/other/issues/issue-2",
+ "/login",
+ 7,
+ ],
+ },
+ },
+ },
+ emptyState(),
+ );
+
+ expect(result.byWorkspace.acme.browsingHistory).toEqual([
+ "/acme/issues/issue-1?tab=activity",
+ "/acme/inbox?view=archived&issue=issue-b",
+ "/acme/inbox?issue=issue-a#comment-comment-1",
+ "/acme/inbox",
+ ]);
+ });
+
+ it("restores only titles belonging to valid browsing-history entries", () => {
+ const result = mergePersistedTabs(
+ {
+ activeWorkspaceSlug: "acme",
+ byWorkspace: {
+ acme: {
+ activeTabId: "t1",
+ tabs: [
+ persistedTab("/acme/issues/issue-1", {
+ title: "MUL-1: Fix history",
+ }),
+ ],
+ browsingHistory: [
+ "/acme/issues/issue-1",
+ "/acme/projects",
+ ],
+ browsingHistoryTitles: {
+ "/acme/issues/issue-1": "MUL-1: Fix history",
+ "/acme/projects": "Projects",
+ "/other/issues/issue-2": "Other workspace",
+ "/acme/agents": 7,
+ },
+ },
+ },
+ },
+ emptyState(),
+ );
+
+ expect(result.byWorkspace.acme.browsingHistoryTitles).toEqual({
+ "/acme/issues/issue-1": "MUL-1: Fix history",
+ "/acme/projects": "Projects",
+ });
+ });
+
+ it("backfills a known tab title when an older v5 payload has no title map", () => {
+ const result = mergePersistedTabs(
+ {
+ activeWorkspaceSlug: "acme",
+ byWorkspace: {
+ acme: {
+ activeTabId: "t1",
+ tabs: [
+ persistedTab("/acme/issues/issue-1", {
+ title: "MUL-1: Fix history",
+ }),
+ ],
+ browsingHistory: ["/acme/issues/issue-1"],
+ },
+ },
+ },
+ emptyState(),
+ );
+
+ expect(result.byWorkspace.acme.browsingHistoryTitles).toEqual({
+ "/acme/issues/issue-1": "MUL-1: Fix history",
+ });
+ });
});
diff --git a/apps/desktop/src/renderer/src/stores/tab-store.ts b/apps/desktop/src/renderer/src/stores/tab-store.ts
index 02eee0b81..0df8289f5 100644
--- a/apps/desktop/src/renderer/src/stores/tab-store.ts
+++ b/apps/desktop/src/renderer/src/stores/tab-store.ts
@@ -114,6 +114,20 @@ export interface WorkspaceTabGroup {
tabs: TabSession[];
/** Must be a valid tab.id in `tabs`; the empty-tabs state is transient only. */
activeTabId: string;
+ /**
+ * Recently visited resources in this workspace, newest first. Unlike each
+ * tab's session history, this list is shared by every tab in the group —
+ * the same split browsers make between per-tab Back/Forward and profile
+ * browsing history. Entries are resource-deduplicated and bounded.
+ */
+ browsingHistory: string[];
+ /**
+ * Last resolved display title for each browsing-history resource key.
+ * Kept separately so the URL list stays backward-compatible with v5
+ * payloads while cold-start menus can render useful labels before query
+ * caches refill.
+ */
+ browsingHistoryTitles: Record;
/**
* Previously visited tabs of this group, most recent first. Never contains
* `activeTabId`, never contains an id that is no longer in `tabs`.
@@ -207,6 +221,8 @@ interface TabStore {
/** Session-driven back/forward (there is no router history to pop). */
goBack: () => void;
goForward: () => void;
+ /** Jump directly to one entry in the active session's virtual history. */
+ goToHistoryIndex: (historyIndex: number) => void;
/**
* Persist captured scroll offsets for one route of a tab (Coordinator, on
* deactivate / before in-tab navigation). REPLACE semantics per route: all
@@ -319,6 +335,32 @@ export function resourceKeyForUrl(url: string): string {
return splitTabUrl(url).pathname;
}
+/**
+ * Dedup identity for the workspace-wide browsing history.
+ *
+ * Tab sessions intentionally treat search params as view state, but selecting
+ * an Inbox row changes the content being viewed without leaving /inbox. Keep
+ * those selections distinct in global History while leaving Back/Forward's
+ * pathname-based session semantics unchanged.
+ */
+export function browsingHistoryKeyForUrl(url: string): string {
+ return inboxSelectionKeyForUrl(url) ?? resourceKeyForUrl(url);
+}
+
+function inboxSelectionKeyForUrl(url: string): string | null {
+ const { pathname, suffix } = splitTabUrl(url);
+ const segments = pathname.split("/").filter(Boolean);
+ if (segments.length !== 2 || segments[1] !== "inbox") return null;
+ if (!suffix.startsWith("?")) return null;
+
+ const hashIndex = suffix.indexOf("#");
+ const search = hashIndex === -1 ? suffix.slice(1) : suffix.slice(1, hashIndex);
+ const selectedKey = new URLSearchParams(search).get("issue");
+ if (!selectedKey) return null;
+
+ return `${pathname}?issue=${encodeURIComponent(selectedKey)}`;
+}
+
/**
* Defensive: catch URLs that don't belong in the tab store, and normalize
* the ones that do.
@@ -400,6 +442,182 @@ function defaultTabFor(slug: string): TabSession {
return makeSession(path, "Issues");
}
+const BROWSING_HISTORY_MAX_ENTRIES = 100;
+
+function prependBrowsingHistoryEntry(
+ entries: string[],
+ url: string,
+): string[] {
+ if (entries[0] === url) return entries;
+ const resourceKey = browsingHistoryKeyForUrl(url);
+ return [
+ url,
+ ...entries.filter(
+ (entry) => browsingHistoryKeyForUrl(entry) !== resourceKey,
+ ),
+ ].slice(0, BROWSING_HISTORY_MAX_ENTRIES);
+}
+
+function normalizedBrowsingHistoryTitle(title: unknown): string | undefined {
+ if (typeof title !== "string" || title.trim().length === 0) return undefined;
+ return title;
+}
+
+function sameStringRecord(
+ left: Record,
+ right: Record,
+): boolean {
+ const leftKeys = Object.keys(left);
+ const rightKeys = Object.keys(right);
+ return (
+ leftKeys.length === rightKeys.length &&
+ leftKeys.every((key) => left[key] === right[key])
+ );
+}
+
+interface BrowsingVisitOptions {
+ replacedUrl?: string;
+ title?: string;
+}
+
+function withBrowsingVisit(
+ group: WorkspaceTabGroup,
+ slug: string,
+ url: string,
+ options: BrowsingVisitOptions = {},
+): WorkspaceTabGroup {
+ const clean = sanitizeTabPath(url);
+ if (!clean || extractWorkspaceSlug(clean) !== slug) return group;
+ const historyKey = browsingHistoryKeyForUrl(clean);
+ const replacedHistoryKey = options.replacedUrl
+ ? browsingHistoryKeyForUrl(options.replacedUrl)
+ : undefined;
+ const preserveDistinctInboxVisit =
+ options.replacedUrl !== undefined &&
+ replacedHistoryKey !== historyKey &&
+ (inboxSelectionKeyForUrl(clean) !== null ||
+ inboxSelectionKeyForUrl(options.replacedUrl) !== null);
+ const previousEntries = replacedHistoryKey && !preserveDistinctInboxVisit
+ ? group.browsingHistory.filter(
+ (entry) => browsingHistoryKeyForUrl(entry) !== replacedHistoryKey,
+ )
+ : group.browsingHistory;
+ const browsingHistory = prependBrowsingHistoryEntry(
+ previousEntries,
+ clean,
+ );
+ const knownTitle =
+ normalizedBrowsingHistoryTitle(options.title) ??
+ group.browsingHistoryTitles[historyKey] ??
+ (replacedHistoryKey
+ ? group.browsingHistoryTitles[replacedHistoryKey]
+ : undefined);
+ const browsingHistoryTitles: Record = {};
+ for (const entry of browsingHistory) {
+ const entryKey = browsingHistoryKeyForUrl(entry);
+ const title =
+ entryKey === historyKey
+ ? knownTitle
+ : group.browsingHistoryTitles[entryKey];
+ if (title) browsingHistoryTitles[entryKey] = title;
+ }
+ return browsingHistory === group.browsingHistory &&
+ sameStringRecord(browsingHistoryTitles, group.browsingHistoryTitles)
+ ? group
+ : { ...group, browsingHistory, browsingHistoryTitles };
+}
+
+function withActiveSessionVisit(
+ group: WorkspaceTabGroup,
+ slug: string,
+): WorkspaceTabGroup {
+ const active = group.tabs.find((tab) => tab.id === group.activeTabId);
+ return active ? withBrowsingVisit(group, slug, active.url) : group;
+}
+
+function withBrowsingNavigation(
+ group: WorkspaceTabGroup,
+ slug: string,
+ current: TabSession,
+ nextUrl: string,
+ replace: boolean,
+): WorkspaceTabGroup {
+ const withOrigin = withBrowsingVisit(group, slug, current.url);
+ return withBrowsingVisit(withOrigin, slug, nextUrl, {
+ replacedUrl: replace ? current.url : undefined,
+ });
+}
+
+function withBrowsingHistoryTitle(
+ group: WorkspaceTabGroup,
+ url: string,
+ title: string,
+): WorkspaceTabGroup {
+ const normalizedTitle = normalizedBrowsingHistoryTitle(title);
+ if (!normalizedTitle) return group;
+ const historyKey = browsingHistoryKeyForUrl(url);
+ if (
+ !group.browsingHistory.some(
+ (entry) => browsingHistoryKeyForUrl(entry) === historyKey,
+ ) ||
+ group.browsingHistoryTitles[historyKey] === normalizedTitle
+ ) {
+ return group;
+ }
+ return {
+ ...group,
+ browsingHistoryTitles: {
+ ...group.browsingHistoryTitles,
+ [historyKey]: normalizedTitle,
+ },
+ };
+}
+
+function normalizeBrowsingHistory(value: unknown, slug: string): string[] {
+ if (!Array.isArray(value)) return [];
+ const result: string[] = [];
+ const seenResources = new Set();
+ for (const candidate of value) {
+ if (typeof candidate !== "string") continue;
+ const clean = sanitizeTabPath(candidate);
+ if (!clean || extractWorkspaceSlug(clean) !== slug) continue;
+ const resourceKey = browsingHistoryKeyForUrl(clean);
+ if (seenResources.has(resourceKey)) continue;
+ seenResources.add(resourceKey);
+ result.push(clean);
+ if (result.length === BROWSING_HISTORY_MAX_ENTRIES) break;
+ }
+ return result;
+}
+
+function normalizeBrowsingHistoryTitles(
+ value: unknown,
+ browsingHistory: string[],
+ tabs: TabSession[],
+): Record {
+ const persistedTitles =
+ value && typeof value === "object"
+ ? (value as Record)
+ : {};
+ const tabTitles = new Map(
+ tabs.flatMap((tab) => {
+ const title = normalizedBrowsingHistoryTitle(tab.title);
+ return title
+ ? [[browsingHistoryKeyForUrl(tab.url), title] as const]
+ : [];
+ }),
+ );
+ const result: Record = {};
+ for (const url of browsingHistory) {
+ const historyKey = browsingHistoryKeyForUrl(url);
+ const title =
+ normalizedBrowsingHistoryTitle(persistedTitles[historyKey]) ??
+ tabTitles.get(historyKey);
+ if (title) result[historyKey] = title;
+ }
+ return result;
+}
+
// ---------------------------------------------------------------------------
// Group helpers
// ---------------------------------------------------------------------------
@@ -431,7 +649,13 @@ function reconcileGroup(
if (recentTabIds.includes(id)) continue;
recentTabIds.push(id);
}
- return { tabs, activeTabId, recentTabIds };
+ return {
+ tabs,
+ activeTabId,
+ browsingHistory: prev?.browsingHistory ?? [],
+ browsingHistoryTitles: prev?.browsingHistoryTitles ?? {},
+ recentTabIds,
+ };
}
/**
@@ -514,11 +738,17 @@ export const useTabStore = create()(
const cleanDesired = desiredPath ? sanitizeTabPath(desiredPath) : null;
const seedPath = cleanDesired ?? defaultPathFor(slug);
const tab = makeSession(seedPath, "Issues");
+ const nextGroup = withBrowsingVisit(
+ reconcileGroup(null, [tab], tab.id),
+ slug,
+ seedPath,
+ { title: tab.title },
+ );
set({
activeWorkspaceSlug: slug,
byWorkspace: {
...byWorkspace,
- [slug]: reconcileGroup(null, [tab], tab.id),
+ [slug]: nextGroup,
},
});
return;
@@ -532,25 +762,37 @@ export const useTabStore = create()(
const key = resourceKeyForUrl(clean);
const match = existing.tabs.find((t) => t.resourceKey === key);
if (match) {
+ const nextGroup = withBrowsingVisit(
+ reconcileGroup(existing, existing.tabs, match.id),
+ slug,
+ match.url,
+ { title: match.title },
+ );
set({
activeWorkspaceSlug: slug,
byWorkspace: {
...byWorkspace,
- [slug]: reconcileGroup(existing, existing.tabs, match.id),
+ [slug]: nextGroup,
},
});
return;
}
const tab = makeSession(clean, "Issues");
+ const nextGroup = withBrowsingVisit(
+ reconcileGroup(
+ existing,
+ [...existing.tabs, tab],
+ tab.id,
+ ),
+ slug,
+ clean,
+ { title: tab.title },
+ );
set({
activeWorkspaceSlug: slug,
byWorkspace: {
...byWorkspace,
- [slug]: reconcileGroup(
- existing,
- [...existing.tabs, tab],
- tab.id,
- ),
+ [slug]: nextGroup,
},
});
return;
@@ -573,14 +815,20 @@ export const useTabStore = create()(
const key = resourceKeyForUrl(clean);
const existing = group.tabs.find((t) => t.resourceKey === key);
if (existing) {
+ const historyBase = withActiveSessionVisit(
+ group,
+ activeWorkspaceSlug,
+ );
+ const nextGroup = withBrowsingVisit(
+ reconcileGroup(historyBase, group.tabs, existing.id),
+ activeWorkspaceSlug,
+ existing.url,
+ { title: existing.title },
+ );
set({
byWorkspace: {
...byWorkspace,
- [activeWorkspaceSlug]: reconcileGroup(
- group,
- group.tabs,
- existing.id,
- ),
+ [activeWorkspaceSlug]: nextGroup,
},
});
return existing.id;
@@ -604,14 +852,24 @@ export const useTabStore = create()(
tab,
...group.tabs.slice(insertAt),
];
+ const historyBase = withActiveSessionVisit(
+ group,
+ activeWorkspaceSlug,
+ );
+ const nextGroup = withBrowsingVisit(
+ reconcileGroup(
+ historyBase,
+ nextTabs,
+ opts?.activate === true ? tab.id : group.activeTabId,
+ ),
+ activeWorkspaceSlug,
+ clean,
+ { title: tab.title },
+ );
set({
byWorkspace: {
...byWorkspace,
- [activeWorkspaceSlug]: reconcileGroup(
- group,
- nextTabs,
- opts?.activate === true ? tab.id : group.activeTabId,
- ),
+ [activeWorkspaceSlug]: nextGroup,
},
});
return tab.id;
@@ -625,14 +883,24 @@ export const useTabStore = create()(
if (!group) return "";
const tab = makeSession(clean, title);
+ const historyBase = withActiveSessionVisit(
+ group,
+ activeWorkspaceSlug,
+ );
+ const nextGroup = withBrowsingVisit(
+ reconcileGroup(
+ historyBase,
+ [...group.tabs, tab],
+ group.activeTabId,
+ ),
+ activeWorkspaceSlug,
+ clean,
+ { title: tab.title },
+ );
set({
byWorkspace: {
...byWorkspace,
- [activeWorkspaceSlug]: reconcileGroup(
- group,
- [...group.tabs, tab],
- group.activeTabId,
- ),
+ [activeWorkspaceSlug]: nextGroup,
},
});
return tab.id;
@@ -649,10 +917,16 @@ export const useTabStore = create()(
// always has at least one tab. Closing a workspace as an explicit
// action is a separate concern (Leave/Delete in Settings).
const fresh = defaultTabFor(slug);
+ const nextGroup = withBrowsingVisit(
+ reconcileGroup(group, [fresh], fresh.id),
+ slug,
+ fresh.url,
+ { title: fresh.title },
+ );
set({
byWorkspace: {
...byWorkspace,
- [slug]: reconcileGroup(null, [fresh], fresh.id),
+ [slug]: nextGroup,
},
});
return;
@@ -701,15 +975,19 @@ export const useTabStore = create()(
const { slug, group, index } = hit;
const current = group.tabs[index];
const next: TabSession = { ...current, ...patch };
- if (next.title === current.title) {
- return;
- }
- const nextTabs = [...group.tabs];
- nextTabs[index] = next;
+ const titleChanged = next.title !== current.title;
+ const nextTabs = titleChanged ? [...group.tabs] : group.tabs;
+ if (titleChanged) nextTabs[index] = next;
+ const nextGroup = withBrowsingHistoryTitle(
+ titleChanged ? { ...group, tabs: nextTabs } : group,
+ next.url,
+ next.title,
+ );
+ if (!titleChanged && nextGroup === group) return;
set({
byWorkspace: {
...byWorkspace,
- [slug]: { ...group, tabs: nextTabs },
+ [slug]: nextGroup,
},
});
},
@@ -750,10 +1028,17 @@ export const useTabStore = create()(
};
const nextTabs = [...group.tabs];
nextTabs[index] = next;
+ const nextGroup = withBrowsingNavigation(
+ { ...group, tabs: nextTabs },
+ activeWorkspaceSlug,
+ current,
+ clean,
+ replace,
+ );
set({
byWorkspace: {
...byWorkspace,
- [activeWorkspaceSlug]: { ...group, tabs: nextTabs },
+ [activeWorkspaceSlug]: nextGroup,
},
});
},
@@ -766,6 +1051,10 @@ export const useTabStore = create()(
stepHistory(get, set, +1);
},
+ goToHistoryIndex(historyIndex) {
+ setHistoryIndex(get, set, historyIndex);
+ },
+
commitScrollMemento(tabId, routeKey, entries) {
const { byWorkspace } = get();
const hit = findTabLocation(byWorkspace, tabId);
@@ -960,10 +1249,11 @@ export const useTabStore = create()(
const fallbackSlug = validSlugs.values().next().value;
if (fallbackSlug) {
const fresh = defaultTabFor(fallbackSlug);
- nextByWorkspace[fallbackSlug] = reconcileGroup(
- null,
- [fresh],
- fresh.id,
+ nextByWorkspace[fallbackSlug] = withBrowsingVisit(
+ reconcileGroup(null, [fresh], fresh.id),
+ fallbackSlug,
+ fresh.url,
+ { title: fresh.title },
);
nextActive = fallbackSlug;
changed = true;
@@ -980,7 +1270,7 @@ export const useTabStore = create()(
}),
{
name: "multica_tabs",
- version: 4,
+ version: 5,
storage: createJSONStorage(() => createPersistStorage(defaultStorage)),
migrate: (persistedState, version) => {
// v1 → v2: flat `tabs` array → per-workspace grouping.
@@ -1002,7 +1292,13 @@ export const useTabStore = create()(
if (version < 4 && state && typeof state === "object") {
state = migrateV3ToV4(state as V3Persisted);
}
- return state as V4Persisted;
+ // v4 → v5: add workspace browsing history. Existing per-tab stacks
+ // provide a best-effort seed so upgrading users do not lose every
+ // known destination even though v4 had no cross-tab timestamps.
+ if (version < 5 && state && typeof state === "object") {
+ state = migrateV4ToV5(state as V4Persisted);
+ }
+ return state as V5Persisted;
},
partialize: (state) => ({
activeWorkspaceSlug: state.activeWorkspaceSlug,
@@ -1017,6 +1313,8 @@ export const useTabStore = create()(
// the tab you were last looking at rather than falling back to
// the positional neighbour.
recentTabIds: group.recentTabIds,
+ browsingHistory: group.browsingHistory,
+ browsingHistoryTitles: group.browsingHistoryTitles,
tabs: group.tabs.map((t) => ({
id: t.id,
url: t.url,
@@ -1057,7 +1355,7 @@ export function mergePersistedTabs(
persistedState: unknown,
currentState: T,
): T {
- const persisted = persistedState as Partial | undefined;
+ const persisted = persistedState as Partial | undefined;
if (!persisted?.byWorkspace) return currentState;
const byWorkspace: Record = {};
@@ -1114,6 +1412,10 @@ export function mergePersistedTabs(
: tabs[0].id;
// reconcileGroup filters the persisted MRU order down to live tab ids
// (tabs dropped just above), drops the active tab from it, and dedupes.
+ const browsingHistory = normalizeBrowsingHistory(
+ pGroup.browsingHistory,
+ slug,
+ );
byWorkspace[slug] = reconcileGroup(
{
tabs,
@@ -1121,6 +1423,12 @@ export function mergePersistedTabs(
recentTabIds: Array.isArray(pGroup.recentTabIds)
? pGroup.recentTabIds.filter((id) => typeof id === "string")
: [],
+ browsingHistory,
+ browsingHistoryTitles: normalizeBrowsingHistoryTitles(
+ pGroup.browsingHistoryTitles,
+ browsingHistory,
+ tabs,
+ ),
},
tabs,
activeTabId,
@@ -1140,6 +1448,17 @@ function stepHistory(
set: (partial: Partial) => void,
delta: -1 | 1,
) {
+ const active = getActiveTab(get());
+ if (!active) return;
+ setHistoryIndex(get, set, active.history.index + delta);
+}
+
+function setHistoryIndex(
+ get: () => TabStore,
+ set: (partial: Partial) => void,
+ historyIndex: number,
+) {
+ if (!Number.isInteger(historyIndex)) return;
const { activeWorkspaceSlug, byWorkspace } = get();
if (!activeWorkspaceSlug) return;
const group = byWorkspace[activeWorkspaceSlug];
@@ -1147,21 +1466,33 @@ function stepHistory(
const index = group.tabs.findIndex((t) => t.id === group.activeTabId);
if (index < 0) return;
const current = group.tabs[index];
- const nextIndex = current.history.index + delta;
- if (nextIndex < 0 || nextIndex >= current.history.stack.length) return;
- const url = current.history.stack[nextIndex];
+ if (
+ historyIndex === current.history.index ||
+ historyIndex < 0 ||
+ historyIndex >= current.history.stack.length
+ ) {
+ return;
+ }
+ const url = current.history.stack[historyIndex];
const next: TabSession = {
...current,
url,
resourceKey: resourceKeyForUrl(url),
- history: { ...current.history, index: nextIndex },
+ history: { ...current.history, index: historyIndex },
};
const nextTabs = [...group.tabs];
nextTabs[index] = next;
+ const nextGroup = withBrowsingNavigation(
+ { ...group, tabs: nextTabs },
+ activeWorkspaceSlug,
+ current,
+ url,
+ false,
+ );
set({
byWorkspace: {
...byWorkspace,
- [activeWorkspaceSlug]: { ...group, tabs: nextTabs },
+ [activeWorkspaceSlug]: nextGroup,
},
});
}
@@ -1253,6 +1584,53 @@ interface V4Persisted {
byWorkspace: Record;
}
+interface V5PersistedGroup extends V4PersistedGroup {
+ browsingHistory: string[];
+ browsingHistoryTitles?: Record;
+}
+
+interface V5Persisted {
+ activeWorkspaceSlug: string | null;
+ byWorkspace: Record;
+}
+
+export function migrateV4ToV5(v4: V4Persisted): V5Persisted {
+ const byWorkspace: Record = {};
+ for (const [slug, group] of Object.entries(v4.byWorkspace ?? {})) {
+ const byId = new Map(group.tabs.map((tab) => [tab.id, tab]));
+ const orderedTabIds = [
+ group.activeTabId,
+ ...(group.recentTabIds ?? []),
+ ...group.tabs.map((tab) => tab.id),
+ ];
+ const seenTabIds = new Set();
+ const candidates: string[] = [];
+ for (const tabId of orderedTabIds) {
+ if (seenTabIds.has(tabId)) continue;
+ seenTabIds.add(tabId);
+ const tab = byId.get(tabId);
+ if (!tab) continue;
+ candidates.push(tab.url, ...[...tab.history.stack].reverse());
+ }
+ byWorkspace[slug] = {
+ ...group,
+ browsingHistory: normalizeBrowsingHistory(candidates, slug),
+ browsingHistoryTitles: Object.fromEntries(
+ group.tabs.flatMap((tab) => {
+ const title = normalizedBrowsingHistoryTitle(tab.title);
+ return title
+ ? [[browsingHistoryKeyForUrl(tab.url), title] as const]
+ : [];
+ }),
+ ),
+ };
+ }
+ return {
+ activeWorkspaceSlug: v4.activeWorkspaceSlug ?? null,
+ byWorkspace,
+ };
+}
+
export function migrateV3ToV4(v3: V3Persisted): V4Persisted {
const byWorkspace: Record = {};
for (const [slug, group] of Object.entries(v3.byWorkspace ?? {})) {
@@ -1385,13 +1763,13 @@ export function useActiveTabUrl(): string | null {
}
/**
- * History tracking for the active tab as primitives. Subscribers re-render
- * only when the numeric index / length change (i.e. on actual navigations),
- * not on unrelated store updates.
+ * History tracking for the active tab. The stack reference changes only on
+ * actual navigation, so subscribers do not re-render for unrelated updates.
*/
export function useActiveTabHistory(): {
historyIndex: number;
historyLength: number;
+ historyEntries: string[];
} {
const historyIndex = useTabStore(
(s) => getActiveTab(s)?.history.index ?? 0,
@@ -1399,5 +1777,33 @@ export function useActiveTabHistory(): {
const historyLength = useTabStore(
(s) => getActiveTab(s)?.history.stack.length ?? 1,
);
- return { historyIndex, historyLength };
+ const historyEntries = useTabStore(
+ (s) => getActiveTab(s)?.history.stack ?? EMPTY_HISTORY_ENTRIES,
+ );
+ return { historyIndex, historyLength, historyEntries };
+}
+
+const EMPTY_HISTORY_ENTRIES: string[] = [];
+const EMPTY_BROWSING_HISTORY_TITLES: Record = {};
+
+/** Workspace browsing history shared by all tabs, newest first. */
+export function useActiveBrowsingHistory(): string[] {
+ return useTabStore((s) => {
+ if (!s.activeWorkspaceSlug) return EMPTY_HISTORY_ENTRIES;
+ return (
+ s.byWorkspace[s.activeWorkspaceSlug]?.browsingHistory ??
+ EMPTY_HISTORY_ENTRIES
+ );
+ });
+}
+
+/** Persisted display-title fallbacks keyed by browsing-history resource. */
+export function useActiveBrowsingHistoryTitles(): Record {
+ return useTabStore((s) => {
+ if (!s.activeWorkspaceSlug) return EMPTY_BROWSING_HISTORY_TITLES;
+ return (
+ s.byWorkspace[s.activeWorkspaceSlug]?.browsingHistoryTitles ??
+ EMPTY_BROWSING_HISTORY_TITLES
+ );
+ });
}
diff --git a/packages/ui/components/ui/sidebar.tsx b/packages/ui/components/ui/sidebar.tsx
index 47108d04c..8db120379 100644
--- a/packages/ui/components/ui/sidebar.tsx
+++ b/packages/ui/components/ui/sidebar.tsx
@@ -89,8 +89,9 @@ type SidebarResizeContextProps = {
}
const SidebarContext = React.createContext(null)
-// Width previews are written directly to the two layout shells during drag.
-// This context only exposes the one committed state transition on pointer-up.
+// Width previews are written directly to the layout shells and opt-in chrome
+// consumers during drag. This context only exposes the one committed state
+// transition on pointer-up.
const SidebarResizeContext = React.createContext(null)
function useSidebar() {
@@ -433,6 +434,7 @@ function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
wrapperEl: HTMLElement
gapEl: HTMLElement
containerEl: HTMLElement
+ liveWidthConsumers: HTMLElement[]
} | null>(null)
const cancelActiveDragRef = React.useRef<(() => void) | null>(null)
@@ -450,6 +452,9 @@ function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
const gapEl = sidebarEl?.querySelector("[data-slot='sidebar-gap']")
const containerEl = sidebarEl?.querySelector("[data-slot='sidebar-container']")
if (!sidebarEl || !wrapperEl || !gapEl || !containerEl) return
+ const liveWidthConsumers = Array.from(
+ wrapperEl.querySelectorAll("[data-sidebar-resize-consumer]")
+ )
const startWidth = clampSidebarWidth(containerEl.getBoundingClientRect().width)
dragRef.current = {
@@ -461,6 +466,7 @@ function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
wrapperEl,
gapEl,
containerEl,
+ liveWidthConsumers,
}
wrapperEl.setAttribute("data-sidebar-resizing", "true")
@@ -488,6 +494,9 @@ function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
}
drag.gapEl.style.removeProperty("width")
drag.containerEl.style.removeProperty("width")
+ for (const consumer of drag.liveWidthConsumers) {
+ consumer.style.removeProperty("--sidebar-live-width")
+ }
drag.wrapperEl.removeAttribute("data-sidebar-resizing")
}
@@ -514,11 +523,15 @@ function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
if (nextWidth === drag.latestWidth) return
drag.latestWidth = nextWidth
- // Only the two layout shells depend on the live width. Direct writes
- // let the browser coalesce layout at paint time without a React commit
- // or an inherited custom-property invalidation on the whole app tree.
+ // Direct writes let the browser coalesce layout at paint time without
+ // a React commit or an inherited custom-property invalidation on the
+ // whole app tree. Optional chrome consumers receive the same preview
+ // through a local custom property.
drag.gapEl.style.width = `${nextWidth}px`
drag.containerEl.style.width = `${nextWidth}px`
+ for (const consumer of drag.liveWidthConsumers) {
+ consumer.style.setProperty("--sidebar-live-width", `${nextWidth}px`)
+ }
}
const onPointerUp = (event: PointerEvent) => {
if (event.pointerId === e.pointerId) finishDrag("commit")
diff --git a/packages/ui/styles/base.css b/packages/ui/styles/base.css
index 80ecd3c33..11030983c 100644
--- a/packages/ui/styles/base.css
+++ b/packages/ui/styles/base.css
@@ -249,8 +249,8 @@
/* Sidebar resizing follows the same cursor contract as
* react-resizable-panels: the resize cursor survives leaving the narrow rail
* and wins over descendants with their own cursor declarations. Live width is
- * written directly to the two layout shells, so their toggle transitions must
- * stay disabled until the gesture finishes. */
+ * written directly to the layout shells and opt-in chrome consumers, so their
+ * toggle transitions must stay disabled until the gesture finishes. */
html[data-sidebar-resizing="true"],
html[data-sidebar-resizing="true"] * {
cursor: ew-resize !important;
@@ -268,7 +268,8 @@ html[data-dnd-dragging="true"] * {
}
[data-sidebar-resizing="true"] [data-slot="sidebar-gap"],
-[data-sidebar-resizing="true"] [data-slot="sidebar-container"] {
+[data-sidebar-resizing="true"] [data-slot="sidebar-container"],
+[data-sidebar-resizing="true"] [data-sidebar-resize-consumer] {
transition: none !important;
}
diff --git a/packages/views/layout/sidebar-resize.test.tsx b/packages/views/layout/sidebar-resize.test.tsx
index 06796b4f6..81977df2e 100644
--- a/packages/views/layout/sidebar-resize.test.tsx
+++ b/packages/views/layout/sidebar-resize.test.tsx
@@ -22,7 +22,6 @@ describe("left sidebar resizing", () => {
it("previews width directly and commits only when the pointer is released", () => {
const stableConsumerRender = vi.fn();
- const setItem = vi.spyOn(Storage.prototype, "setItem");
function StableSidebarConsumer() {
useSidebar();
@@ -36,6 +35,7 @@ describe("left sidebar resizing", () => {
+
,
);
@@ -43,6 +43,9 @@ describe("left sidebar resizing", () => {
const sidebarContainer = container.querySelector("[data-slot='sidebar-container']")!;
const sidebarGap = container.querySelector("[data-slot='sidebar-gap']")!;
const sidebar = container.querySelector("[data-slot='sidebar']")!;
+ const liveWidthConsumer = container.querySelector(
+ "[data-sidebar-resize-consumer]",
+ )!;
const rail = container.querySelector("[data-slot='sidebar-rail']")!;
const setPointerCapture = vi.fn();
const releasePointerCapture = vi.fn();
@@ -79,17 +82,22 @@ describe("left sidebar resizing", () => {
expect(sidebarGap.style.width).toBe("300px");
expect(sidebarContainer.style.width).toBe("300px");
+ expect(
+ liveWidthConsumer.style.getPropertyValue("--sidebar-live-width"),
+ ).toBe("300px");
expect(wrapper.style.getPropertyValue("--sidebar-width")).toBe("256px");
- expect(setItem).not.toHaveBeenCalled();
+ expect(localStorage.getItem("sidebar_width")).toBeNull();
expect(stableConsumerRender).toHaveBeenCalledTimes(1);
fireEvent.pointerUp(document, { pointerId: 7 });
expect(sidebarGap.style.width).toBe("");
expect(sidebarContainer.style.width).toBe("");
+ expect(
+ liveWidthConsumer.style.getPropertyValue("--sidebar-live-width"),
+ ).toBe("");
expect(wrapper.style.getPropertyValue("--sidebar-width")).toBe("300px");
- expect(setItem).toHaveBeenCalledTimes(1);
- expect(setItem).toHaveBeenCalledWith("sidebar_width", "300");
+ expect(localStorage.getItem("sidebar_width")).toBe("300");
expect(releasePointerCapture).toHaveBeenCalledWith(7);
expect(wrapper).not.toHaveAttribute("data-sidebar-resizing");
expect(document.documentElement).not.toHaveAttribute("data-sidebar-resizing");
@@ -100,18 +108,21 @@ describe("left sidebar resizing", () => {
});
it("restores the committed width and cursor state when pointer capture is cancelled", () => {
- const setItem = vi.spyOn(Storage.prototype, "setItem");
const { container } = renderWithI18n(
+
,
);
const wrapper = container.querySelector("[data-slot='sidebar-wrapper']")!;
const sidebarContainer = container.querySelector("[data-slot='sidebar-container']")!;
const sidebarGap = container.querySelector("[data-slot='sidebar-gap']")!;
+ const liveWidthConsumer = container.querySelector(
+ "[data-sidebar-resize-consumer]",
+ )!;
const rail = container.querySelector("[data-slot='sidebar-rail']")!;
rail.setPointerCapture = vi.fn();
rail.hasPointerCapture = vi.fn(() => true);
@@ -136,12 +147,18 @@ describe("left sidebar resizing", () => {
pointerId: 8,
});
fireEvent.pointerMove(document, { buttons: 1, clientX: 320, pointerId: 8 });
+ expect(
+ liveWidthConsumer.style.getPropertyValue("--sidebar-live-width"),
+ ).toBe("320px");
fireEvent.pointerCancel(document, { pointerId: 8 });
expect(sidebarGap.style.width).toBe("");
expect(sidebarContainer.style.width).toBe("");
+ expect(
+ liveWidthConsumer.style.getPropertyValue("--sidebar-live-width"),
+ ).toBe("");
expect(wrapper.style.getPropertyValue("--sidebar-width")).toBe("256px");
- expect(setItem).not.toHaveBeenCalled();
+ expect(localStorage.getItem("sidebar_width")).toBeNull();
expect(wrapper).not.toHaveAttribute("data-sidebar-resizing");
expect(document.documentElement).not.toHaveAttribute("data-sidebar-resizing");
});