Skip to content

Commit 8379ee4

Browse files
committed
feat(scroll): extract fade logic into hook and improve drag handling
- Add `useScrollFade` hook for reusable scroll fade mask styling - Replace `opacity-0` with `invisible` in ghost containers for proper Electron drag regions - Add drag control classes to overlay headers and interactive elements - Implement `.lightcode-content-over-drag-region--drag` for non-draggable pane headers - Refactor ChatPane, ProjectTreeView, GitReviewSidebar, and Sidebar to use new hook
1 parent a01ab5d commit 8379ee4

14 files changed

Lines changed: 187 additions & 79 deletions

File tree

src/renderer/components/layout/PageLayout.tsx

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -87,10 +87,13 @@ function SidebarHeaderRow(props: {
8787

8888
return (
8989
<>
90-
{/* Ghost container to measure uncollapsed width */}
90+
{/* Ghost container to measure uncollapsed width. `invisible` (not
91+
`opacity-0`) so the no-drag children inside don't contribute to
92+
Electron's draggable-region map and steal drag from the visible
93+
spacer in the actual header row. */}
9194
<div
9295
ref={fullContentRef}
93-
className={`pointer-events-none absolute left-0 top-0 flex w-max items-center gap-1.5 opacity-0${
96+
className={`pointer-events-none invisible absolute left-0 top-0 flex w-max items-center gap-1.5${
9497
isWindows() ? " pl-1" : ""
9598
}`}
9699
aria-hidden="true"

src/renderer/components/layout/PanelHeaderProjectName.tsx

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,17 @@ import { Tooltip } from "@heroui/react";
22
import { panelHeaderTooltipTriggerResetClass } from "@/renderer/components/layout/sidebarChrome";
33

44
/** Truncated project label with hover tooltip — used in right/bottom panel header bars. */
5-
export function PanelHeaderProjectName(props: { name: string; maxWidthClass: string }) {
5+
export function PanelHeaderProjectName(props: {
6+
name: string;
7+
maxWidthClass: string;
8+
triggerClassName?: string;
9+
}) {
10+
const triggerClassName = props.triggerClassName ? ` ${props.triggerClassName}` : "";
611
return (
712
<Tooltip delay={300}>
8-
<Tooltip.Trigger className={`${panelHeaderTooltipTriggerResetClass} ${props.maxWidthClass}`}>
13+
<Tooltip.Trigger
14+
className={`${panelHeaderTooltipTriggerResetClass} ${props.maxWidthClass}${triggerClassName}`}
15+
>
916
<span className="min-w-0 truncate text-left text-xs font-medium leading-none text-foreground @max-[400px]:text-[11px]">
1017
{props.name}
1118
</span>

src/renderer/components/layout/ProjectSidebarPanel.tsx

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -43,13 +43,15 @@ export function ProjectSidebarPanel(props: {
4343
};
4444
};
4545

46+
const dragCtl = "lightcode-overlay-header__controls";
47+
4648
return (
4749
<div className="flex h-full min-h-0 flex-col bg-[var(--content-background)]">
48-
<div className="flex h-8 shrink-0 items-center gap-1.5 border-b border-[color:var(--border)] px-3">
50+
<div className="lightcode-overlay-header flex h-8 shrink-0 items-center gap-1.5 border-b border-[color:var(--border)] px-3">
4951
{projectName ? (
5052
<div className="min-w-0">
5153
<Tooltip delay={300}>
52-
<Tooltip.Trigger>
54+
<Tooltip.Trigger className={dragCtl}>
5355
<div className="max-w-[100px] truncate text-xs font-medium text-foreground">
5456
{projectName}
5557
</div>
@@ -62,7 +64,7 @@ export function ProjectSidebarPanel(props: {
6264
{activeTab === "git" && onExpandGitToOverlay ? (
6365
<button
6466
type="button"
65-
className="rounded p-0.5 text-muted hover:text-foreground"
67+
className={`${dragCtl} rounded p-0.5 text-muted hover:text-foreground`}
6668
title="Open as page"
6769
onClick={onExpandGitToOverlay}
6870
>
@@ -71,7 +73,7 @@ export function ProjectSidebarPanel(props: {
7173
) : activeTab === "files" && onExpandFilesToOverlay ? (
7274
<button
7375
type="button"
74-
className="rounded p-0.5 text-muted hover:text-foreground"
76+
className={`${dragCtl} rounded p-0.5 text-muted hover:text-foreground`}
7577
title="Open as page"
7678
onClick={onExpandFilesToOverlay}
7779
>
@@ -81,7 +83,7 @@ export function ProjectSidebarPanel(props: {
8183
<div className="mx-0.5 h-3 w-px bg-border" />
8284
<button
8385
type="button"
84-
className={`rounded p-0.5 transition-colors ${
86+
className={`${dragCtl} rounded p-0.5 transition-colors ${
8587
activeTab === "files" ? "text-accent" : "text-muted hover:text-foreground"
8688
}`}
8789
title="Files"
@@ -94,7 +96,7 @@ export function ProjectSidebarPanel(props: {
9496
</button>
9597
<button
9698
type="button"
97-
className={`rounded p-0.5 transition-colors ${
99+
className={`${dragCtl} rounded p-0.5 transition-colors ${
98100
activeTab === "git" ? "text-accent" : "text-muted hover:text-foreground"
99101
}`}
100102
title="Git"
@@ -107,7 +109,7 @@ export function ProjectSidebarPanel(props: {
107109
</button>
108110
<button
109111
type="button"
110-
className="rounded p-0.5 text-muted hover:text-foreground"
112+
className={`${dragCtl} rounded p-0.5 text-muted hover:text-foreground`}
111113
title="Hide panel"
112114
onClick={onClose}
113115
>

src/renderer/components/layout/UnifiedRightPanel.tsx

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -55,15 +55,23 @@ export function UnifiedRightPanel(props: {
5555
};
5656
};
5757

58+
const dragCtl = "lightcode-overlay-header__controls";
59+
5860
return (
5961
<div className="flex h-full min-h-0 flex-col bg-[var(--content-background)]">
60-
<div className={panelHeaderRowClass}>
61-
{projectName && <PanelHeaderProjectName name={projectName} maxWidthClass="max-w-[100px]" />}
62+
<div className={`lightcode-overlay-header ${panelHeaderRowClass}`}>
63+
{projectName && (
64+
<PanelHeaderProjectName
65+
name={projectName}
66+
maxWidthClass="max-w-[100px]"
67+
triggerClassName={dragCtl}
68+
/>
69+
)}
6270
<div className="flex-1" />
6371
{activeTab === "git" && onExpandGitToOverlay && (
6472
<button
6573
type="button"
66-
className={panelHeaderIconButtonClass}
74+
className={`${dragCtl} ${panelHeaderIconButtonClass}`}
6775
title="Open as page"
6876
onClick={onExpandGitToOverlay}
6977
>
@@ -73,7 +81,7 @@ export function UnifiedRightPanel(props: {
7381
{activeTab === "files" && onExpandFilesToOverlay && (
7482
<button
7583
type="button"
76-
className={panelHeaderIconButtonClass}
84+
className={`${dragCtl} ${panelHeaderIconButtonClass}`}
7785
title="Open as page"
7886
onClick={onExpandFilesToOverlay}
7987
>
@@ -83,7 +91,7 @@ export function UnifiedRightPanel(props: {
8391
<div className="mx-0.5 h-3 w-px bg-border" />
8492
<button
8593
type="button"
86-
className={panelHeaderTabIconButtonClass(activeTab === "terminal")}
94+
className={`${dragCtl} ${panelHeaderTabIconButtonClass(activeTab === "terminal")}`}
8795
title="Terminal"
8896
onClick={() => {
8997
onTabChange("terminal");
@@ -94,7 +102,7 @@ export function UnifiedRightPanel(props: {
94102
</button>
95103
<button
96104
type="button"
97-
className={panelHeaderTabIconButtonClass(activeTab === "files")}
105+
className={`${dragCtl} ${panelHeaderTabIconButtonClass(activeTab === "files")}`}
98106
title="Files"
99107
onClick={() => {
100108
onTabChange("files");
@@ -105,7 +113,7 @@ export function UnifiedRightPanel(props: {
105113
</button>
106114
<button
107115
type="button"
108-
className={panelHeaderTabIconButtonClass(activeTab === "git")}
116+
className={`${dragCtl} ${panelHeaderTabIconButtonClass(activeTab === "git")}`}
109117
title="Git"
110118
onClick={() => {
111119
onTabChange("git");
@@ -116,7 +124,7 @@ export function UnifiedRightPanel(props: {
116124
</button>
117125
<button
118126
type="button"
119-
className={panelHeaderIconButtonClass}
127+
className={`${dragCtl} ${panelHeaderIconButtonClass}`}
120128
title="Hide panel"
121129
onClick={onClose}
122130
>

src/renderer/components/thread/ChatPane/ChatPane.tsx

Lines changed: 5 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import {
22
forwardRef,
3-
useCallback,
43
useEffect,
54
useEffectEvent,
65
useImperativeHandle,
@@ -15,6 +14,7 @@ import { useShallow } from "zustand/react/shallow";
1514
import { isThreadTurnActive, type Thread } from "@/shared/contracts";
1615
import { chatMessageSurfaceClass } from "./parts/items/chatMessageSurface";
1716
import { readBridge } from "@/renderer/bridge";
17+
import { useScrollFade } from "@/renderer/hooks/useScrollFade";
1818
import { useAppStore } from "@/renderer/state/appStore";
1919
import { hydrateThreadRuntimeItems } from "@/renderer/state/chatRuntimePersister";
2020
import {
@@ -76,53 +76,20 @@ export function ChatPane(props: ChatPaneProps) {
7676
layoutChangeToken,
7777
} = props;
7878
const { id: threadId, projectId, status, worktreePath, worktreeBranch } = thread;
79-
const scrollRef = useRef<HTMLDivElement>(null);
79+
const contentRef = useRef<HTMLDivElement>(null);
8080
// `scrollEl` mirrors `scrollRef.current` as React state so the virtualizer
8181
// in `MessageList` sees the element transition from `null` to mounted across
8282
// a real React render. Without this, after a drag-drop pane move the
8383
// virtualizer's internal observer-driven rerender can be lost and the chat
8484
// renders empty (with a scrollbar from `getTotalSize`) until the next state
8585
// change forces a recompute.
86-
const [scrollEl, setScrollEl] = useState<HTMLDivElement | null>(null);
87-
const setScrollContainer = useCallback((el: HTMLDivElement | null) => {
88-
scrollRef.current = el;
89-
setScrollEl(el);
90-
}, []);
91-
const contentRef = useRef<HTMLDivElement>(null);
86+
const { setScrollContainer, scrollRef, scrollEl, scrollFadeStyle } =
87+
useScrollFade<HTMLDivElement>({ contentRef });
9288
const [initialScrollSettledThreadId, setInitialScrollSettledThreadId] = useState<string | null>(
9389
null,
9490
);
9591
const isInitialScrollSettled = initialScrollSettledThreadId === threadId;
9692

97-
useEffect(() => {
98-
const el = scrollEl;
99-
if (!el) return;
100-
101-
const updateFades = () => {
102-
const { scrollTop, scrollHeight, clientHeight } = el;
103-
const topFade = Math.min(32, scrollTop);
104-
const bottomFade = Math.min(32, Math.max(0, scrollHeight - scrollTop - clientHeight));
105-
106-
el.style.setProperty("--top-fade-size", `${topFade}px`);
107-
el.style.setProperty("--bottom-fade-size", `${bottomFade}px`);
108-
};
109-
110-
updateFades();
111-
el.addEventListener("scroll", updateFades, { passive: true });
112-
113-
// Re-check when the container or its content resizes.
114-
const observer = new ResizeObserver(updateFades);
115-
observer.observe(el);
116-
if (contentRef.current) {
117-
observer.observe(contentRef.current);
118-
}
119-
120-
return () => {
121-
el.removeEventListener("scroll", updateFades);
122-
observer.disconnect();
123-
};
124-
}, [scrollEl]);
125-
12693
const scrollControlsRef = useRef<ChatScrollControlsHandle>(null);
12794
const virtualScrollToBottomRef = useRef<(() => void) | null>(null);
12895
const timelineEntries = useAppStore(
@@ -279,12 +246,7 @@ export function ChatPane(props: ChatPaneProps) {
279246
<div
280247
ref={setScrollContainer}
281248
className="min-h-0 h-full overflow-y-auto [overflow-anchor:none] [scrollbar-gutter:stable]"
282-
style={{
283-
WebkitMaskImage:
284-
"linear-gradient(to bottom, transparent, black var(--top-fade-size, 0px), black calc(100% - var(--bottom-fade-size, 0px)), transparent)",
285-
maskImage:
286-
"linear-gradient(to bottom, transparent, black var(--top-fade-size, 0px), black calc(100% - var(--bottom-fade-size, 0px)), transparent)",
287-
}}
249+
style={scrollFadeStyle}
288250
onWheelCapture={(event) => {
289251
if (event.deltaY < 0) {
290252
scrollControlsRef.current?.markUserScrollIntent();

src/renderer/components/thread/ThreadDraftView.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -968,7 +968,7 @@ export function ThreadDraftView(props: {
968968
<div className={`px-2 ${headerNeedsTrafficLightPad ? macosTrafficLightPadClass : ""}`}>
969969
<div
970970
ref={props.dragHandleRef}
971-
className={`lightcode-content-over-drag-region ${alignClass} flex w-full max-w-[920px] items-center gap-2 py-1 ${props.dragHandleRef ? "cursor-grab active:cursor-grabbing" : ""}`}
971+
className={`${props.dragHandleRef ? "lightcode-content-over-drag-region cursor-grab active:cursor-grabbing" : "lightcode-content-over-drag-region--drag"} ${alignClass} flex w-full max-w-[920px] items-center gap-2 py-1`}
972972
>
973973
<TerminalSquare className="size-3.5 shrink-0 text-muted/60" />
974974
<span className="min-w-0 flex-1 truncate text-sm font-medium leading-tight text-muted">
@@ -980,7 +980,7 @@ export function ThreadDraftView(props: {
980980
<button
981981
type="button"
982982
aria-label="Close pane"
983-
className="shrink-0 rounded p-1 text-muted/60 transition-colors hover:bg-white/[0.06] hover:text-foreground"
983+
className="lightcode-overlay-header__controls shrink-0 rounded p-1 text-muted/60 transition-colors hover:bg-white/[0.06] hover:text-foreground"
984984
onClick={(e) => {
985985
e.stopPropagation();
986986
props.onClose?.();

src/renderer/components/thread/ThreadHeaderStatus.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,7 @@ export function ThreadHeaderStatusButton(props: {
100100
<Tooltip.Trigger>
101101
<button
102102
type="button"
103-
className="inline-flex shrink-0 rounded-sm p-0.5 outline-offset-2 hover:bg-white/[0.06]"
103+
className="lightcode-overlay-header__controls inline-flex shrink-0 rounded-sm p-0.5 outline-offset-2 hover:bg-white/[0.06]"
104104
aria-label={`${props.agentLabel ?? props.fallbackAgentKind}: ${threadRuntimeStatusLabel(thread)}. Hover for status details.`}
105105
onClick={(e) => {
106106
e.stopPropagation();

src/renderer/components/thread/ThreadView.tsx

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -325,7 +325,7 @@ export const ThreadView = memo(function ThreadView(props: ThreadViewProps) {
325325
{/* Header bar — provider icon outside pane drag handle; status tooltip uses HeroUI tooltip (anchored bottom start). */}
326326
<div className={`px-2 ${headerNeedsTrafficLightPad ? macosTrafficLightPadClass : ""}`}>
327327
<div
328-
className={`lightcode-content-over-drag-region @container ${alignClass} flex w-full max-w-[920px] items-center gap-2 py-1`}
328+
className={`${dragHandleRef ? "lightcode-content-over-drag-region" : "lightcode-content-over-drag-region--drag"} @container ${alignClass} flex w-full max-w-[920px] items-center gap-2 py-1`}
329329
>
330330
<ThreadHeaderStatusButton
331331
threadId={thread.id}
@@ -380,7 +380,7 @@ export const ThreadView = memo(function ThreadView(props: ThreadViewProps) {
380380
<button
381381
type="button"
382382
aria-label="Continue in another provider"
383-
className="shrink-0 rounded p-1 text-muted/60 transition-colors hover:bg-white/[0.06] hover:text-foreground"
383+
className="lightcode-overlay-header__controls shrink-0 rounded p-1 text-muted/60 transition-colors hover:bg-white/[0.06] hover:text-foreground"
384384
onClick={(e) => {
385385
e.stopPropagation();
386386
setContinueDialogOpen(true);
@@ -401,7 +401,7 @@ export const ThreadView = memo(function ThreadView(props: ThreadViewProps) {
401401
runtimeDebugOpen ? "Hide runtime debug panel" : "Show runtime debug panel"
402402
}
403403
aria-pressed={runtimeDebugOpen}
404-
className={`shrink-0 rounded p-1 transition-colors hover:bg-white/[0.06] ${runtimeDebugOpen ? "text-foreground" : "text-muted/60 hover:text-foreground"}`}
404+
className={`lightcode-overlay-header__controls shrink-0 rounded p-1 transition-colors hover:bg-white/[0.06] ${runtimeDebugOpen ? "text-foreground" : "text-muted/60 hover:text-foreground"}`}
405405
onClick={(e) => {
406406
e.stopPropagation();
407407
setRuntimeDebugOpen((o) => !o);
@@ -421,7 +421,7 @@ export const ThreadView = memo(function ThreadView(props: ThreadViewProps) {
421421
<button
422422
type="button"
423423
aria-label={thread.done ? "Unmark done" : "Mark done"}
424-
className={`shrink-0 rounded p-1 transition-colors hover:bg-white/[0.06] ${thread.done ? "text-[oklch(0.78_0.1_180)]" : "text-muted/60 hover:text-foreground"}`}
424+
className={`lightcode-overlay-header__controls shrink-0 rounded p-1 transition-colors hover:bg-white/[0.06] ${thread.done ? "text-[oklch(0.78_0.1_180)]" : "text-muted/60 hover:text-foreground"}`}
425425
onClick={(e) => {
426426
e.stopPropagation();
427427
onMarkDone();
@@ -434,7 +434,7 @@ export const ThreadView = memo(function ThreadView(props: ThreadViewProps) {
434434
<button
435435
type="button"
436436
aria-label="Close pane"
437-
className="shrink-0 rounded p-1 text-muted/60 transition-colors hover:bg-white/[0.06] hover:text-foreground"
437+
className="lightcode-overlay-header__controls shrink-0 rounded p-1 text-muted/60 transition-colors hover:bg-white/[0.06] hover:text-foreground"
438438
onClick={(e) => {
439439
e.stopPropagation();
440440
onClose?.();

0 commit comments

Comments
 (0)