mirror of
https://github.com/block/buzz.git
synced 2026-10-29 05:21:31 +00:00
## 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>
144 lines
4.1 KiB
TypeScript
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,
|
|
};
|
|
}
|