Files
buzz/desktop/src/shared/hooks/useThreadPanelWidth.ts
thomaspblock 2b7be9340d feat(desktop): make Projects workspaces selectable (#6368)
## Summary

Projects workspaces now support consistent selection across projects,
repositories, reviews, tasks, and channel results. A user can collect
related entities, see the active selection without losing their current
workspace, and pass a bounded, injection-safe representation to an agent
for discussion.

Detail views now keep repository source and availability explicit while
preserving contextual actions across local and remote workspaces.
Missing or inaccessible repositories produce a recoverable state instead
of leaving the workspace ambiguous.

This is part 2 of the Projects v6 stack. Parts 3 and 4 will add
context-aware collaboration and navigation/detail-page polish.

### Related issue

Related: #6335

### Testing

- Full pre-push gate passed: file-size checks, Biome, TypeScript,
Desktop unit tests, Tauri checks, and Rust tests
- Project context safety suite: 9/9 passed, including bounded untrusted
overview data and hidden-context stripping
- Updated Playwright coverage exercises grouped selection,
selection-aware agent context, repository source states, and detail
actions

---------

Signed-off-by: Thomas Petersen <thomasp@squareup.com>
2026-08-20 07:42:02 -07:00

144 lines
4.1 KiB
TypeScript

import * as React from "react";
import {
AUXILIARY_PANEL_DEFAULT_WIDTH_PX,
AUXILIARY_PANEL_MIN_WIDTH_PX,
getAuxiliaryPanelMaxWidth,
} from "@/shared/layout/AuxiliaryPanel";
const THREAD_PANEL_WIDTH_SESSION_KEY = "buzz.desktop.thread-panel-width";
type ThreadPanelWidthOptions = {
defaultWidthPx?: number;
minWidthPx?: number;
sessionKey?: string;
};
function getViewportWidth(): number {
return typeof window === "undefined" ? 0 : window.innerWidth;
}
/**
* Clamp the stored panel width for the current viewport.
*
* The upper bound grows with the viewport (see {@link clampAuxiliaryPanelWidth}) so
* the pane can expand on ultrawide displays. `AuxiliaryPanelShell` additionally
* clamps the rendered width to `calc(100% - MIN)` at paint time, so a stored width
* larger than the current viewport never collapses the main pane.
*/
function clampThreadPanelWidth(width: number, minWidthPx: number): number {
return Math.max(
minWidthPx,
Math.min(getAuxiliaryPanelMaxWidth(getViewportWidth()), width),
);
}
function getInitialThreadPanelWidth({
defaultWidthPx,
minWidthPx,
sessionKey,
}: Required<ThreadPanelWidthOptions>): number {
if (typeof window === "undefined") {
return defaultWidthPx;
}
try {
const raw = window.sessionStorage.getItem(sessionKey);
if (!raw) {
return defaultWidthPx;
}
const parsed = Number.parseInt(raw, 10);
if (!Number.isFinite(parsed)) {
return defaultWidthPx;
}
return clampThreadPanelWidth(parsed, minWidthPx);
} catch {
return defaultWidthPx;
}
}
export function useThreadPanelWidth(
availableWidthPx?: number,
options: ThreadPanelWidthOptions = {},
) {
const defaultWidthPx =
options.defaultWidthPx ?? AUXILIARY_PANEL_DEFAULT_WIDTH_PX;
const minWidthPx = options.minWidthPx ?? AUXILIARY_PANEL_MIN_WIDTH_PX;
const sessionKey = options.sessionKey ?? THREAD_PANEL_WIDTH_SESSION_KEY;
const getAvailableWidth = React.useCallback(
() => availableWidthPx ?? getViewportWidth(),
[availableWidthPx],
);
const [widthPx, setWidthPx] = React.useState<number>(() =>
getInitialThreadPanelWidth({ defaultWidthPx, minWidthPx, sessionKey }),
);
const [isResizing, setIsResizing] = React.useState(false);
React.useEffect(() => {
if (typeof window === "undefined") {
return;
}
try {
window.sessionStorage.setItem(sessionKey, String(widthPx));
} catch {
// Ignore storage failures and keep in-memory width for this session.
}
}, [sessionKey, widthPx]);
const onResizeStart = React.useCallback(
(event: React.PointerEvent<HTMLButtonElement>) => {
event.preventDefault();
const startX = event.clientX;
const startWidth = widthPx;
const previousCursor = document.body.style.cursor;
const previousUserSelect = document.body.style.userSelect;
document.body.style.cursor = "col-resize";
document.body.style.userSelect = "none";
setIsResizing(true);
const handlePointerMove = (moveEvent: PointerEvent) => {
const deltaX = startX - moveEvent.clientX;
const nextWidth = Math.max(
minWidthPx,
Math.min(
getAuxiliaryPanelMaxWidth(getAvailableWidth()),
startWidth + deltaX,
),
);
setWidthPx(nextWidth);
};
const handlePointerEnd = () => {
document.body.style.cursor = previousCursor;
document.body.style.userSelect = previousUserSelect;
setIsResizing(false);
window.removeEventListener("pointermove", handlePointerMove);
window.removeEventListener("pointerup", handlePointerEnd);
window.removeEventListener("pointercancel", handlePointerEnd);
};
window.addEventListener("pointermove", handlePointerMove);
window.addEventListener("pointerup", handlePointerEnd);
window.addEventListener("pointercancel", handlePointerEnd);
},
[getAvailableWidth, minWidthPx, widthPx],
);
const onResetWidth = React.useCallback(() => {
setWidthPx(defaultWidthPx);
}, [defaultWidthPx]);
return {
canReset: widthPx !== defaultWidthPx,
isResizing,
onResetWidth,
onResizeStart,
widthPx,
};
}