Skip to content
9 changes: 9 additions & 0 deletions app-prefixable/src/components/resize-handle.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,21 @@ export interface ResizeHandleProps {
max: number
onResize: (size: number) => void
onCollapse?: () => void
onDragEnd?: () => void
collapseThreshold?: number
class?: string
}

export function ResizeHandle(props: ResizeHandleProps) {
let cleanup: (() => void) | null = null
let dragging = false

onCleanup(() => {
if (cleanup) cleanup()
if (dragging) {
dragging = false
props.onDragEnd?.()
}
})

const handleMouseDown = (e: MouseEvent) => {
Expand All @@ -26,6 +32,7 @@ export function ResizeHandle(props: ResizeHandleProps) {
const startSize = props.size
let current = startSize

dragging = true
document.body.style.userSelect = "none"
document.body.style.overflow = "hidden"

Expand All @@ -45,6 +52,7 @@ export function ResizeHandle(props: ResizeHandleProps) {
}

const onMouseUp = () => {
dragging = false
document.body.style.userSelect = ""
document.body.style.overflow = ""
document.removeEventListener("mousemove", onMouseMove)
Expand All @@ -55,6 +63,7 @@ export function ResizeHandle(props: ResizeHandleProps) {
if (props.onCollapse && threshold > 0 && current < threshold) {
props.onCollapse()
}
props.onDragEnd?.()
}

cleanup = () => {
Expand Down
28 changes: 28 additions & 0 deletions app-prefixable/src/context/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ const LAYOUT_STORAGE_KEY = "opencode.layout";
// Default values
const DEFAULT_REVIEW_WIDTH = 320;
const DEFAULT_INFO_WIDTH = 256;
const DEFAULT_SIDEBAR_WIDTH = 256;
export const SIDEBAR_MIN_WIDTH = 180;
export const SIDEBAR_MAX_WIDTH = 480;

interface PanelState {
opened: boolean;
Expand All @@ -25,6 +28,7 @@ export type FileTab = {
interface LayoutState {
review: PanelState;
info: PanelState;
sidebar: { width?: number };
tabs?: FileTab[];
activeTab?: string | null; // null = Review tab, string = file path
}
Expand All @@ -48,6 +52,11 @@ interface LayoutContextValue {
close: () => void;
resize: (width: number) => void;
};
// Sidebar panel (sessions list)
sidebar: {
width: () => number;
resize: (width: number) => void;
};
// File tabs
tabs: {
all: () => FileTab[];
Expand Down Expand Up @@ -86,6 +95,9 @@ function loadState(): LayoutState {
opened: parsed.info?.opened ?? false,
width: parsed.info?.width ?? DEFAULT_INFO_WIDTH,
},
sidebar: {
width: parsed.sidebar?.width ?? DEFAULT_SIDEBAR_WIDTH,
},
tabs,
activeTab,
};
Expand All @@ -96,6 +108,7 @@ function loadState(): LayoutState {
return {
review: { opened: false, width: DEFAULT_REVIEW_WIDTH },
info: { opened: false, width: DEFAULT_INFO_WIDTH },
sidebar: { width: DEFAULT_SIDEBAR_WIDTH },
tabs: [],
activeTab: null,
};
Expand Down Expand Up @@ -124,6 +137,13 @@ export function LayoutProvider(props: ParentProps) {
initial.info.width ?? DEFAULT_INFO_WIDTH,
);

// Sidebar state (clamp loaded value to valid range)
const [sidebarWidth, setSidebarWidth] = createSignal(
Math.max(SIDEBAR_MIN_WIDTH, Math.min(SIDEBAR_MAX_WIDTH,
initial.sidebar.width ?? DEFAULT_SIDEBAR_WIDTH,
)),
Comment on lines +141 to +144
Copy link

Copilot AI Mar 4, 2026

Choose a reason for hiding this comment

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

The sidebar width clamping here can still produce NaN if initial.sidebar.width is non-numeric (e.g., corrupted localStorage). Math.min/max with NaN returns NaN, which later becomes an invalid CSS width. Consider validating with Number.isFinite(...) and falling back to DEFAULT_SIDEBAR_WIDTH before clamping.

Suggested change
const [sidebarWidth, setSidebarWidth] = createSignal(
Math.max(SIDEBAR_MIN_WIDTH, Math.min(SIDEBAR_MAX_WIDTH,
initial.sidebar.width ?? DEFAULT_SIDEBAR_WIDTH,
)),
const initialSidebarWidth =
typeof initial.sidebar.width === "number" &&
Number.isFinite(initial.sidebar.width)
? initial.sidebar.width
: DEFAULT_SIDEBAR_WIDTH;
const [sidebarWidth, setSidebarWidth] = createSignal(
Math.max(
SIDEBAR_MIN_WIDTH,
Math.min(SIDEBAR_MAX_WIDTH, initialSidebarWidth),
),

Copilot uses AI. Check for mistakes.
);

// File tabs state
const [fileTabs, setFileTabs] = createSignal<FileTab[]>(initial.tabs ?? []);
const [activeTab, setActiveTab] = createSignal<string | null>(
Expand All @@ -135,6 +155,7 @@ export function LayoutProvider(props: ParentProps) {
saveState({
review: { opened: reviewOpened(), width: reviewWidth() },
info: { opened: infoOpened(), width: infoWidth() },
sidebar: { width: sidebarWidth() },
tabs: fileTabs(),
activeTab: activeTab(),
});
Expand Down Expand Up @@ -181,6 +202,13 @@ export function LayoutProvider(props: ParentProps) {
persist();
},
},
sidebar: {
width: sidebarWidth,
resize: (width: number) => {
setSidebarWidth(Math.max(SIDEBAR_MIN_WIDTH, Math.min(SIDEBAR_MAX_WIDTH, width)));
Copy link

Copilot AI Mar 4, 2026

Choose a reason for hiding this comment

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

review.resize() and info.resize() both call persist() internally, but sidebar.resize() only updates the signal and requires callers to remember to call persist() separately. This inconsistency makes it easy to introduce future bugs where sidebar width changes aren’t saved (e.g., if another caller is added later). Consider persisting inside sidebar.resize() as well (or rename/split APIs so it’s explicit when persistence occurs).

Suggested change
setSidebarWidth(Math.max(SIDEBAR_MIN_WIDTH, Math.min(SIDEBAR_MAX_WIDTH, width)));
setSidebarWidth(Math.max(SIDEBAR_MIN_WIDTH, Math.min(SIDEBAR_MAX_WIDTH, width)));
persist();

Copilot uses AI. Check for mistakes.
persist();
},
},
tabs: {
all: fileTabs,
active: activeTab,
Expand Down
Loading