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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/server/src/keybindings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,7 @@ it.layer(NodeServices.layer)("keybindings", (it) => {
assert.equal(defaultsByCommand.get("thread.jump.1"), "mod+1");
assert.equal(defaultsByCommand.get("thread.jump.9"), "mod+9");
assert.equal(defaultsByCommand.get("modelPicker.toggle"), "mod+shift+m");
assert.equal(defaultsByCommand.get("sidebar.toggle"), "mod+b");
assert.equal(defaultsByCommand.get("rightPanel.toggle"), "mod+alt+b");
assert.equal(defaultsByCommand.get("terminal.splitVertical"), "mod+shift+d");
assert.equal(defaultsByCommand.get("modelPicker.jump.1"), "mod+1");
Expand Down
57 changes: 54 additions & 3 deletions apps/web/src/components/AppSidebarLayout.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,64 @@
import { useEffect, type ReactNode } from "react";
import { useAtomValue } from "@effect/atom-react";
import { useEffect, type CSSProperties, type ReactNode } from "react";
import { useNavigate } from "@tanstack/react-router";

import { isElectron } from "../env";
import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings";
import { isMacPlatform } from "../lib/utils";
import { primaryServerKeybindingsAtom } from "../state/server";
import ThreadSidebar from "./Sidebar";
import { Sidebar, SidebarProvider, SidebarRail } from "./ui/sidebar";
import { Sidebar, SidebarProvider, SidebarRail, SidebarTrigger, useSidebar } from "./ui/sidebar";
import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";

const THREAD_SIDEBAR_WIDTH_STORAGE_KEY = "chat_thread_sidebar_width";
const THREAD_SIDEBAR_MIN_WIDTH = 13 * 16;
const THREAD_MAIN_CONTENT_MIN_WIDTH = 40 * 16;
const MACOS_TRAFFIC_LIGHTS_LEFT_INSET = "90px";

function SidebarControl() {
const keybindings = useAtomValue(primaryServerKeybindingsAtom);
const { toggleSidebar } = useSidebar();
const shortcutLabel = shortcutLabelForCommand(keybindings, "sidebar.toggle");

useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => {
if (event.defaultPrevented) return;
if (resolveShortcutCommand(event, keybindings) !== "sidebar.toggle") return;

event.preventDefault();
event.stopPropagation();
toggleSidebar();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Key repeat toggles sidebar repeatedly

Medium Severity

The sidebar.toggle keydown handler in SidebarControl doesn't check event.repeat, causing the sidebar to toggle rapidly when the shortcut is held down.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 57cb362. Configure here.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Mod+B blocked after preventDefault

Medium Severity

sidebar.toggle is handled on window bubble and returns early when event.defaultPrevented is set. Focused targets that call preventDefault on Mod+B before bubble can block toggling, unlike rightPanel.toggle, which is handled in ChatView’s capture listener and stops propagation earlier.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 41d4e81. Configure here.

};

window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, [keybindings, toggleSidebar]);

return (
<div
className="pointer-events-none fixed left-[var(--workspace-controls-left)] top-[var(--workspace-controls-top)] z-50 flex h-[var(--workspace-topbar-height)] items-center"
data-sidebar-control=""
>
<Tooltip>
<TooltipTrigger
render={
<SidebarTrigger className="pointer-events-auto" aria-label="Toggle main sidebar" />
}
/>
<TooltipPopup side="bottom">
Toggle main sidebar{shortcutLabel ? ` (${shortcutLabel})` : ""}
</TooltipPopup>
</Tooltip>
</div>
);
}

export function AppSidebarLayout({ children }: { children: ReactNode }) {
const navigate = useNavigate();
const macosWindowControlsStyle =
isElectron && isMacPlatform(navigator.platform)
? ({ "--workspace-controls-left": MACOS_TRAFFIC_LIGHTS_LEFT_INSET } as CSSProperties)
: undefined;

useEffect(() => {
const onMenuAction = window.desktopBridge?.onMenuAction;
Expand All @@ -28,7 +78,7 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) {
}, [navigate]);

return (
<SidebarProvider className="h-dvh! min-h-0!" defaultOpen>
<SidebarProvider className="h-dvh! min-h-0!" defaultOpen style={macosWindowControlsStyle}>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WCO inset overridden on Mac

Medium Severity

On macOS Electron, SidebarProvider sets a fixed --workspace-controls-left to 90px. This overrides the env(titlebar-area-x) value used for Window Controls Overlay, causing the SidebarControl and collapsed sidebar header padding to misalign with native titlebar geometry.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 57cb362. Configure here.

<Sidebar
side="left"
collapsible="offcanvas"
Expand All @@ -44,6 +94,7 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) {
<SidebarRail />
</Sidebar>
{children}
<SidebarControl />
</SidebarProvider>
);
}
4 changes: 3 additions & 1 deletion apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,7 @@ import PlanSidebar from "./PlanSidebar";
import ThreadTerminalDrawer from "./ThreadTerminalDrawer";
import { ChevronDownIcon, TriangleAlertIcon, WifiOffIcon } from "lucide-react";
import { cn, randomHex } from "~/lib/utils";
import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "~/workspaceTitlebar";
import { stackedThreadToast, toastManager } from "./ui/toast";
import { decodeProjectScriptKeybindingRule } from "~/lib/projectScriptKeybindings";
import { type NewProjectScriptInput } from "./ProjectScriptsControl";
Expand Down Expand Up @@ -4677,7 +4678,7 @@ function ChatViewContent(props: ChatViewProps) {
<header
data-chat-header
className={cn(
"border-b border-border",
"border-b border-border transition-[padding-left] duration-200 ease-linear motion-reduce:transition-none",
isElectron
? cn(
"workspace-topbar drag-region relative px-3 sm:px-5",
Expand All @@ -4686,6 +4687,7 @@ function ChatViewContent(props: ChatViewProps) {
"wco:pr-[var(--workspace-native-controls-inset)]",
)
: "workspace-topbar pl-[calc(env(safe-area-inset-left)+0.75rem)] pr-[calc(env(safe-area-inset-right)+0.75rem)] sm:pl-[calc(env(safe-area-inset-left)+1.25rem)] sm:pr-[calc(env(safe-area-inset-right)+1.25rem)]",
COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS,
)}
>
{!rightPanelOpen ? panelLayoutControls : null}
Expand Down
7 changes: 4 additions & 3 deletions apps/web/src/components/NoActiveThreadState.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,18 @@
import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "./ui/empty";
import { SidebarInset, SidebarTrigger } from "./ui/sidebar";
import { SidebarInset } from "./ui/sidebar";
import { isElectron } from "../env";
import { cn } from "~/lib/utils";
import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "~/workspaceTitlebar";

export function NoActiveThreadState() {
return (
<SidebarInset className="h-dvh min-h-0 overflow-hidden overscroll-y-none bg-background text-foreground">
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-x-hidden bg-background">
<header
className={cn(
"border-b border-border px-3 sm:px-5",
"border-b border-border px-3 transition-[padding-left] duration-200 ease-linear motion-reduce:transition-none sm:px-5",
isElectron ? "workspace-topbar drag-region" : "workspace-topbar",
COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS,
)}
>
{isElectron ? (
Expand All @@ -19,7 +21,6 @@ export function NoActiveThreadState() {
</span>
) : (
<div className="flex items-center gap-2">
<SidebarTrigger className="size-7 shrink-0 md:hidden" />
<span className="text-sm font-medium text-foreground md:text-muted-foreground/60">
No active thread
</span>
Expand Down
106 changes: 53 additions & 53 deletions apps/web/src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ import {
type SidebarThreadSortOrder,
} from "@t3tools/contracts/settings";
import { isElectron } from "../env";
import { APP_STAGE_LABEL, APP_VERSION } from "../branding";
import { APP_STAGE_LABEL } from "../branding";
import { useOpenPrLink } from "../lib/openPullRequestLink";
import { isTerminalFocused } from "../lib/terminalFocus";
import { isMacPlatform } from "../lib/utils";
Expand Down Expand Up @@ -187,12 +187,12 @@ import {
resolveProjectStatusIndicator,
resolveSidebarNewThreadSeedContext,
resolveSidebarNewThreadEnvMode,
resolveSidebarStageBadgeLabel,
resolveThreadRowClassName,
resolveThreadStatusPill,
orderItemsByPreferredIds,
shouldClearThreadSelectionOnMouseDown,
sortProjectsForSidebar,
resolveSidebarStageBadgeLabel,
useThreadJumpHintVisibility,
ThreadStatusPill,
} from "./Sidebar.logic";
Expand Down Expand Up @@ -2452,22 +2452,6 @@ const SidebarProjectListRow = memo(function SidebarProjectListRow(props: Sidebar
);
});

function T3Wordmark() {
return (
<svg
aria-label="T3"
className="h-2.5 w-auto shrink-0 text-foreground"
viewBox="15.5309 37 94.3941 56.96"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M33.4509 93V47.56H15.5309V37H64.3309V47.56H46.4109V93H33.4509ZM86.7253 93.96C82.832 93.96 78.9653 93.4533 75.1253 92.44C71.2853 91.3733 68.032 89.88 65.3653 87.96L70.4053 78.04C72.5386 79.5867 75.0186 80.8133 77.8453 81.72C80.672 82.6267 83.5253 83.08 86.4053 83.08C89.6586 83.08 92.2186 82.44 94.0853 81.16C95.952 79.88 96.8853 78.12 96.8853 75.88C96.8853 73.7467 96.0586 72.0667 94.4053 70.84C92.752 69.6133 90.0853 69 86.4053 69H80.4853V60.44L96.0853 42.76L97.5253 47.4H68.1653V37H107.365V45.4L91.8453 63.08L85.2853 59.32H89.0453C95.9253 59.32 101.125 60.8667 104.645 63.96C108.165 67.0533 109.925 71.0267 109.925 75.88C109.925 79.0267 109.099 81.9867 107.445 84.76C105.792 87.48 103.259 89.6933 99.8453 91.4C96.432 93.1067 92.0586 93.96 86.7253 93.96Z"
fill="currentColor"
/>
</svg>
);
}

type SortableProjectHandleProps = Pick<
ReturnType<typeof useSortable>,
"attributes" | "listeners" | "setActivatorNodeRef"
Expand Down Expand Up @@ -2664,48 +2648,64 @@ const SidebarChromeHeader = memo(function SidebarChromeHeader({
}: {
isElectron: boolean;
}) {
return isElectron ? (
<SidebarHeader className="@container/sidebar-header drag-region h-[var(--workspace-topbar-height)] shrink-0 flex-row items-center px-3 py-0 md:px-0">
<SidebarTrigger className="md:hidden" />
<SidebarBrand />
</SidebarHeader>
) : (
<SidebarHeader className="@container/sidebar-header h-[var(--workspace-topbar-height)] shrink-0 flex-row items-center px-3 py-0 md:px-0">
<SidebarTrigger className="md:hidden" />
<SidebarBrand />
</SidebarHeader>
);
});

function SidebarBrand() {
const stageLabel = useSidebarStageLabel();

return (
<Link
aria-label="Go to threads"
className="sidebar-brand ml-[var(--workspace-titlebar-content-left)] h-7 w-fit min-w-0 shrink-0 items-center gap-1 overflow-hidden rounded-md text-foreground outline-hidden ring-ring focus-visible:ring-2"
to="/"
>
<T3Wordmark />
<span className="truncate text-sm font-medium tracking-tight text-muted-foreground">
Code
</span>
<span className="sidebar-brand-stage shrink-0 items-center whitespace-nowrap rounded-full bg-muted/50 px-1.5 py-0.5 text-[8px] font-medium uppercase tracking-[0.18em] text-muted-foreground/60">
{stageLabel}
</span>
</Link>
);
}

function useSidebarStageLabel() {
const primaryServerVersion =
useAtomValue(primaryServerConfigAtom)?.environment.serverVersion ?? null;
const stageBadgeLabel = resolveSidebarStageBadgeLabel({

return resolveSidebarStageBadgeLabel({
primaryServerVersion,
fallbackStageLabel: APP_STAGE_LABEL,
});
const wordmark = (
<div className="flex items-center gap-2">
<SidebarTrigger className="shrink-0 md:hidden" />
<Tooltip>
<TooltipTrigger
render={
<Link
aria-label="Go to threads"
className="ml-1 flex min-w-0 flex-1 cursor-pointer items-center gap-1 rounded-md outline-hidden ring-ring transition-colors hover:text-foreground focus-visible:ring-2"
to="/"
>
<T3Wordmark />
<span className="truncate text-sm font-medium tracking-tight text-muted-foreground">
Code
</span>
<span className="rounded-full bg-muted/50 px-1.5 py-0.5 text-[8px] font-medium uppercase tracking-[0.18em] text-muted-foreground/60">
{stageBadgeLabel}
</span>
</Link>
}
/>
<TooltipPopup side="bottom" sideOffset={2}>
Version {APP_VERSION}
</TooltipPopup>
</Tooltip>
</div>
);
}

return isElectron ? (
<SidebarHeader className="drag-region h-[52px] flex-row items-center gap-2 px-4 py-0 pl-[90px] wco:h-[env(titlebar-area-height)] wco:pl-[calc(env(titlebar-area-x)+1em)]">
{wordmark}
</SidebarHeader>
) : (
<SidebarHeader className="gap-3 px-3 py-2 sm:gap-2.5 sm:px-4 sm:py-3">{wordmark}</SidebarHeader>
function T3Wordmark() {
return (
<svg
aria-label="T3"
className="h-2.5 w-auto shrink-0 text-foreground"
viewBox="15.5309 37 94.3941 56.96"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M33.4509 93V47.56H15.5309V37H64.3309V47.56H46.4109V93H33.4509ZM86.7253 93.96C82.832 93.96 78.9653 93.4533 75.1253 92.44C71.2853 91.3733 68.032 89.88 65.3653 87.96L70.4053 78.04C72.5386 79.5867 75.0186 80.8133 77.8453 81.72C80.672 82.6267 83.5253 83.08 86.4053 83.08C89.6586 83.08 92.2186 82.44 94.0853 81.16C95.952 79.88 96.8853 78.12 96.8853 75.88C96.8853 73.7467 96.0586 72.0667 94.4053 70.84C92.752 69.6133 90.0853 69 86.4053 69H80.4853V60.44L96.0853 42.76L97.5253 47.4H68.1653V37H107.365V45.4L91.8453 63.08L85.2853 59.32H89.0453C95.9253 59.32 101.125 60.8667 104.645 63.96C108.165 67.0533 109.925 71.0267 109.925 75.88C109.925 79.0267 109.099 81.9867 107.445 84.76C105.792 87.48 103.259 89.6933 99.8453 91.4C96.432 93.1067 92.0586 93.96 86.7253 93.96Z"
fill="currentColor"
/>
</svg>
);
});
}

const SidebarChromeFooter = memo(function SidebarChromeFooter() {
const navigate = useNavigate();
Expand Down
2 changes: 0 additions & 2 deletions apps/web/src/components/chat/ChatHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ import ProjectScriptsControl, {
type NewProjectScriptInput,
type ProjectScriptActionResult,
} from "../ProjectScriptsControl";
import { SidebarTrigger } from "../ui/sidebar";
import { OpenInPicker } from "./OpenInPicker";
import { usePrimaryEnvironmentId } from "../../state/environments";
import { cn } from "~/lib/utils";
Expand Down Expand Up @@ -80,7 +79,6 @@ export const ChatHeader = memo(function ChatHeader({
return (
<div className="@container/header-actions flex min-w-0 flex-1 items-center gap-2 sm:gap-3">
<div className="flex min-w-0 flex-1 items-center gap-2 overflow-hidden sm:gap-3">
<SidebarTrigger className="size-7 shrink-0 md:hidden" />
<Tooltip>
<TooltipTrigger
render={
Expand Down
33 changes: 33 additions & 0 deletions apps/web/src/components/ui/sidebar.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@ import {
SidebarMenuButton,
SidebarMenuSubButton,
SidebarProvider,
SidebarTrigger,
} from "./sidebar";
import { resolveSidebarState } from "./sidebarState";

function renderSidebarButton(className?: string) {
return renderToStaticMarkup(
Expand All @@ -17,6 +19,37 @@ function renderSidebarButton(className?: string) {
}

describe("sidebar interactive cursors", () => {
it("uses mobile sheet visibility for the shared responsive state", () => {
expect(resolveSidebarState({ isMobile: true, open: true, openMobile: false })).toBe(
"collapsed",
);
expect(resolveSidebarState({ isMobile: true, open: false, openMobile: true })).toBe("expanded");
expect(resolveSidebarState({ isMobile: false, open: true, openMobile: false })).toBe(
"expanded",
);
});

it("exposes collapsed state for shared titlebar inset styling", () => {
const html = renderToStaticMarkup(
<SidebarProvider defaultOpen={false}>
<div />
</SidebarProvider>,
);

expect(html).toContain('data-sidebar-state="collapsed"');
});

it("keeps the sidebar trigger interactive inside Electron drag regions", () => {
const html = renderToStaticMarkup(
<SidebarProvider>
<SidebarTrigger />
</SidebarProvider>,
);

expect(html).toContain("[-webkit-app-region:no-drag]");
expect(html).toContain("size-[var(--workspace-titlebar-control-size)]!");
});

it("uses a pointer cursor for menu buttons by default", () => {
const html = renderSidebarButton();

Expand Down
Loading
Loading