Skip to content
Closed
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
8 changes: 7 additions & 1 deletion src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -202,7 +202,13 @@ export function AppContent({
/>
<SidebarInset className="overflow-hidden">
<div className="flex flex-col h-full">
<TitleBar onToggleLeftSidebar={detachedProject ? undefined : leftSidebar.toggleSidebar} />
<TitleBar
onToggleLeftSidebar={
detachedProject || (leftSidebar.state === "expanded" && !leftSidebar.isMobile)
? undefined
: leftSidebar.toggleSidebar
}
/>

<div className="flex-1 flex flex-col min-w-0 min-h-0 select-none">
{isBooting && (
Expand Down
2 changes: 1 addition & 1 deletion src/components/AppSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -426,7 +426,7 @@ export function AppSidebar({
});

return (
<Sidebar collapsible="icon" className="select-none relative">
<Sidebar collapsible="offcanvas" className="select-none">
{profileOpen && <ProfilePanel onClose={() => setProfileOpen(false)} />}
<div
ref={selectionAreaRef}
Expand Down
5 changes: 5 additions & 0 deletions src/components/message-list/MessageListTranscript.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { useMemo } from "react";
import { MessageListTrailing } from "@/components/message-list/MessageListTrailing";
import type { useMessageListModel } from "@/components/message-list/useMessageListModel";
import { useMessageListTranscriptRows } from "@/features/session-transcript/build-message-list-transcript-rows";
import { buildPromptMarkers } from "@/features/session-transcript/transcript-prompt-markers";
import { TranscriptMessageRow } from "@/features/session-transcript/transcript-rows";
import { TranscriptViewport } from "@/features/session-transcript/transcript-viewport";
import { useActions } from "@/hooks/use-agent-state";
Expand Down Expand Up @@ -50,6 +52,8 @@ export function MessageListTranscript({ model }: { model: MessageListModel }) {
revertToMessage,
});

const promptMarkers = useMemo(() => buildPromptMarkers(rows), [rows]);

const contentKey = `${activeSessionId ?? ""}:${transcriptRevision}:${visibleMessages.length}:${isBusy ? 1 : 0}`;

const trailingContent = (
Expand All @@ -76,6 +80,7 @@ export function MessageListTranscript({ model }: { model: MessageListModel }) {
onLoadOlder={loadOlderMessages}
showLoadOlderRow={messageHistoryHasMore}
trailingContent={trailingContent}
promptMarkers={promptMarkers}
>
{rows.map((row) => (
<TranscriptMessageRow
Expand Down
25 changes: 22 additions & 3 deletions src/components/sidebar/SidebarHeaderContent.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { Search, SquarePen, X } from "lucide-react";
import { PanelLeftIcon, Search, SquarePen, X } from "lucide-react";
import type { ReactNode, RefObject } from "react";
import { Input } from "@/components/ui/input";
import { SidebarHeader } from "@/components/ui/sidebar";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { SidebarHeader, useSidebar } from "@/components/ui/sidebar";
import appIcon from "../../../assets/app-icon.svg";
import openguiLogoDark from "../../../assets/opengui-dark.svg";
import openguiLogoLight from "../../../assets/opengui-light.svg";
Expand Down Expand Up @@ -37,12 +39,15 @@ export function SidebarHeaderContent({
projectSelector?: ReactNode;
selectionActions?: ReactNode;
}) {
const { t } = useTranslation();
const { toggleSidebar } = useSidebar();

return (
<SidebarHeader className="border-b border-sidebar-border p-0 gap-0 group-data-[collapsible=icon]:p-2">
{selectionActions ?? (
<>
<div
className="flex items-center justify-center gap-2 h-9 shrink-0 border-b border-sidebar-border group-data-[collapsible=icon]:h-auto group-data-[collapsible=icon]:border-b-0"
className="flex items-center justify-start gap-3 h-9 shrink-0 border-b border-sidebar-border px-2 group-data-[collapsible=icon]:h-auto group-data-[collapsible=icon]:justify-center group-data-[collapsible=icon]:border-b-0"
style={
{
WebkitAppRegion: "drag",
Expand All @@ -51,6 +56,20 @@ export function SidebarHeaderContent({
} as React.CSSProperties
}
>
{!detachedProject && (
<Button
data-sidebar="trigger"
variant="ghost"
size="icon"
className="size-7 shrink-0"
style={{ WebkitAppRegion: "no-drag" } as React.CSSProperties}
title={`${t("workspace.hideSidebar")} (Ctrl+B)`}
onClick={toggleSidebar}
>
<PanelLeftIcon />
<span className="sr-only">{t("workspace.hideSidebar")}</span>
</Button>
)}
<img
src={appIcon}
alt="OpenGUI"
Expand Down
45 changes: 45 additions & 0 deletions src/components/ui/sidebar-resize.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
// @vitest-environment happy-dom

import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, test, vi } from "vite-plus/test";

vi.mock("react-i18next", () => ({ useTranslation: () => ({ t: (key: string) => key }) }));
vi.mock("@/hooks/use-mobile", () => ({ useIsMobile: () => false }));

import { Sidebar, SidebarProvider } from "./sidebar";
import { clampSidebarWidth, SIDEBAR_DEFAULT_WIDTH_PX } from "@/hooks/use-sidebar-width";
import { STORAGE_KEYS } from "@/lib/constants";
import { storageSet } from "@/lib/persistence/storage";

function renderSidebar() {
return render(
<SidebarProvider>
<Sidebar collapsible="offcanvas">content</Sidebar>
</SidebarProvider>,
);
}

describe("sidebar resizing", () => {
afterEach(() => {
cleanup();
storageSet(STORAGE_KEYS.SIDEBAR_WIDTH, String(SIDEBAR_DEFAULT_WIDTH_PX));
});

test("clamps widths to the supported range", () => {
expect(clampSidebarWidth(50)).toBe(200);
expect(clampSidebarWidth(9999)).toBe(480);
expect(clampSidebarWidth(Number.NaN)).toBe(SIDEBAR_DEFAULT_WIDTH_PX);
});

test("resizes with the keyboard and resets on double-click", () => {
const view = renderSidebar();
const wrapper = view.container.querySelector<HTMLElement>("[data-slot=sidebar-wrapper]")!;
const handle = screen.getByRole("separator", { name: "workspace.resizeSidebar" });

fireEvent.keyDown(handle, { key: "ArrowRight" });
expect(wrapper.style.getPropertyValue("--sidebar-width")).toBe("272px");

fireEvent.doubleClick(handle);
expect(wrapper.style.getPropertyValue("--sidebar-width")).toBe(`${SIDEBAR_DEFAULT_WIDTH_PX}px`);
});
});
94 changes: 90 additions & 4 deletions src/components/ui/sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,13 @@ import {
import { Skeleton } from "@/components/ui/skeleton";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { useSidebarController } from "./use-sidebar-controller";
import {
SIDEBAR_DEFAULT_WIDTH_PX,
SIDEBAR_MAX_WIDTH_PX,
SIDEBAR_MIN_WIDTH_PX,
clampSidebarWidth,
useSidebarWidth,
} from "@/hooks/use-sidebar-width";

function Slot({ children, ...props }: { children?: React.ReactNode; [key: string]: unknown }) {
if (React.isValidElement(children)) {
Expand All @@ -27,7 +34,6 @@ function Slot({ children, ...props }: { children?: React.ReactNode; [key: string

Slot.Root = Slot as React.ElementType;

const SIDEBAR_WIDTH = "16rem";
const SIDEBAR_WIDTH_MOBILE = "18rem";
const SIDEBAR_WIDTH_ICON = "3rem";

Expand Down Expand Up @@ -75,6 +81,7 @@ function SidebarProvider({
// We add a state so that we can do data-state="expanded" or "collapsed".
// This makes it easier to style the sidebar with Tailwind classes.
const state = open ? "expanded" : "collapsed";
const [sidebarWidth] = useSidebarWidth();

const contextValue = React.useMemo<SidebarContextProps>(
() => ({
Expand All @@ -96,7 +103,7 @@ function SidebarProvider({
data-slot="sidebar-wrapper"
style={
{
"--sidebar-width": SIDEBAR_WIDTH,
"--sidebar-width": `${sidebarWidth}px`,
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
...style,
} as React.CSSProperties
Expand Down Expand Up @@ -183,7 +190,7 @@ function Sidebar({
<div
data-slot="sidebar-gap"
className={cn(
"relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear",
"relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear in-data-[sidebar-resizing=true]:transition-none",
"group-data-[collapsible=offcanvas]:w-0",
"group-data-[side=right]:rotate-180",
variant === "floating" || variant === "inset"
Expand All @@ -194,7 +201,7 @@ function Sidebar({
<div
data-slot="sidebar-container"
className={cn(
"app-safe-sidebar fixed z-10 hidden w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear lg:flex",
"app-safe-sidebar fixed z-10 hidden w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear in-data-[sidebar-resizing=true]:transition-none lg:flex",
side === "left"
? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]"
: "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]",
Expand All @@ -213,11 +220,90 @@ function Sidebar({
>
{children}
</div>
{side === "left" && <SidebarResizeHandle />}
</div>
</div>
);
}

const SIDEBAR_RESIZE_KEYBOARD_STEP_PX = 16;

/** Drag handle on the sidebar's inner edge; double-click restores the default width. */
function SidebarResizeHandle() {
const { t } = useTranslation();
const { state } = useSidebar();
const [width, setWidth] = useSidebarWidth();
const dragRef = React.useRef<{ startX: number; startWidth: number; wrapper: HTMLElement } | null>(
null,
);

if (state === "collapsed") return null;

const endDrag = (event: React.PointerEvent<HTMLDivElement>) => {
const drag = dragRef.current;
if (!drag) return;
dragRef.current = null;
delete drag.wrapper.dataset.sidebarResizing;
document.body.style.removeProperty("cursor");
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
event.currentTarget.releasePointerCapture(event.pointerId);
}
setWidth(drag.startWidth + event.clientX - drag.startX);
};

return (
// biome-ignore lint/a11y/useSemanticElements: A focusable separator is the ARIA pattern for resizable panes.
<div
role="separator"
aria-orientation="vertical"
aria-label={t("workspace.resizeSidebar")}
aria-valuemin={SIDEBAR_MIN_WIDTH_PX}
aria-valuemax={SIDEBAR_MAX_WIDTH_PX}
aria-valuenow={width}
tabIndex={0}
title={t("workspace.resizeSidebar")}
data-slot="sidebar-resize-handle"
className="absolute inset-y-0 -right-1 z-20 w-2 cursor-col-resize touch-none outline-none after:absolute after:inset-y-0 after:left-1/2 after:w-0.5 after:-translate-x-1/2 after:bg-transparent after:transition-colors hover:after:bg-sidebar-border focus-visible:after:bg-ring data-[dragging=true]:after:bg-ring"
style={{ WebkitAppRegion: "no-drag" } as React.CSSProperties}
onPointerDown={(event) => {
if (event.button !== 0) return;
const wrapper = event.currentTarget.closest<HTMLElement>("[data-slot=sidebar-wrapper]");
if (!wrapper) return;
event.preventDefault();
event.currentTarget.setPointerCapture(event.pointerId);
event.currentTarget.dataset.dragging = "true";
wrapper.dataset.sidebarResizing = "true";
document.body.style.cursor = "col-resize";
dragRef.current = { startX: event.clientX, startWidth: width, wrapper };
}}
onPointerMove={(event) => {
const drag = dragRef.current;
if (!drag) return;
const next = clampSidebarWidth(drag.startWidth + event.clientX - drag.startX);
// Live-update the CSS variable; the setting is persisted once on release.
drag.wrapper.style.setProperty("--sidebar-width", `${next}px`);
}}
onPointerUp={(event) => {
delete event.currentTarget.dataset.dragging;
endDrag(event);
}}
onPointerCancel={(event) => {
delete event.currentTarget.dataset.dragging;
endDrag(event);
}}
onDoubleClick={() => setWidth(SIDEBAR_DEFAULT_WIDTH_PX)}
onKeyDown={(event) => {
if (event.key === "ArrowLeft") setWidth(width - SIDEBAR_RESIZE_KEYBOARD_STEP_PX);
else if (event.key === "ArrowRight") setWidth(width + SIDEBAR_RESIZE_KEYBOARD_STEP_PX);
else if (event.key === "Home") setWidth(SIDEBAR_MIN_WIDTH_PX);
else if (event.key === "End") setWidth(SIDEBAR_MAX_WIDTH_PX);
else return;
event.preventDefault();
}}
/>
);
}

function SidebarTrigger({ className, onClick, ...props }: React.ComponentProps<typeof Button>) {
const { t } = useTranslation();
const { toggleSidebar } = useSidebar();
Expand Down
99 changes: 99 additions & 0 deletions src/features/session-transcript/transcript-navigation.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
// @vitest-environment happy-dom

import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, test, vi } from "vite-plus/test";

vi.mock("react-i18next", () => ({
useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }),
}));

import { TranscriptViewport } from "./transcript-viewport";

function setGeometry(
element: HTMLElement,
geometry: { scrollHeight: number; clientHeight: number; scrollTop: number },
) {
Object.defineProperties(element, {
scrollHeight: { configurable: true, get: () => geometry.scrollHeight },
clientHeight: { configurable: true, get: () => geometry.clientHeight },
scrollTop: {
configurable: true,
get: () => geometry.scrollTop,
set: (value) => {
geometry.scrollTop = value;
},
},
});
}

function renderViewport(contentKey: string) {
return (
<TranscriptViewport
sessionId="s1"
contentKey={contentKey}
pinWhenNearBottom={false}
isLoadingOlder={false}
onLoadOlder={async () => true}
showLoadOlderRow={false}
>
<div>message</div>
</TranscriptViewport>
);
}

describe("TranscriptNavigation", () => {
beforeEach(() => {
vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => {
callback(0);
return 1;
});
vi.stubGlobal(
"ResizeObserver",
class {
observe() {}
disconnect() {}
},
);
});
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});

test("offers a jump to the end once the user scrolls away and re-pins on click", () => {
render(renderViewport("1"));
const viewport = screen.getByText("message").parentElement!.parentElement!;
const button = screen.getByText("messageList.scrollToEnd").closest("button")!;
expect(button.getAttribute("aria-hidden")).toBe("true");

const geometry = { scrollHeight: 2000, clientHeight: 400, scrollTop: 200 };
setGeometry(viewport, geometry);
fireEvent.scroll(viewport);
expect(button.getAttribute("aria-hidden")).toBe("false");

fireEvent.click(button);
expect(geometry.scrollTop).toBe(1600);
expect(button.getAttribute("aria-hidden")).toBe("true");
});

test("flags new output that arrives while reading further up", () => {
const view = render(renderViewport("1"));
const viewport = screen.getByText("message").parentElement!.parentElement!;
setGeometry(viewport, { scrollHeight: 2000, clientHeight: 400, scrollTop: 200 });
fireEvent.scroll(viewport);
expect(screen.queryByText("messageList.newMessages")).toBeNull();

view.rerender(renderViewport("2"));
expect(screen.getByText("messageList.newMessages")).toBeTruthy();
});

test("Alt+End jumps to the end", () => {
render(renderViewport("1"));
const viewport = screen.getByText("message").parentElement!.parentElement!;
const geometry = { scrollHeight: 2000, clientHeight: 400, scrollTop: 200 };
setGeometry(viewport, geometry);
viewport.getClientRects = () => [{}] as unknown as DOMRectList;
fireEvent.keyDown(window, { key: "End", altKey: true });
expect(geometry.scrollTop).toBe(1600);
});
});
Loading
Loading