Skip to content

Commit 5df58f6

Browse files
author
Julius Marminge
committed
Add main sidebar toggle
1 parent 82a9bcc commit 5df58f6

15 files changed

Lines changed: 151 additions & 77 deletions

File tree

apps/mobile/src/features/threads/ThreadNavigationDrawer.tsx

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ export function ThreadNavigationDrawer(props: {
5858
const drawerBg = useThemeColor("--color-drawer");
5959
const drawerShadow = useThemeColor("--color-drawer-shadow");
6060
const primaryForeground = useThemeColor("--color-primary-foreground");
61+
const iconColor = useThemeColor("--color-icon");
6162
const borderSubtleColor = useThemeColor("--color-border-subtle");
6263

6364
useEffect(() => {
@@ -151,7 +152,10 @@ export function ThreadNavigationDrawer(props: {
151152
drawerStyle,
152153
]}
153154
>
154-
<View className="flex-row items-center justify-between px-4 pb-5">
155+
<View
156+
className="flex-row items-center justify-between pr-4 pb-5"
157+
style={{ paddingLeft: 68 }}
158+
>
155159
<Text className="text-2xl font-t3-bold">Threads</Text>
156160
<Pressable
157161
onPress={() => {
@@ -178,6 +182,24 @@ export function ThreadNavigationDrawer(props: {
178182
/>
179183
</Animated.View>
180184
</GestureDetector>
185+
<Pressable
186+
accessibilityLabel="Close thread sidebar"
187+
accessibilityRole="button"
188+
onPress={props.onClose}
189+
style={{
190+
position: "absolute",
191+
left: 12,
192+
top: insets.top + 6,
193+
width: 44,
194+
height: 44,
195+
alignItems: "center",
196+
justifyContent: "center",
197+
borderRadius: 22,
198+
backgroundColor: drawerBg,
199+
}}
200+
>
201+
<SymbolView name="sidebar.left" size={18} tintColor={iconColor} type="monochrome" />
202+
</Pressable>
181203
</View>
182204
</Modal>
183205
);

apps/mobile/src/features/threads/ThreadRouteScreen.tsx

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,9 @@ export function ThreadRouteScreen() {
153153
const handleOpenDrawer = useCallback(() => {
154154
setDrawerVisible(true);
155155
}, []);
156+
const handleToggleDrawer = useCallback(() => {
157+
setDrawerVisible((visible) => !visible);
158+
}, []);
156159

157160
const handleOpenConnectionEditor = useCallback(() => {
158161
void router.push("/connections");
@@ -416,6 +419,14 @@ export function ThreadRouteScreen() {
416419
}}
417420
/>
418421

422+
<Stack.Toolbar placement="left">
423+
<Stack.Toolbar.Button
424+
accessibilityLabel={drawerVisible ? "Close thread sidebar" : "Open thread sidebar"}
425+
icon="sidebar.left"
426+
onPress={handleToggleDrawer}
427+
/>
428+
</Stack.Toolbar>
429+
419430
<ThreadGitControls
420431
currentBranch={selectedThread.branch}
421432
gitStatus={gitStatus.data}

apps/server/src/keybindings.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,7 @@ it.layer(NodeServices.layer)("keybindings", (it) => {
198198
assert.equal(defaultsByCommand.get("thread.jump.1"), "mod+1");
199199
assert.equal(defaultsByCommand.get("thread.jump.9"), "mod+9");
200200
assert.equal(defaultsByCommand.get("modelPicker.toggle"), "mod+shift+m");
201+
assert.equal(defaultsByCommand.get("sidebar.toggle"), "mod+b");
201202
assert.equal(defaultsByCommand.get("rightPanel.toggle"), "mod+alt+b");
202203
assert.equal(defaultsByCommand.get("terminal.splitVertical"), "mod+shift+d");
203204
assert.equal(defaultsByCommand.get("modelPicker.jump.1"), "mod+1");

apps/web/src/components/AppSidebarLayout.tsx

Lines changed: 47 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,57 @@
1-
import { useEffect, type ReactNode } from "react";
1+
import { useAtomValue } from "@effect/atom-react";
2+
import { useEffect, type CSSProperties, type ReactNode } from "react";
23
import { useNavigate } from "@tanstack/react-router";
34

5+
import { isElectron } from "../env";
6+
import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings";
7+
import { isMacPlatform } from "../lib/utils";
8+
import { primaryServerKeybindingsAtom } from "../state/server";
49
import ThreadSidebar from "./Sidebar";
5-
import { Sidebar, SidebarProvider, SidebarRail } from "./ui/sidebar";
10+
import { Sidebar, SidebarProvider, SidebarRail, SidebarTrigger, useSidebar } from "./ui/sidebar";
11+
import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
612

713
const THREAD_SIDEBAR_WIDTH_STORAGE_KEY = "chat_thread_sidebar_width";
814
const THREAD_SIDEBAR_MIN_WIDTH = 13 * 16;
915
const THREAD_MAIN_CONTENT_MIN_WIDTH = 40 * 16;
16+
const MACOS_TRAFFIC_LIGHTS_LEFT_INSET = "90px";
17+
18+
function SidebarControl() {
19+
const keybindings = useAtomValue(primaryServerKeybindingsAtom);
20+
const { toggleSidebar } = useSidebar();
21+
const shortcutLabel = shortcutLabelForCommand(keybindings, "sidebar.toggle");
22+
23+
useEffect(() => {
24+
const onKeyDown = (event: KeyboardEvent) => {
25+
if (event.defaultPrevented) return;
26+
if (resolveShortcutCommand(event, keybindings) !== "sidebar.toggle") return;
27+
28+
event.preventDefault();
29+
event.stopPropagation();
30+
toggleSidebar();
31+
};
32+
33+
window.addEventListener("keydown", onKeyDown);
34+
return () => window.removeEventListener("keydown", onKeyDown);
35+
}, [keybindings, toggleSidebar]);
36+
37+
return (
38+
<div className="fixed left-[var(--workspace-controls-left)] top-[var(--workspace-controls-top)] z-50 flex h-[var(--workspace-topbar-height)] items-center [-webkit-app-region:no-drag]">
39+
<Tooltip>
40+
<TooltipTrigger render={<SidebarTrigger aria-label="Toggle main sidebar" />} />
41+
<TooltipPopup side="bottom">
42+
Toggle main sidebar{shortcutLabel ? ` (${shortcutLabel})` : ""}
43+
</TooltipPopup>
44+
</Tooltip>
45+
</div>
46+
);
47+
}
48+
1049
export function AppSidebarLayout({ children }: { children: ReactNode }) {
1150
const navigate = useNavigate();
51+
const macosWindowControlsStyle =
52+
isElectron && isMacPlatform(navigator.platform)
53+
? ({ "--workspace-controls-left": MACOS_TRAFFIC_LIGHTS_LEFT_INSET } as CSSProperties)
54+
: undefined;
1255

1356
useEffect(() => {
1457
const onMenuAction = window.desktopBridge?.onMenuAction;
@@ -28,7 +71,8 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) {
2871
}, [navigate]);
2972

3073
return (
31-
<SidebarProvider className="h-dvh! min-h-0!" defaultOpen>
74+
<SidebarProvider className="h-dvh! min-h-0!" defaultOpen style={macosWindowControlsStyle}>
75+
<SidebarControl />
3276
<Sidebar
3377
side="left"
3478
collapsible="offcanvas"

apps/web/src/components/NoActiveThreadState.tsx

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,18 @@
11
import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "./ui/empty";
2-
import { SidebarInset, SidebarTrigger } from "./ui/sidebar";
2+
import { SidebarInset, useSidebarVisibility } from "./ui/sidebar";
33
import { isElectron } from "../env";
44
import { cn } from "~/lib/utils";
55

66
export function NoActiveThreadState() {
7+
const sidebarOpen = useSidebarVisibility();
78
return (
89
<SidebarInset className="h-dvh min-h-0 overflow-hidden overscroll-y-none bg-background text-foreground">
910
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-x-hidden bg-background">
1011
<header
1112
className={cn(
12-
"border-b border-border px-3 sm:px-5",
13+
"border-b border-border px-3 transition-[padding-left] duration-200 ease-linear motion-reduce:transition-none sm:px-5",
1314
isElectron ? "workspace-topbar drag-region" : "workspace-topbar",
15+
!sidebarOpen && "pl-[calc(var(--workspace-controls-left)+2.5rem)]",
1416
)}
1517
>
1618
{isElectron ? (
@@ -19,7 +21,6 @@ export function NoActiveThreadState() {
1921
</span>
2022
) : (
2123
<div className="flex items-center gap-2">
22-
<SidebarTrigger className="size-7 shrink-0 md:hidden" />
2324
<span className="text-sm font-medium text-foreground md:text-muted-foreground/60">
2425
No active thread
2526
</span>

apps/web/src/components/Sidebar.tsx

Lines changed: 7 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ import {
6161
settlePromise,
6262
squashAtomCommandFailure,
6363
} from "@t3tools/client-runtime/state/runtime";
64-
import { Link, useLocation, useNavigate, useParams, useRouter } from "@tanstack/react-router";
64+
import { useLocation, useNavigate, useParams, useRouter } from "@tanstack/react-router";
6565
import {
6666
MAX_SIDEBAR_THREAD_PREVIEW_COUNT,
6767
MIN_SIDEBAR_THREAD_PREVIEW_COUNT,
@@ -70,7 +70,6 @@ import {
7070
type SidebarThreadSortOrder,
7171
} from "@t3tools/contracts/settings";
7272
import { isElectron } from "../env";
73-
import { APP_STAGE_LABEL, APP_VERSION } from "../branding";
7473
import { useOpenPrLink } from "../lib/openPullRequestLink";
7574
import { isTerminalFocused } from "../lib/terminalFocus";
7675
import { isMacPlatform } from "../lib/utils";
@@ -192,7 +191,6 @@ import {
192191
orderItemsByPreferredIds,
193192
shouldClearThreadSelectionOnMouseDown,
194193
sortProjectsForSidebar,
195-
resolveSidebarStageBadgeLabel,
196194
useThreadJumpHintVisibility,
197195
ThreadStatusPill,
198196
} from "./Sidebar.logic";
@@ -202,7 +200,7 @@ import { useCopyToClipboard } from "~/hooks/useCopyToClipboard";
202200
import { useIsMobile } from "~/hooks/useMediaQuery";
203201
import { CommandDialogTrigger } from "./ui/command";
204202
import { useClientSettings, useUpdateClientSettings } from "~/hooks/useSettings";
205-
import { primaryServerConfigAtom, primaryServerKeybindingsAtom } from "../state/server";
203+
import { primaryServerKeybindingsAtom } from "../state/server";
206204
import {
207205
derivePhysicalProjectKey,
208206
deriveProjectGroupingOverrideKey,
@@ -2452,22 +2450,6 @@ const SidebarProjectListRow = memo(function SidebarProjectListRow(props: Sidebar
24522450
);
24532451
});
24542452

2455-
function T3Wordmark() {
2456-
return (
2457-
<svg
2458-
aria-label="T3"
2459-
className="h-2.5 w-auto shrink-0 text-foreground"
2460-
viewBox="15.5309 37 94.3941 56.96"
2461-
xmlns="http://www.w3.org/2000/svg"
2462-
>
2463-
<path
2464-
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"
2465-
fill="currentColor"
2466-
/>
2467-
</svg>
2468-
);
2469-
}
2470-
24712453
type SortableProjectHandleProps = Pick<
24722454
ReturnType<typeof useSortable>,
24732455
"attributes" | "listeners" | "setActivatorNodeRef"
@@ -2664,46 +2646,14 @@ const SidebarChromeHeader = memo(function SidebarChromeHeader({
26642646
}: {
26652647
isElectron: boolean;
26662648
}) {
2667-
const primaryServerVersion =
2668-
useAtomValue(primaryServerConfigAtom)?.environment.serverVersion ?? null;
2669-
const stageBadgeLabel = resolveSidebarStageBadgeLabel({
2670-
primaryServerVersion,
2671-
fallbackStageLabel: APP_STAGE_LABEL,
2672-
});
2673-
const wordmark = (
2674-
<div className="flex items-center gap-2">
2675-
<SidebarTrigger className="shrink-0 md:hidden" />
2676-
<Tooltip>
2677-
<TooltipTrigger
2678-
render={
2679-
<Link
2680-
aria-label="Go to threads"
2681-
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"
2682-
to="/"
2683-
>
2684-
<T3Wordmark />
2685-
<span className="truncate text-sm font-medium tracking-tight text-muted-foreground">
2686-
Code
2687-
</span>
2688-
<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">
2689-
{stageBadgeLabel}
2690-
</span>
2691-
</Link>
2692-
}
2693-
/>
2694-
<TooltipPopup side="bottom" sideOffset={2}>
2695-
Version {APP_VERSION}
2696-
</TooltipPopup>
2697-
</Tooltip>
2698-
</div>
2699-
);
2700-
27012649
return isElectron ? (
2702-
<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)]">
2703-
{wordmark}
2650+
<SidebarHeader className="drag-region h-[var(--workspace-topbar-height)] shrink-0 flex-row items-center px-3 py-0">
2651+
<SidebarTrigger className="md:hidden" />
27042652
</SidebarHeader>
27052653
) : (
2706-
<SidebarHeader className="gap-3 px-3 py-2 sm:gap-2.5 sm:px-4 sm:py-3">{wordmark}</SidebarHeader>
2654+
<SidebarHeader className="h-[var(--workspace-topbar-height)] shrink-0 flex-row items-center px-3 py-0">
2655+
<SidebarTrigger className="md:hidden" />
2656+
</SidebarHeader>
27072657
);
27082658
});
27092659

apps/web/src/components/chat/ChatHeader.tsx

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,8 @@ import ProjectScriptsControl, {
1414
type NewProjectScriptInput,
1515
type ProjectScriptActionResult,
1616
} from "../ProjectScriptsControl";
17-
import { SidebarTrigger } from "../ui/sidebar";
1817
import { OpenInPicker } from "./OpenInPicker";
18+
import { useSidebarVisibility } from "../ui/sidebar";
1919
import { usePrimaryEnvironmentId } from "../../state/environments";
2020
import { cn } from "~/lib/utils";
2121

@@ -72,15 +72,20 @@ export const ChatHeader = memo(function ChatHeader({
7272
onDeleteProjectScript,
7373
}: ChatHeaderProps) {
7474
const primaryEnvironmentId = usePrimaryEnvironmentId();
75+
const sidebarOpen = useSidebarVisibility();
7576
const showOpenInPicker = shouldShowOpenInPicker({
7677
activeProjectName,
7778
activeThreadEnvironmentId,
7879
primaryEnvironmentId,
7980
});
8081
return (
8182
<div className="@container/header-actions flex min-w-0 flex-1 items-center gap-2 sm:gap-3">
82-
<div className="flex min-w-0 flex-1 items-center gap-2 overflow-hidden sm:gap-3">
83-
<SidebarTrigger className="size-7 shrink-0 md:hidden" />
83+
<div
84+
className={cn(
85+
"flex min-w-0 flex-1 items-center gap-2 overflow-hidden transition-[padding-left] duration-200 ease-linear motion-reduce:transition-none sm:gap-3",
86+
!sidebarOpen && "pl-[calc(var(--workspace-controls-left)+2.5rem)]",
87+
)}
88+
>
8489
<Tooltip>
8590
<TooltipTrigger
8691
render={

apps/web/src/components/ui/sidebar.tsx

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,11 @@ function useSidebar() {
8585
return context;
8686
}
8787

88+
function useSidebarVisibility() {
89+
const { isMobile, open, openMobile } = useSidebar();
90+
return isMobile ? openMobile : open;
91+
}
92+
8893
function SidebarProvider({
8994
defaultOpen = true,
9095
open: openProp,
@@ -310,13 +315,15 @@ function Sidebar({
310315
}
311316

312317
function SidebarTrigger({ className, onClick, ...props }: React.ComponentProps<typeof Button>) {
313-
const { toggleSidebar, openMobile } = useSidebar();
318+
const { toggleSidebar } = useSidebar();
319+
const isOpen = useSidebarVisibility();
314320

315321
return (
316322
<Button
317-
className={cn("size-7", className)}
323+
className={cn("size-7 sm:size-7", className)}
318324
data-sidebar="trigger"
319325
data-slot="sidebar-trigger"
326+
aria-pressed={isOpen}
320327
onClick={(event) => {
321328
onClick?.(event);
322329
toggleSidebar();
@@ -325,7 +332,7 @@ function SidebarTrigger({ className, onClick, ...props }: React.ComponentProps<t
325332
variant="ghost"
326333
{...props}
327334
>
328-
{openMobile ? <PanelLeftCloseIcon /> : <PanelLeftIcon />}
335+
{isOpen ? <PanelLeftCloseIcon /> : <PanelLeftIcon />}
329336
<span className="sr-only">Toggle Sidebar</span>
330337
</Button>
331338
);
@@ -1004,4 +1011,5 @@ export {
10041011
SidebarSeparator,
10051012
SidebarTrigger,
10061013
useSidebar,
1014+
useSidebarVisibility,
10071015
};

apps/web/src/index.css

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,15 @@
77
:root {
88
--workspace-topbar-height: 52px;
99
--workspace-controls-top: 0px;
10+
--workspace-controls-left: calc(env(safe-area-inset-left) + 0.75rem);
1011
--workspace-controls-right: calc(env(safe-area-inset-right) + 0.75rem);
1112
--workspace-native-controls-inset: 0px;
1213
}
1314

1415
.wco {
1516
--workspace-topbar-height: env(titlebar-area-height, 52px);
1617
--workspace-controls-top: env(titlebar-area-y, 0px);
18+
--workspace-controls-left: calc(env(titlebar-area-x, 0px) + 0.75rem);
1719
--workspace-controls-right: calc(
1820
100vw - env(titlebar-area-width, 100vw) - env(titlebar-area-x, 0px) + 0.75rem
1921
);

apps/web/src/keybindings.test.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@ function compile(bindings: TestBinding[]): ResolvedKeybindingsConfig {
8585
}
8686

8787
const DEFAULT_BINDINGS = compile([
88+
{ shortcut: modShortcut("b"), command: "sidebar.toggle" },
8889
{ shortcut: modShortcut("j"), command: "terminal.toggle" },
8990
{ shortcut: modShortcut("b", { altKey: true }), command: "rightPanel.toggle" },
9091
{
@@ -312,6 +313,10 @@ describe("shortcutLabelForCommand", () => {
312313
});
313314

314315
it("returns effective labels for non-terminal commands", () => {
316+
assert.strictEqual(
317+
shortcutLabelForCommand(DEFAULT_BINDINGS, "sidebar.toggle", "MacIntel"),
318+
"⌘B",
319+
);
315320
assert.strictEqual(shortcutLabelForCommand(DEFAULT_BINDINGS, "chat.new", "MacIntel"), "⇧⌘O");
316321
assert.strictEqual(shortcutLabelForCommand(DEFAULT_BINDINGS, "diff.toggle", "Linux"), "Ctrl+D");
317322
assert.strictEqual(

0 commit comments

Comments
 (0)