Skip to content
Closed
Show file tree
Hide file tree
Changes from 3 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
42 changes: 42 additions & 0 deletions apps/web/src/components/Sidebar.swipe.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { describe, expect, it } from "vite-plus/test";

import {
clampSidebarSwipeOffset,
resolveSidebarSwipeIntent,
shouldOpenSidebarSwipe,
} from "./Sidebar.swipe";

describe("resolveSidebarSwipeIntent", () => {
it("waits for enough movement before claiming a gesture", () => {
expect(resolveSidebarSwipeIntent(-7, 0)).toBe("pending");
expect(resolveSidebarSwipeIntent(3, 7)).toBe("pending");
});

it("claims only clearly horizontal movement", () => {
expect(resolveSidebarSwipeIntent(-30, 8)).toBe("horizontal");
expect(resolveSidebarSwipeIntent(-20, 18)).toBe("vertical");
});

it("keeps vertical scroll gestures vertical", () => {
expect(resolveSidebarSwipeIntent(2, 20)).toBe("vertical");
expect(resolveSidebarSwipeIntent(-8, 30)).toBe("vertical");
});
});

describe("clampSidebarSwipeOffset", () => {
it("reveals left-side movement without changing row geometry", () => {
expect(clampSidebarSwipeOffset({ originOffset: 0, deltaX: -60, revealWidth: 216 })).toBe(-60);
});

it("does not drag beyond the tray or past the closed position", () => {
expect(clampSidebarSwipeOffset({ originOffset: 0, deltaX: -300, revealWidth: 216 })).toBe(-216);
expect(clampSidebarSwipeOffset({ originOffset: -216, deltaX: 300, revealWidth: 216 })).toBe(0);
});
});

describe("shouldOpenSidebarSwipe", () => {
it("opens after a deliberate reveal and otherwise settles closed", () => {
expect(shouldOpenSidebarSwipe({ offset: -76, revealWidth: 216 })).toBe(true);
expect(shouldOpenSidebarSwipe({ offset: -75, revealWidth: 216 })).toBe(false);
});
});
33 changes: 33 additions & 0 deletions apps/web/src/components/Sidebar.swipe.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
export const SIDEBAR_SWIPE_ACTION_WIDTH = 72;

const SWIPE_INTENT_DISTANCE = 8;
const HORIZONTAL_INTENT_RATIO = 1.25;

export type SidebarSwipeIntent = "pending" | "horizontal" | "vertical";

/**
* Bias ambiguous gestures toward vertical scrolling. A row only takes over
* once the finger has moved clearly farther sideways than up or down.
*/
export function resolveSidebarSwipeIntent(deltaX: number, deltaY: number): SidebarSwipeIntent {
const horizontalDistance = Math.abs(deltaX);
const verticalDistance = Math.abs(deltaY);
if (Math.max(horizontalDistance, verticalDistance) < SWIPE_INTENT_DISTANCE) return "pending";
return horizontalDistance > verticalDistance * HORIZONTAL_INTENT_RATIO
? "horizontal"
: "vertical";
}

export function clampSidebarSwipeOffset(input: {
originOffset: number;
deltaX: number;
revealWidth: number;
}): number {
const { originOffset, deltaX, revealWidth } = input;
return Math.min(0, Math.max(-revealWidth, originOffset + deltaX));
}

/** A short deliberate pull opens the tray; a mostly closed row stays closed. */
export function shouldOpenSidebarSwipe(input: { offset: number; revealWidth: number }): boolean {
return input.offset <= -input.revealWidth * 0.35;
}
134 changes: 107 additions & 27 deletions apps/web/src/components/Sidebar.tsx
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,9 @@ import {
snoozeWakeLabel,
type SnoozePreset,
} from "./Sidebar.snooze";
import { SIDEBAR_SWIPE_ACTION_WIDTH } from "./Sidebar.swipe";
import { MobileSidebarSwipeActions, useMobileSidebarRowSwipe } from "./SidebarSwipeActions";
import { SidebarSnoozePresetList } from "./SidebarSnoozePresetList";
import { ProjectFavicon } from "./ProjectFavicon";
import { ProviderInstanceIcon } from "./chat/ProviderInstanceIcon";
import { getTriggerDisplayModelLabel } from "./chat/providerIconUtils";
Expand All @@ -192,7 +195,7 @@ import {
} from "./ui/combobox";
import { SidebarContent, SidebarGroup, SidebarMenuButton, useSidebar } from "./ui/sidebar";
import { SidebarChromeFooter, SidebarChromeHeader } from "./sidebar/SidebarChrome";
import { Popover, PopoverPopup, PopoverTrigger } from "./ui/popover";
import { Popover, PopoverTrigger } from "./ui/popover";
import { Tooltip, TooltipPopup, TooltipProvider, TooltipTrigger } from "./ui/tooltip";
import {
composerDraftHasUserContent,
Expand Down Expand Up @@ -427,25 +430,13 @@ function SnoozePopoverButton(props: {
</TooltipTrigger>
<TooltipPopup>Snooze thread</TooltipPopup>
</Tooltip>
<PopoverPopup side="bottom" align="end" className="w-56" viewportClassName="p-1">
{presets.map((preset) => (
<button
key={preset.id}
type="button"
onClick={(event) => {
event.stopPropagation();
onOpenChange(false);
onSnooze(preset);
}}
className="flex w-full cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-left text-xs text-foreground/90 hover:bg-accent hover:text-foreground"
>
<span className="flex-1">{preset.label}</span>
<span className="font-mono text-[10px] text-muted-foreground/60 tabular-nums">
{preset.whenLabel}
</span>
</button>
))}
</PopoverPopup>
<SidebarSnoozePresetList
presets={presets}
onSelect={(preset) => {
onOpenChange(false);
onSnooze(preset);
}}
/>
</Popover>
);
}
Expand Down Expand Up @@ -730,6 +721,8 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
// When a snooze ended (timer or early wake); drives the Woke pill until
// the user visits the thread.
wokeAt: string | null;
mobileSwipeOpen: boolean;
mobileSwipeEnabled: boolean;
isActive: boolean;
openPullRequestsInRightPanel: boolean;
jumpLabel: string | null;
Expand All @@ -753,7 +746,9 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
onUnsettle: (threadRef: ScopedThreadRef) => void;
onSnooze: (threadRef: ScopedThreadRef, preset: SnoozePreset) => void;
onUnsnooze: (threadRef: ScopedThreadRef) => void;
onPin: (threadRef: ScopedThreadRef) => void;
onUnpin: (threadRef: ScopedThreadRef) => void;
onMobileSwipeOpenChange: (threadKey: string, open: boolean) => void;
onAcknowledgeWoke: (threadRef: ScopedThreadRef, visitedAt: string) => void;
changeRequestSnapshot: ThreadChangeRequestSnapshot | null;
onChangeRequestSnapshot: (
Expand All @@ -772,6 +767,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
onRenameTitleChange,
onSettle,
onSnooze,
onPin,
onStartRename,
onThreadActivate,
onThreadClick,
Expand All @@ -790,6 +786,35 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
);
const threadKey = scopedThreadKey(threadRef);
const { leaseLiveStatus, rowRef } = useSidebarRowSubscriptionLease(props.isActive);
// Snooze is offered only where it can succeed: capability-gated and never
// on blocked-on-you work or queued turns (the server rejects both).
const showSnoozeButton =
props.snoozeSupported && canSnooze(thread, { now: new Date().toISOString() });
const swipeActionCount =
(props.settlementSupported ? 1 : 0) +
(showSnoozeButton ? 1 : 0) +
(props.pinningSupported ? 1 : 0);
const swipeRevealWidth = swipeActionCount * SIDEBAR_SWIPE_ACTION_WIDTH;
const swipeEnabled =
props.mobileSwipeEnabled && variant === "card" && !isRenaming && swipeActionCount > 0;
const setMobileSwipeOpen = useCallback(
(open: boolean) => props.onMobileSwipeOpenChange(threadKey, open),
[props.onMobileSwipeOpenChange, threadKey],
);
const {
consumeSuppressedClick,
dragging: swipeDragging,
offset: swipeOffset,
onPointerCancel: handleSwipePointerCancel,
onPointerDown: handleSwipePointerDown,
onPointerMove: handleSwipePointerMove,
onPointerUp: handleSwipePointerUp,
} = useMobileSidebarRowSwipe({
enabled: swipeEnabled,
open: props.mobileSwipeOpen && swipeEnabled,
revealWidth: swipeRevealWidth,
onOpenChange: setMobileSwipeOpen,
});
const isRegeneratingTitle = thread.titleRegeneration != null;
const lastVisitedAt = useUiStateStore((state) => state.threadLastVisitedAtById[threadKey]);
const isSelected = useThreadSelectionStore((state) => state.selectedThreadKeys.has(threadKey));
Expand Down Expand Up @@ -985,9 +1010,20 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {

const handleClick = useCallback(
(event: ReactMouseEvent) => {
if (consumeSuppressedClick()) {
event.preventDefault();
event.stopPropagation();
return;
}
if (props.mobileSwipeOpen) {
event.preventDefault();
event.stopPropagation();
setMobileSwipeOpen(false);
return;
}
onThreadClick(event, threadRef);
},
[onThreadClick, threadRef],
[consumeSuppressedClick, onThreadClick, props.mobileSwipeOpen, setMobileSwipeOpen, threadRef],
);
const handleAcknowledgeWokeClick = useCallback(
(event: ReactMouseEvent) => {
Expand Down Expand Up @@ -1082,6 +1118,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
},
[onUnpin, threadRef],
);
const handlePinClick = useCallback(() => onPin(threadRef), [onPin, threadRef]);
const handleSnoozePreset = useCallback(
(preset: SnoozePreset) => {
onSnooze(threadRef, preset);
Expand All @@ -1091,10 +1128,6 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
// While the snooze popover is open the pointer leaves the row, which
// would fade the hover actions out from under the open menu; pin them.
const [snoozeMenuOpenRaw, setSnoozeMenuOpen] = useState(false);
// Snooze is offered only where it can succeed: capability-gated and never
// on blocked-on-you work or queued turns (the server rejects both).
const showSnoozeButton =
props.snoozeSupported && canSnooze(thread, { now: new Date().toISOString() });
// If the thread becomes blocked while the popover is open, the button
// unmounts without firing onOpenChange(false). Deriving the flag keeps a
// stale true from permanently hiding the status label / pinning the
Expand Down Expand Up @@ -1408,9 +1441,24 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
{...(sortable?.listeners ?? {})}
className={cn(
"list-none py-0.5 [content-visibility:auto] [contain-intrinsic-size:auto_96px]",
swipeEnabled && "relative overflow-hidden rounded-md",
sortable?.isDragging && "z-20 opacity-80",
)}
>
{swipeEnabled ? (
<MobileSidebarSwipeActions
open={props.mobileSwipeOpen}
showSettle={props.settlementSupported}
showSnooze={showSnoozeButton}
showPin={props.pinningSupported}
isPinned={props.isPinned}
timestampFormat={props.timestampFormat}
onSettle={() => onSettle(threadRef)}
onSnooze={handleSnoozePreset}
onPinToggle={props.isPinned ? () => onUnpin(threadRef) : handlePinClick}
onClose={() => setMobileSwipeOpen(false)}
/>
) : null}
<Tooltip>
<TooltipTrigger
render={
Expand All @@ -1420,11 +1468,32 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
tabIndex={0}
data-testid="sidebar-row-card"
aria-busy={isRegeneratingTitle || undefined}
className={rowSurfaceClassName}
className={cn(
rowSurfaceClassName,
swipeEnabled && "touch-pan-y",
swipeEnabled && !swipeDragging && "transition-transform duration-200 ease-out",
swipeEnabled && (swipeDragging || props.mobileSwipeOpen) && "will-change-transform",
)}
style={
swipeEnabled
? {
transform: `translate3d(${swipeOffset}px, 0, 0)`,
background: props.isActive
? "linear-gradient(var(--sidebar-row-active), var(--sidebar-row-active)), var(--sidebar)"
: isSelected
? "linear-gradient(var(--sidebar-row-selected), var(--sidebar-row-selected)), var(--sidebar)"
: "var(--sidebar)",
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Outdated
}
: undefined
}
onClick={handleClick}
onDoubleClick={handleDoubleClick}
onKeyDown={handleKeyDown}
onContextMenu={handleContextMenu}
onPointerDown={handleSwipePointerDown}
onPointerMove={handleSwipePointerMove}
onPointerUp={handleSwipePointerUp}
onPointerCancel={handleSwipePointerCancel}
/>
}
>
Expand Down Expand Up @@ -1749,7 +1818,14 @@ export default function Sidebar() {
const projectOrder = useUiStateStore((store) => store.projectOrder);
const threads = useThreadShells();
const router = useRouter();
const { isMobile, setOpenMobile } = useSidebar();
const { isMobile, openMobile, setOpenMobile } = useSidebar();
const [mobileSwipeOpenThreadKey, setMobileSwipeOpenThreadKey] = useState<string | null>(null);
const handleMobileSwipeOpenChange = useCallback((threadKey: string, open: boolean) => {
setMobileSwipeOpenThreadKey(open ? threadKey : null);
}, []);
Comment thread
cursor[bot] marked this conversation as resolved.
useEffect(() => {
if (!openMobile) setMobileSwipeOpenThreadKey(null);
}, [openMobile]);
const keybindings = useAtomValue(primaryServerKeybindingsAtom);
const confirmThreadDelete = useClientSettings((s) => s.confirmThreadDelete);
const confirmThreadArchive = useClientSettings((s) => s.confirmThreadArchive);
Expand Down Expand Up @@ -3808,6 +3884,8 @@ export default function Sidebar() {
// the wake signal must survive the trip. Still-snoozed
// rows resolve to null on their own.
wokeAt={threadWokeAt(thread, { now: snoozeNow })}
mobileSwipeEnabled={isMobile}
mobileSwipeOpen={mobileSwipeOpenThreadKey === threadKey}
isActive={routeThreadKey === threadKey}
openPullRequestsInRightPanel={routeThreadRef !== null}
jumpLabel={
Expand Down Expand Up @@ -3846,7 +3924,9 @@ export default function Sidebar() {
onUnsettle={attemptUnsettle}
onSnooze={attemptSnooze}
onUnsnooze={attemptUnsnooze}
onPin={attemptPin}
onUnpin={attemptUnpin}
onMobileSwipeOpenChange={handleMobileSwipeOpenChange}
onAcknowledgeWoke={acknowledgeWoke}
changeRequestSnapshot={changeRequestSnapshotByKey.get(threadKey) ?? null}
onChangeRequestSnapshot={setThreadChangeRequestSnapshot}
Expand Down
33 changes: 33 additions & 0 deletions apps/web/src/components/SidebarSnoozePresetList.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { cn } from "~/lib/utils";
import type { SnoozePreset } from "./Sidebar.snooze";
import { PopoverPopup } from "./ui/popover";

export function SidebarSnoozePresetList(props: {
presets: ReadonlyArray<SnoozePreset>;
rowSize?: "compact" | "touch";
onSelect: (preset: SnoozePreset) => void;
}) {
return (
<PopoverPopup side="bottom" align="end" className="w-56" viewportClassName="p-1">
{props.presets.map((preset) => (
<button
key={preset.id}
type="button"
onClick={(event) => {
event.stopPropagation();
props.onSelect(preset);
}}
className={cn(
"flex w-full cursor-pointer items-center gap-2 rounded-md px-2 text-left text-xs text-foreground/90 hover:bg-accent hover:text-foreground",
props.rowSize === "touch" ? "py-2" : "py-1.5",
)}
>
<span className="flex-1">{preset.label}</span>
<span className="font-mono text-[10px] text-muted-foreground/60 tabular-nums">
{preset.whenLabel}
</span>
</button>
))}
</PopoverPopup>
);
}
Loading
Loading