Skip to content
Draft
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
77 changes: 20 additions & 57 deletions desktop/src/app/AppShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,9 @@ import {
TerminalContextOverrideProvider,
} from "@/app/TerminalContextOverrideContext";
import { useAppNavigation } from "@/app/navigation/useAppNavigation";
import { describeHistoryLocation } from "@/app/navigation/navigationHistory";
import { useBackForwardControls } from "@/app/navigation/useBackForwardControls";
import { useCommunityDestinationRestore } from "@/app/useCommunityDestinationRestore";
import { useCommunityNavigationTransitions } from "@/app/useCommunityNavigationTransitions";
import { useLiveHomeFeedActions } from "@/app/useLiveHomeFeedActions";
import { useChannelBrowserDialog } from "@/app/useChannelBrowserDialog";
Expand Down Expand Up @@ -81,11 +83,6 @@ import { CommunityRail } from "@/features/sidebar/ui/CommunityRail";
import { useChannelMutes } from "@/features/sidebar/lib/useChannelMutes";
import { useChannelStars } from "@/features/sidebar/lib/useChannelStars";
import { useCommunities } from "@/features/communities/useCommunities";
import {
consumePendingCommunityRestore,
loadCommunityDestination,
saveCommunityDestination,
} from "@/features/communities/communityNavigationStorage";
import { useAddCommunityDialogState } from "@/features/communities/addCommunityPrefill";
import { useApplyTemplate } from "@/features/channel-templates/useApplyTemplate";
import { relayClient } from "@/shared/api/relayClient";
Expand Down Expand Up @@ -141,6 +138,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,
Expand All @@ -154,8 +153,9 @@ export function AppShell() {
closeSettings,
openSearchHit,
} = useAppNavigation();
const { canGoBack, canGoForward, goBack, goForward } =
useBackForwardControls();
const navigationControls = useBackForwardControls(
describeHistoryLocation(location, channels),
);
const { selectedChannelId, selectedView } = React.useMemo(
() => deriveShellRoute(location.pathname),
[location.pathname],
Expand Down Expand Up @@ -233,8 +233,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,
Expand Down Expand Up @@ -274,54 +272,15 @@ export function AppShell() {
),
[huddleBackingChannelIds, memberChannels, revealedHuddleChannelIds],
);
const hasRestoredCommunityDestinationRef = React.useRef(false);
React.useEffect(() => {
const activeCommunityId = communitiesHook.activeCommunity?.id;
if (
hasRestoredCommunityDestinationRef.current ||
!channelsQuery.isSuccess ||
channelsQuery.dataUpdatedAt === 0 ||
!activeCommunityId
) {
return;
}
hasRestoredCommunityDestinationRef.current = true;

// Restoration belongs to an explicit community transition. Cold boot and
// reconnect remounts must preserve the route the user explicitly opened.
if (!consumePendingCommunityRestore(activeCommunityId)) {
return;
}

const destination = loadCommunityDestination(activeCommunityId);
if (!destination || destination.kind === "home") {
return;
}

const channelIsAvailable = sidebarChannels.some(
(channel) => channel.id === destination.channelId,
);
if (!channelIsAvailable) {
saveCommunityDestination(activeCommunityId, { kind: "home" });
void goHome({ replace: true });
return;
}

// The normal switch path writes the remembered channel into the hash before
// the target community mounts, so no intermediate Inbox frame is painted.
// Older transition callers may still arrive at neutral Home; repair those.
if (selectedView === "home") {
void goChannel(destination.channelId, { replace: true });
}
}, [
channelsQuery.dataUpdatedAt,
channelsQuery.isSuccess,
communitiesHook.activeCommunity?.id,
useCommunityDestinationRestore({
activeCommunityId: communitiesHook.activeCommunity?.id,
channelsDataUpdatedAt: channelsQuery.dataUpdatedAt,
channelsLoaded: channelsQuery.isSuccess,
goChannel,
goHome,
selectedView,
sidebarChannels,
]);
});
const [terminalContextOverride, setTerminalContextOverride] =
React.useState<TerminalContextOverride | null>(null);
const { activeChannel, terminalContext } = useTerminalContext({
Expand Down Expand Up @@ -774,11 +733,15 @@ export function AppShell() {
<AppWorkflowEditorOverlayProvider>
{!settingsOpen && !isHuddleRoom ? (
<AppTopChrome
canGoBack={canGoBack}
canGoForward={canGoForward}
backHistory={navigationControls.backHistory}
canGoBack={navigationControls.canGoBack}
canGoForward={navigationControls.canGoForward}
forwardHistory={navigationControls.forwardHistory}
hasCommunityRail={hasCommunityRail}
onGoBack={goBack}
onGoForward={goForward}
onGoBack={navigationControls.goBack}
onGoBackTo={navigationControls.goBackTo}
onGoForward={navigationControls.goForward}
onGoForwardTo={navigationControls.goForwardTo}
/>
) : null}
{settingsOpen ? (
Expand Down
167 changes: 145 additions & 22 deletions desktop/src/app/AppTopChrome.tsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,30 @@
import * as React from "react";
import { ChevronLeft, ChevronRight } from "lucide-react";

import type { NavigationHistoryEntry } 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: NavigationHistoryEntry[];
canGoBack: boolean;
canGoForward: boolean;
forwardHistory: NavigationHistoryEntry[];
onGoBack: () => void;
onGoBackTo: (index: number) => void;
onGoForward: () => void;
onGoForwardTo: (index: number) => void;
hasCommunityRail?: boolean;
};

Expand All @@ -25,6 +36,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 HISTORY_LONG_PRESS_MS = 500;

function preventTopChromeWheel(event: WheelEvent) {
event.preventDefault();
Expand Down Expand Up @@ -52,11 +64,130 @@ function TopChromeSidebarTrigger() {
);
}

type HistoryButtonProps = {
canGo: boolean;
direction: "back" | "forward";
entries: NavigationHistoryEntry[];
onGo: () => void;
onGoTo: (index: number) => void;
};

function HistoryButton({
canGo,
direction,
entries,
onGo,
onGoTo,
}: HistoryButtonProps) {
const buttonRef = React.useRef<HTMLButtonElement>(null);
const longPressTimerRef = React.useRef<number | null>(null);
const longPressTriggeredRef = React.useRef(false);
const isBack = direction === "back";
const actionLabel = isBack ? "Go back" : "Go forward";
const testIdPrefix = isBack ? "global-back" : "global-forward";
const Icon = isBack ? ChevronLeft : ChevronRight;

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 || entries.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,
}),
);
}, HISTORY_LONG_PRESS_MS);
},
[cancelLongPress, entries.length],
);

return (
<ContextMenu
onOpenChange={(open) => {
if (!open) {
longPressTriggeredRef.current = false;
}
}}
>
<ContextMenuTrigger asChild>
<Button
ref={buttonRef}
aria-label={actionLabel}
className={HISTORY_ICON_BUTTON_CLASS}
data-history-count={entries.length}
data-testid={testIdPrefix}
disabled={!canGo}
onClick={(event) => {
if (longPressTriggeredRef.current) {
longPressTriggeredRef.current = false;
event.preventDefault();
return;
}

onGo();
}}
onPointerCancel={cancelLongPress}
onPointerDown={handlePointerDown}
onPointerLeave={cancelLongPress}
onPointerUp={cancelLongPress}
size="icon"
variant="ghost"
>
<Icon />
</Button>
</ContextMenuTrigger>
{entries.length > 0 ? (
<ContextMenuContent
className="w-64"
data-testid={`${testIdPrefix}-history-menu`}
>
{entries.map((entry) => (
<ContextMenuItem
aria-label={`${actionLabel} to ${entry.label}`}
data-testid={`${testIdPrefix}-history-item`}
key={entry.key}
onSelect={() => onGoTo(entry.index)}
>
<span className="min-w-0 truncate">{entry.label}</span>
</ContextMenuItem>
))}
</ContextMenuContent>
) : null}
</ContextMenu>
);
}

export function AppTopChrome({
backHistory,
canGoBack,
canGoForward,
forwardHistory,
onGoBack,
onGoBackTo,
onGoForward,
onGoForwardTo,
hasCommunityRail = false,
}: AppTopChromeProps) {
const topChromeRef = React.useRef<HTMLDivElement>(null);
Expand Down Expand Up @@ -132,28 +263,20 @@ 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>
<Button
aria-label="Go forward"
className={HISTORY_ICON_BUTTON_CLASS}
data-testid="global-forward"
disabled={!canGoForward}
onClick={onGoForward}
size="icon"
variant="ghost"
>
<ChevronRight />
</Button>
<HistoryButton
canGo={canGoBack}
direction="back"
entries={backHistory}
onGo={onGoBack}
onGoTo={onGoBackTo}
/>
<HistoryButton
canGo={canGoForward}
direction="forward"
entries={forwardHistory}
onGo={onGoForward}
onGoTo={onGoForwardTo}
/>
</div>
<div
className={cn("flex min-w-0 flex-1 items-center", navRowAlignmentClass)}
Expand Down
32 changes: 30 additions & 2 deletions desktop/src/app/navigation/navigationGuard.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down
Loading
Loading