mirror of
https://github.com/Sea-Haven-Industries/open-swe.git
synced 2026-10-01 13:13:14 +00:00
* feat: inline PR comments in the reviews UI
Click the diff gutter "+" on a line to open an inline comment composer
(rendered like the finding card via a Pierre annotation); submitting
posts a real inline PR review comment as the signed-in user through a
new POST /reviews/{owner}/{repo}/{number}/comments. The "+" press-drag →
"Add to Chat" selection path is unchanged.
* feat: GitHub-parity comment box, PR comments dropdown, collapse nav
- Comment composer now mirrors GitHub's box: Write/Preview tabs (markdown
rendered via the existing Markdown component) and a markdown toolbar
(heading, bold, italic, quote, code, link, bulleted/numbered/task list).
- Surface other people's inline PR comments in a Devin-style dropdown in the
review header (search + link to the thread on GitHub). New
GET /reviews/{owner}/{repo}/{number}/comments lists them and flags the
reviewer's own (marker-bearing) comments so they're filtered out.
- Collapse the global nav by default on a review detail page, restoring the
prior preference on leave.
* feat: bigger comment-toolbar icons; open dropdown comments inline
- Enlarge the markdown toolbar glyphs (Phosphor) in the comment composer —
they were rendering at 10px.
- Clicking a comment in the PR comments dropdown now opens it inline in the
diff as a read-only finding-style card (InlineComment), scrolling its line
into view, instead of navigating to GitHub. Falls back to GitHub when the
comment's file/line isn't in the current diff.
* fix: drive "Add to Chat" from native text selection
The gutter "+" is now comment-only; wiring its click to the composer
conflicted with its old double-duty as the drag-to-select handle, which
broke selection → "Add to Chat". Switch to Devin's model: disable Pierre's
interactive line selection and instead map a native text highlight in the
diff to a line range (via the data-line / data-line-type attributes Pierre
stamps on each line, read from the diff's open shadow root) to show the
"Add to Chat" popup. ⌘L and the existing attachment/popup path are unchanged.
* feat: gutter "+" drag selects a range for multi-line comments
Re-enable Pierre's gutter line selection so dragging the "+" down the
gutter comments across a range (click still comments on a single line);
onLineSelectionEnd routes the range to the composer. Native code-text
selection still drives "Add to Chat" — Pierre only line-selects from the
gutter, and onLineSelectionEnd bails when a native text selection is
present, so a code highlight never opens the composer.
* fix: keep the range highlighted while its comment composer is open
Previously opening the composer cleared the selection, so the lines being
commented on lost their highlight. Drive the controlled selection from the
open comment draft's range so the rows stay highlighted until the composer
is closed.
* fix: address PR review — paginate comments, fall back for outdated ones
- list_review_comments now pages through all PR review comments (bounded by
_MAX_REVIEW_COMMENT_PAGES) instead of returning only the first 100, so older
comments still show in the dropdown.
- Surface GitHub's outdated flag (position == null) as is_outdated; opening such
a comment (or one whose line isn't in the diff) now opens it on GitHub instead
of silently rendering nothing, plus a timeout fallback if the annotation never
mounts (e.g. collapsed context).
214 lines
6.5 KiB
TypeScript
214 lines
6.5 KiB
TypeScript
import {
|
|
createContext,
|
|
useCallback,
|
|
useContext,
|
|
useEffect,
|
|
useRef,
|
|
useState,
|
|
} from "react";
|
|
import { SidebarSimpleIcon } from "@phosphor-icons/react";
|
|
|
|
import { useHotkey } from "@/lib/hotkeys";
|
|
import { cn } from "@/lib/utils";
|
|
|
|
const STORAGE_WIDTH = "open-swe.sidebar.width";
|
|
const STORAGE_COLLAPSED = "open-swe.sidebar.collapsed";
|
|
|
|
export const SIDEBAR_DEFAULT_WIDTH = 260;
|
|
export const SIDEBAR_MIN_WIDTH = 200;
|
|
export const SIDEBAR_MAX_WIDTH = 420;
|
|
|
|
function readStoredWidth(): number {
|
|
if (typeof window === "undefined") return SIDEBAR_DEFAULT_WIDTH;
|
|
const raw = window.localStorage.getItem(STORAGE_WIDTH);
|
|
const parsed = raw ? Number(raw) : NaN;
|
|
if (!Number.isFinite(parsed)) return SIDEBAR_DEFAULT_WIDTH;
|
|
return Math.min(SIDEBAR_MAX_WIDTH, Math.max(SIDEBAR_MIN_WIDTH, parsed));
|
|
}
|
|
|
|
function readStoredCollapsed(): boolean {
|
|
if (typeof window === "undefined") return false;
|
|
// On mobile the sidebar is a full-screen overlay, so start collapsed to keep
|
|
// the chat visible regardless of the stored desktop preference.
|
|
if (window.matchMedia("(max-width: 767px)").matches) return true;
|
|
return window.localStorage.getItem(STORAGE_COLLAPSED) === "1";
|
|
}
|
|
|
|
export function useSidebarLayout() {
|
|
const [width, setWidthState] = useState<number>(() => readStoredWidth());
|
|
const [collapsed, setCollapsedState] = useState<boolean>(() => readStoredCollapsed());
|
|
|
|
const setWidth = useCallback((next: number) => {
|
|
const clamped = Math.min(SIDEBAR_MAX_WIDTH, Math.max(SIDEBAR_MIN_WIDTH, next));
|
|
setWidthState(clamped);
|
|
window.localStorage.setItem(STORAGE_WIDTH, String(clamped));
|
|
}, []);
|
|
|
|
const setCollapsed = useCallback((next: boolean) => {
|
|
setCollapsedState(next);
|
|
window.localStorage.setItem(STORAGE_COLLAPSED, next ? "1" : "0");
|
|
}, []);
|
|
|
|
const toggle = useCallback(() => setCollapsed(!collapsed), [collapsed, setCollapsed]);
|
|
|
|
useHotkey("mod+b", toggle, { enableInFormFields: true, ignoreRepeat: true });
|
|
|
|
const closeOnMobile = useCallback(() => {
|
|
if (typeof window === "undefined") return;
|
|
// State-only: don't persist, so the desktop collapsed preference is preserved.
|
|
if (window.matchMedia("(max-width: 767px)").matches) setCollapsedState(true);
|
|
}, []);
|
|
|
|
return { width, collapsed, setWidth, setCollapsed, toggle, closeOnMobile };
|
|
}
|
|
|
|
export type SidebarLayout = ReturnType<typeof useSidebarLayout>;
|
|
|
|
// Shares the single sidebar-layout instance with page content so it can react
|
|
// to the collapsed state (e.g. clear room for the fixed collapse toggle).
|
|
const SidebarLayoutContext = createContext<SidebarLayout | null>(null);
|
|
|
|
export function SidebarLayoutProvider({
|
|
value,
|
|
children,
|
|
}: {
|
|
value: SidebarLayout;
|
|
children: React.ReactNode;
|
|
}) {
|
|
return (
|
|
<SidebarLayoutContext.Provider value={value}>
|
|
{children}
|
|
</SidebarLayoutContext.Provider>
|
|
);
|
|
}
|
|
|
|
export function useSidebarCollapsed(): boolean {
|
|
return useContext(SidebarLayoutContext)?.collapsed ?? false;
|
|
}
|
|
|
|
// Full sidebar controls (collapse/expand) for pages that want to drive the nav,
|
|
// e.g. the review detail page collapsing it by default. Null outside the provider.
|
|
export function useSidebarControls(): SidebarLayout | null {
|
|
return useContext(SidebarLayoutContext);
|
|
}
|
|
|
|
interface SidebarFrameProps {
|
|
width: number;
|
|
setWidth: (next: number) => void;
|
|
collapsed: boolean;
|
|
toggle: () => void;
|
|
className?: string;
|
|
children: React.ReactNode;
|
|
}
|
|
|
|
export function SidebarFrame({
|
|
width,
|
|
setWidth,
|
|
collapsed,
|
|
toggle,
|
|
className,
|
|
children,
|
|
}: SidebarFrameProps) {
|
|
if (collapsed) {
|
|
return (
|
|
<button
|
|
type="button"
|
|
aria-label="Expand sidebar"
|
|
onClick={toggle}
|
|
className="fixed top-3 left-3 z-30 flex size-7 cursor-pointer items-center justify-center rounded-md border border-border bg-background text-muted-foreground shadow-sm hover:bg-accent hover:text-foreground"
|
|
>
|
|
<SidebarSimpleIcon className="size-4" />
|
|
</button>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<aside
|
|
style={{ width }}
|
|
className={cn(
|
|
"relative flex h-svh shrink-0 flex-col",
|
|
"max-md:fixed max-md:inset-0 max-md:z-40 max-md:!w-full",
|
|
"max-md:animate-in max-md:fade-in-0 max-md:slide-in-from-left-4 max-md:duration-200 max-md:ease-out",
|
|
className,
|
|
)}
|
|
>
|
|
{children}
|
|
<div className="max-md:hidden">
|
|
<ResizeHandle width={width} onResize={setWidth} />
|
|
</div>
|
|
</aside>
|
|
);
|
|
}
|
|
|
|
function ResizeHandle({ width, onResize }: { width: number; onResize: (next: number) => void }) {
|
|
const startRef = useRef<{ x: number; width: number } | null>(null);
|
|
const [dragging, setDragging] = useState(false);
|
|
|
|
const onPointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
|
|
e.preventDefault();
|
|
startRef.current = { x: e.clientX, width };
|
|
setDragging(true);
|
|
e.currentTarget.setPointerCapture(e.pointerId);
|
|
};
|
|
|
|
const onPointerMove = (e: React.PointerEvent<HTMLDivElement>) => {
|
|
if (!startRef.current) return;
|
|
const next = startRef.current.width + (e.clientX - startRef.current.x);
|
|
onResize(next);
|
|
};
|
|
|
|
const onPointerUp = (e: React.PointerEvent<HTMLDivElement>) => {
|
|
startRef.current = null;
|
|
setDragging(false);
|
|
if (e.currentTarget.hasPointerCapture(e.pointerId)) {
|
|
e.currentTarget.releasePointerCapture(e.pointerId);
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
if (!dragging) return;
|
|
const prev = document.body.style.cursor;
|
|
document.body.style.cursor = "col-resize";
|
|
return () => {
|
|
document.body.style.cursor = prev;
|
|
};
|
|
}, [dragging]);
|
|
|
|
return (
|
|
<div
|
|
role="separator"
|
|
aria-orientation="vertical"
|
|
onPointerDown={onPointerDown}
|
|
onPointerMove={onPointerMove}
|
|
onPointerUp={onPointerUp}
|
|
onPointerCancel={onPointerUp}
|
|
className={cn(
|
|
"absolute top-0 right-0 z-20 h-full w-1 cursor-col-resize touch-none select-none",
|
|
"after:absolute after:inset-y-0 after:right-0 after:w-px after:bg-transparent after:transition-colors",
|
|
"hover:after:bg-border",
|
|
dragging && "after:bg-border",
|
|
)}
|
|
/>
|
|
);
|
|
}
|
|
|
|
interface SidebarCollapseButtonProps {
|
|
onToggle: () => void;
|
|
className?: string;
|
|
}
|
|
|
|
export function SidebarCollapseButton({ onToggle, className }: SidebarCollapseButtonProps) {
|
|
return (
|
|
<button
|
|
type="button"
|
|
aria-label="Collapse sidebar"
|
|
onClick={onToggle}
|
|
className={cn(
|
|
"flex size-6 shrink-0 cursor-pointer items-center justify-center rounded text-muted-foreground hover:bg-accent hover:text-foreground",
|
|
className,
|
|
)}
|
|
>
|
|
<SidebarSimpleIcon className="size-4" />
|
|
</button>
|
|
);
|
|
}
|